Compare commits

...

66 Commits

Author SHA1 Message Date
Dream Hunter
c97a9a278b docs: clarify Address JWT vs User JWT and reorganize API menu (#914)
- Add warning notes in new-address-api and mail-api docs
- Explain the difference between Address JWT and User JWT
- Create dedicated 'API Endpoints' section in sidebar
- Update both zh and en documentation

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

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

Fixes #912

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

* test: verify address_id in new_address response

* fix: add address_id validation and improve test coverage

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

---------

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

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

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

* docs: consolidate IMAP changelog entries into single line

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

---------

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

* Apply suggestions from code review

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

---------

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

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

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

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

* docs: mention Android via browser in changelog

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

* chore: add missing entries to worker .gitignore

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

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

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

---------

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

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

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

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

Closes #894

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

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

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

* refactor: remove unused i18n attachment messages

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

* refactor: use sendMediaGroup for batch attachment sending

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

* refactor: remove redundant commonParseMail call, use cached result

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: unify attachment sending with sendMediaGroup for all cases

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

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

* chore: reduce sendMediaGroup batch size to 6

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

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

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

* chore: regenerate wasm parser patch with attachments support

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

* feat: add ENABLE_TG_PUSH_ATTACHMENT env var to control attachment push

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

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

---------

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

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

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

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

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

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

* docs: add ENABLE_LOGIN_TURNSTILE_CHECK to wrangler.toml.template

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

* fix: ensure openSettings loaded before admin login modal

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

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

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

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

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

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

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

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

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

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

* fix: use unique IDs for multiple Turnstile instances

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

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

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

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

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

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

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

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

* fix: add enableLoginTurnstileCheck to store defaults, simplify changelog

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

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

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

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

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

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

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

* refactor: rename to ENABLE_GLOBAL_TURNSTILE_CHECK and add isGlobalTurnstileEnabled helper

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

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

* refactor: use utils.isGlobalTurnstileEnabled instead of named import

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

* test: add E2E tests for turnstile login endpoints

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

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

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

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

* fix: validate JWT payload has address field in credential_login

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* feat: upgrade @simplewebauthn packages from v10 to v13

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

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

* test: add passkey API E2E tests

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

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

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

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

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

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

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

* fix: allow crypto.subtle in Docker browser tests

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

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

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

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

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

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

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

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

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

* fix: store userJwt without JSON.stringify in localStorage

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

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

* refactor: clean up passkey API test per review feedback

Remove unused variables and rename test to match actual behavior.

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

---------

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

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

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

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

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

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

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

* feat: upgrade version to v1.5.0

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

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

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

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

* refactor: address CodeRabbit review suggestions

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

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

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

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

---------

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

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

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

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

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

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

* fix: use hyphenated cache-clearing in release skill

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: assert webhook request path in E2E test

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

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

---------

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

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

Closes #249

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

* test: add E2E tests for SMTP/IMAP STARTTLS

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

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

* fix: enforce auth over TLS when STARTTLS is configured

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

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

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

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

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

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

---------

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

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

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

* refactor: extract isAdmin() as shared utility function

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

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

* refactor: rename isAdmin to checkIsAdmin for consistency

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

* fix: address PR review comments

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

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

---------

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

Supersedes #872

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

Closes #864

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

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

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

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

---------

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

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

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

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

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

* chore: trigger CI

---------

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

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

Enable ENABLE_AUTO_REPLY in E2E wrangler config.

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

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

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

---------

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

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

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

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

---------

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

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

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

* test: remove unused address variable

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

---------

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

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

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

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

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

---------

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

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

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

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

* test: add XSS sanitization E2E screenshots

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

* chore: remove temporary screenshots from tree

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

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

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

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

---------

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

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

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

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

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

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

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

* refactor: remove unnecessary vitest exclude config

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

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

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

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

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

* test: add e2e screenshots for PR review

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

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

* chore: remove temporary test screenshots

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

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

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

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

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

* test: update e2e screenshots showing HTML formatting preserved

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

* chore: remove temporary screenshots from tree

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

* test: add SMTP send flow E2E screenshots with mailpit

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

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

* chore: remove temporary SMTP test screenshots from tree

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: update lock files and version

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

* fix: restore accessTokenFormat as optional field

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:30:44 +08:00
149 changed files with 10008 additions and 3617 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

View File

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

View File

@@ -16,24 +16,25 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v4
name: Install pnpm
id: pnpm-install
with:
version: 8
version: 10
run_install: false
- name: Deploy Backend for ${{ github.ref_name }}
run: |
export use_worker_assets=${{ secrets.USE_WORKER_ASSETS }}
export use_worker_assets_with_telegram=${{ secrets.USE_WORKER_ASSETS_WITH_TELEGRAM }}
if [ -n "$use_worker_assets" ]; then
cd frontend/
pnpm install --no-frozen-lockfile
@@ -49,8 +50,11 @@ jobs:
export debug_mode=${{ secrets.DEBUG_MODE }}
export use_mail_wasm_parser=${{ secrets.BACKEND_USE_MAIL_WASM_PARSER }}
cd worker/
echo '${{ secrets.BACKEND_TOML }}' > wrangler.toml
# ✅ 修复核心:使用环境变量写入,避免 Shell 解析特殊字符
printf '%s\n' "$WRANGLER_TOML_CONTENT" > wrangler.toml
pnpm install --no-frozen-lockfile
if [ -n "$use_mail_wasm_parser" ]; then
@@ -74,3 +78,5 @@ jobs:
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# ✅ 将 secret 映射到环境变量中
WRANGLER_TOML_CONTENT: ${{ secrets.BACKEND_TOML }}

View File

@@ -15,20 +15,20 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v4
name: Install pnpm
id: pnpm-install
with:
version: 8
version: 10
run_install: false
- name: check github release done

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

@@ -10,55 +10,76 @@ on:
workflow_dispatch:
jobs:
deploy:
deploy-frontend:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@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
export frontend_branch=${{ secrets.FRONTEND_BRANCH }}
export frontend_branch="${{ secrets.FRONTEND_BRANCH }}"
if [ -n "$frontend_branch" ]; then
echo "Deploying branch $frontend_branch"
pnpm run deploy:actions --project-name=$project_name --branch $frontend_branch
pnpm run deploy:actions --project-name=$FRONTEND_NAME --branch $frontend_branch
else
echo "Deploying branch production"
pnpm run deploy --project-name=$project_name
pnpm run deploy --project-name=$FRONTEND_NAME
fi
echo "Deploying production for ${{ github.ref_name }}"
echo "Deployed for tag ${{ github.ref_name }}"
export tg_mini_app_project_name=${{ secrets.TG_FRONTEND_NAME }}
if [ -n "$tg_mini_app_project_name" ]; then
echo "Deploying telegram mini app $tg_mini_app_project_name"
if [ -n "$frontend_branch" ]; then
echo "Deploying telegram mini app branch $frontend_branch"
pnpm run deploy:actions:telegram --project-name=$tg_mini_app_project_name --branch $frontend_branch
else
echo "Deploying telegram mini app branch production"
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
fi
echo "Deployed telegram mini app for ${{ github.ref_name }}"
fi
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FRONTEND_NAME: ${{ secrets.FRONTEND_NAME }}
deploy-telegram-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- uses: pnpm/action-setup@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

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

View File

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

View File

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

View File

@@ -6,24 +6,21 @@ on:
- "*"
jobs:
build:
build-frontend:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v4
name: Install pnpm
id: pnpm-install
with:
version: 8
version: 10
run_install: false
- name: Build Frontend
@@ -32,12 +29,58 @@ jobs:
- name: Zip Frontend dist
run: cd frontend/dist/ && zip -r frontend.zip * && mv frontend.zip ../
- name: Upload artifact
uses: actions/upload-artifact@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
@@ -57,11 +100,34 @@ jobs:
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/frontend.zip
frontend/telegram-frontend.zip
worker/worker.js
worker/worker-with-wasm-mail-parser.zip
artifacts/frontend/frontend.zip
artifacts/telegram-frontend/telegram-frontend.zip
artifacts/worker-js/worker.js
artifacts/worker-wasm/worker-with-wasm-mail-parser.zip

5
.gitignore vendored
View File

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

View File

@@ -1,35 +0,0 @@
# Repository Guidelines
## Project Structure
- Backend: `worker/` (Workers API; entry `worker/src/worker.ts`, APIs under `worker/src/*_api/`).
- Frontend: `frontend/` (Vue 3 app; routes in `frontend/src/router/`).
- Pages middleware: `pages/functions/_middleware.js`.
- Mail parser: `mail-parser-wasm/` (Rust WASM).
- SMTP/IMAP proxy: `smtp_proxy_server/`.
- DB schema/migrations: `db/`.
- Docs: `vitepress-docs/`.
## Build & Dev Commands
Run inside each folder:
- Frontend: `pnpm dev`, `pnpm build`.
- Worker: `pnpm dev`, `pnpm lint`, `pnpm build`.
- Pages: `pnpm dev`.
- Docs: `pnpm dev` in `vitepress-docs/`.
- WASM: `wasm-pack build --release` in `mail-parser-wasm/`.
- SMTP proxy: `pip install -r smtp_proxy_server/requirements.txt` then `python smtp_proxy_server/main.py`.
## Coding Style
- Follow existing module style. `worker/` uses TypeScript + ESLint; `frontend/` uses Vue SFCs.
- Keep current naming patterns: `*_api/`, `utils/`, `models/`.
- ESM imports only (`type: module`).
## Testing
- No formal test runner. Validate with local dev servers and key flows (login, inbox, send/receive).
## Commits & PRs
- Use Conventional Commits (`feat:`, `fix:`, `docs:`). Recent history includes PR numbers like `(#123)`.
- PRs should explain scope and add screenshots for UI changes.
## Config Tips
- Worker settings in `worker/wrangler.toml` (see template for bindings).
- Frontend uses `VITE_*` env vars. Dont commit secrets.

View File

@@ -6,7 +6,88 @@
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
</p>
## v1.2.1(main)
## v1.5.0(main)
### Features
- feat: |Admin API| `/admin/new_address` 接口返回值新增 `address_id` 字段,避免创建后需再次查询地址 ID#912
- 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: |API| 新增地址 JWT 与用户 JWT 的区分说明,避免混淆两种认证方式;调整文档菜单结构,将 API 接口文档归类到独立分组(#910
- docs: |Telegram| 新增每用户邮件推送和全局推送功能说明文档(#769
- docs: |Webhook| 新增 Telegram Bot、企业微信、Discord 等常用推送平台的 Webhook 模板示例
- feat: |Webhook| 前端预设模板新增 Telegram Bot、企业微信、Discord 三个模板
### Improvements
## v1.4.0
### Features
- feat: |用户注册| 新增用户注册邮箱正则校验功能,管理员可配置邮箱格式验证规则
- feat: |前端| 新增可配置的 Status 菜单按钮,通过 `STATUS_URL` 环境变量配置状态监控页面链接
- feat: |SMTP| SMTP 代理服务支持 STARTTLS通过 `smtp_tls_cert``smtp_tls_key` 环境变量配置
- feat: |Webhook| Webhook 设置页面新增预设模板下拉菜单,支持 Message Pusher、Bark、ntfy 一键填充配置
### Bug Fixes
- fix: |Telegram| 修复 admin 用户通过 Telegram MiniApp 查看邮件时报 `Auth date expired` 的问题,支持 admin 密码认证查看邮件
- fix: |Admin API| 修复 `/admin/account_settings` 在未配置 KV 且 `fromBlockList` 为空时触发 `Cannot read properties of undefined (reading 'put')` 的问题
- fix: |数据库| 修复 `DB_INIT_QUERIES` 缺少 `idx_raw_mails_message_id` 索引导致 `UPDATE raw_mails ... WHERE message_id = ?` 全表扫描的问题,同步 `schema.sql` 与初始化代码,新增 v0.0.6 迁移逻辑
- fix: |文档| 修复 User Mail API 文档中错误使用 `x-admin-auth` 的问题,改为正确的 `x-user-token`
- fix: |前端| 修复暗色主题下邮件内容文字看不清的问题,优化纯文本邮件和 Shadow DOM 渲染的暗色模式样式
- docs: |文档| 新增 Admin 删除邮件、删除邮箱地址、清空收件箱、清空发件箱 API 文档
- fix: |前端| 修复回复 HTML 格式邮件时丢失原邮件 HTML 内容的问题,优先使用 HTML 原文而非纯文本
- fix: |安全| 修复回复/转发邮件时的 XSS 风险,使用 DOMPurify 对 HTML 内容进行白名单消毒,对纯文本内容进行 HTML 转义
- fix: |API| 修复 `requset_send_mail_access` API 路径拼写错误,改为 `request_send_mail_access`
### Testing
- test: |E2E| 新增 Docker 化端到端测试环境Playwright + Mailpit`cd e2e && npm test` 一条命令运行
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
### Improvements
- style: |邮件列表| 优化收件箱和发件箱空状态显示,根据邮件数量显示不同提示信息,添加语义化图标
- feat: |后台管理| 邮箱地址列表来源IP添加 ip.im 查询链接点击可快速查看IP信息
- docs: |文档| 修复 VitePress 中英文切换路径错误,改用双前缀 locale 配置
- feat: |IMAP 代理| 重构 IMAP 服务端拆分为独立模块HTTP 客户端、邮箱、消息),使用 `deferToThread` 异步 HTTP 避免阻塞 Twisted reactor使用后端 `id` 作为稳定 UID新增 STARTTLS 支持、LRU 消息缓存、session 级 flags 管理、SEARCH 命令支持、JWT 凭证和地址+密码双登录方式,新增完整测试套件
- fix: |IMAP 代理| 修复 `getHeaders()` 过滤逻辑、`store()` 崩溃问题
- fix: |邮件解析| 修复 `parse_email.py` 中使用私有属性 `_payload` 导致编码错误的问题,改用 `get_payload(decode=True)` 正确解码邮件体
## v1.3.0
### Features
- feat: |OAuth2| 新增 OAuth2 邮箱格式转换功能,支持通过正则表达式转换第三方登录返回的邮箱格式(如将 `user@domain` 转换为 `user@custom.domain`
- feat: |OAuth2| 新增 OAuth2 提供商 SVG 图标支持,管理员可为登录按钮配置自定义图标,预置 GitHub、Linux Do、Authentik 模板图标
- feat: |发送邮件| 未配置发送邮件功能时自动隐藏发送邮件 tab、发件箱 tab 和回复按钮
### Bug Fixes
- fix: |用户地址| 修复禁止匿名创建时,已登录用户地址数量限制检查失效的问题,新增公共函数 `isAddressCountLimitReached` 统一处理地址数量限制逻辑
### Improvements
- refactor: |代码重构| 提取地址数量限制检查为公共函数,优化代码复用性
- perf: |性能优化| GET 请求中的地址活动时间更新改为异步执行,使用 `waitUntil` 不阻塞响应
## v1.2.1
### Bug Fixes

View File

@@ -6,7 +6,88 @@
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
</p>
## v1.2.1(main)
## v1.5.0(main)
### Features
- feat: |Admin API| `/admin/new_address` endpoint now returns `address_id` field, avoiding additional query after address creation (#912)
- 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: |API| Add clarification between Address JWT and User JWT to avoid confusion; reorganize documentation menu structure with dedicated API Endpoints section (#910)
- docs: |Telegram| Add per-user mail push and global push documentation (#769)
- docs: |Webhook| Add webhook template examples for Telegram Bot, WeChat Work, Discord and other common push platforms
- feat: |Webhook| Add Telegram Bot, WeChat Work, Discord preset templates to frontend webhook settings
### Improvements
## v1.4.0
### Features
- feat: |User Registration| Add email regex validation for user registration, admins can configure email format validation rules
- feat: |Frontend| Add configurable Status menu button via `STATUS_URL` environment variable for status monitoring page link
- feat: |SMTP| Add STARTTLS support for SMTP proxy server via `smtp_tls_cert` and `smtp_tls_key` environment variables
- feat: |Webhook| Add preset templates dropdown to Webhook settings page, supporting one-click fill for Message Pusher, Bark, and ntfy
### Bug Fixes
- fix: |Telegram| Fix admin users unable to view emails via Telegram MiniApp due to `Auth date expired` error, support admin password auth for viewing emails
- fix: |Admin API| Fix `/admin/account_settings` throwing `Cannot read properties of undefined (reading 'put')` when KV is not configured and `fromBlockList` is empty
- fix: |Database| Fix missing `idx_raw_mails_message_id` index in `DB_INIT_QUERIES` causing full table scan on `UPDATE raw_mails ... WHERE message_id = ?`, sync `schema.sql` with init code, add v0.0.6 migration
- fix: |Docs| Fix User Mail API documentation incorrectly using `x-admin-auth`, changed to correct `x-user-token`
- fix: |Frontend| Fix email content text being unreadable in dark theme, improve dark mode styles for plain text mail and Shadow DOM rendering
- docs: |Docs| Add Admin API documentation for delete mail, delete address, clear inbox, and clear sent items
- fix: |Frontend| Fix reply to HTML email losing original HTML content, prefer HTML message over plain text
- fix: |Security| Fix XSS vulnerability in reply/forward mail content, sanitize HTML with DOMPurify whitelist and escape plain text
- fix: |API| Fix typo in `requset_send_mail_access` API path, renamed to `request_send_mail_access`
### Testing
- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test`
- test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization
- test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled
### Improvements
- style: |Mail List| Improve empty state display for inbox and sent box, show different messages based on mail count, add semantic icons
- feat: |Admin| Add ip.im lookup link for source IP in address list, click to quickly view IP information
- docs: |Docs| Fix VitePress i18n language switch path error, use dual-prefix locale configuration
- feat: |IMAP Proxy| Refactor IMAP server into separate modules (HTTP client, mailbox, message), use `deferToThread` for async HTTP to avoid blocking Twisted reactor, use backend `id` as stable UID, add STARTTLS support, LRU message cache, session-local flags management, SEARCH command support, JWT credential and address+password dual login methods, and comprehensive test suite
- fix: |IMAP Proxy| Fix `getHeaders()` filtering and `store()` crash
- fix: |Email Parser| Fix `parse_email.py` using private `_payload` attribute causing encoding errors, use `get_payload(decode=True)` for proper email body decoding
## v1.3.0
### Features
- feat: |OAuth2| Add email format transformation support for OAuth2, allowing regex-based email format conversion from third-party login providers (e.g., transform `user@domain` to `user@custom.domain`)
- feat: |OAuth2| Add SVG icon support for OAuth2 providers, admins can configure custom icons for login buttons, preset icons for GitHub, Linux Do, Authentik templates
- feat: |Send Mail| Auto-hide sendmail tab, sendbox tab, and reply button when send mail is not configured
### Bug Fixes
- fix: |User Address| Fix address count limit check failure when anonymous creation is disabled for logged-in users, add public function `isAddressCountLimitReached` to unify address count limit logic
### Improvements
- refactor: |Code Refactoring| Extract address count limit check as a public function to improve code reusability
- perf: |Performance| Change address activity time update in GET requests to async execution using `waitUntil`, non-blocking response
## v1.2.1
### Bug Fixes

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.

View File

@@ -29,37 +29,37 @@
</p>
<p align="center">
<a href="README.md">🇨🇳 中文文档</a> |
<a href="README_EN.md">🇺🇸 English Document</a>
<a href="README.md">中文文档</a> |
<a href="README_EN.md">English Document</a>
</p>
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
**🎉 一个功能完整的临时邮箱服务!**
**一个功能完整的临时邮箱服务!**
- 🆓 **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
- **高性能** - Rust WASM 邮件解析,响应速度极快
- 🎨 **现代化界面** - 响应式设计,支持多语言,操作简便
- 🔐 **地址密码** - 支持为邮箱地址设置独立密码,增强安全性 (通过 `ENABLE_ADDRESS_PASSWORD` 启用)
- **完全免费** - 基于 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)
[部署文档](https://temp-mail-docs.awsl.uk) | [Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html)
<a href="https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html">
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
</a>
## 📝 更新日志
## 更新日志
查看 [CHANGELOG](CHANGELOG.md) 了解最新更新内容。
## 🎯 在线体验
## 在线体验
立即体验 → [https://mail.awsl.uk/](https://mail.awsl.uk/)
<details>
<summary>📊 服务状态监控(点击收缩/展开)</summary>
<summary>服务状态监控(点击收缩/展开)</summary>
| | |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -69,7 +69,7 @@
</details>
<details>
<summary>Star History点击收缩/展开)</summary>
<summary>Star History点击收缩/展开)</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
@@ -80,32 +80,32 @@
</details>
<details open>
<summary>📖 目录(点击收缩/展开)</summary>
<summary>目录(点击收缩/展开)</summary>
- [Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#cloudflare-临时邮箱---免费搭建临时邮件服务)
- [📚 部署文档 - 快速开始](#-部署文档---快速开始)
- [📝 更新日志](#-更新日志)
- [🎯 在线体验](#-在线体验)
- [核心功能](#-核心功能)
- [📧 邮件处理](#-邮件处理)
- [👥 用户管理](#-用户管理)
- [🔧 管理功能](#-管理功能)
- [🌐 多语言与界面](#-多语言与界面)
- [🤖 集成与扩展](#-集成与扩展)
- [🏗️ 技术架构](#-技术架构)
- [🏛️ 系统架构](#-系统架构)
- [🛠️ 技术栈](#-技术栈)
- [📦 主要组件](#-主要组件)
- [🌟 加入社区](#-加入社区)
- [部署文档 - 快速开始](#部署文档---快速开始)
- [更新日志](#更新日志)
- [在线体验](#在线体验)
- [核心功能](#核心功能)
- [邮件处理](#邮件处理)
- [用户管理](#用户管理)
- [管理功能](#管理功能)
- [多语言与界面](#多语言与界面)
- [集成与扩展](#集成与扩展)
- [技术架构](#技术架构)
- [系统架构](#系统架构)
- [技术栈](#技术栈)
- [主要组件](#主要组件)
- [加入社区](#加入社区)
</details>
## 核心功能
## 核心功能
<details open>
<summary>核心功能详情(点击收缩/展开)</summary>
<summary>核心功能详情(点击收缩/展开)</summary>
### 📧 邮件处理
### 邮件处理
- [x] 使用 `rust wasm` 解析邮件解析速度快几乎所有邮件都能解析node 的解析模块解析邮件失败的邮件rust wasm 也能解析成功
- [x] **AI 邮件识别** - 使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
@@ -116,7 +116,7 @@
- [x] 垃圾邮件检测和黑白名单配置
- [x] 邮件转发功能,支持全局转发地址
### 👥 用户管理
### 用户管理
- [x] 使用 `凭证` 重新登录之前的邮箱
- [x] 添加完整的用户注册登录功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证切换不同邮箱
@@ -125,7 +125,7 @@
- [x] 用户角色管理,支持多角色域名和前缀配置
- [x] 用户收件箱查看,支持地址和关键词过滤
### 🔧 管理功能
### 管理功能
- [x] 完整的 admin 控制台
- [x] `admin` 后台创建无前缀邮箱
@@ -134,7 +134,7 @@
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
- [x] 增加访问密码,可作为私人站点
### 🌐 多语言与界面
### 多语言与界面
- [x] 前后台均支持多语言
- [x] 现代化 UI 设计,支持响应式布局
@@ -142,7 +142,7 @@
- [x] 使用 shadow DOM 防止样式污染
- [x] 支持 URL JWT 参数自动登录
### 🤖 集成与扩展
### 集成与扩展
- [x] 完整的 `Telegram Bot` 支持,以及 `Telegram` 推送Telegram Bot 小程序
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件,`IMAP` 查看邮件
@@ -152,19 +152,19 @@
</details>
## 🏗️ 技术架构
## 技术架构
<details>
<summary>🏗️ 技术架构详情(点击收缩/展开)</summary>
<summary>技术架构详情(点击收缩/展开)</summary>
### 🏛️ 系统架构
### 系统架构
- **数据库**: Cloudflare D1 作为主数据库
- **前端部署**: 使用 Cloudflare Pages 部署前端
- **后端部署**: 使用 Cloudflare Workers 部署后端
- **邮件转发**: 使用 Cloudflare Email Routing
### 🛠️ 技术栈
### 技术栈
- **前端**: Vue 3 + Vite + TypeScript
- **后端**: TypeScript + Cloudflare Workers
@@ -173,7 +173,7 @@
- **存储**: Cloudflare KV + R2 (可选 S3)
- **代理服务**: Python SMTP/IMAP Proxy Server
### 📦 主要组件
### 主要组件
- **Worker**: 核心后端服务
- **Frontend**: Vue 3 用户界面
@@ -192,6 +192,6 @@ nslookup -qt="mx" a.b.com 1.1.1.1
```
进行验证。
## 🌟 加入社区
## 加入社区
- [Telegram](https://t.me/cloudflare_temp_email)

View File

@@ -29,37 +29,37 @@
</p>
<p align="center">
<a href="README.md">🇨🇳 中文文档</a> |
<a href="README_EN.md">🇺🇸 English Document</a>
<a href="README.md">中文文档</a> |
<a href="README_EN.md">English Document</a>
</p>
> 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!**
**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`)
- **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
## 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)
[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
## Changelog
See [CHANGELOG](CHANGELOG.md) for the latest updates.
## 🎯 Live Demo
## Live Demo
Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
<details>
<summary>📊 Service Status Monitoring (Click to expand/collapse)</summary>
<summary>Service Status Monitoring (Click to expand/collapse)</summary>
| | |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -69,7 +69,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
</details>
<details>
<summary>Star History (Click to expand/collapse)</summary>
<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" />
@@ -80,32 +80,32 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
</details>
<details open>
<summary>📖 Table of Contents (Click to expand/collapse)</summary>
<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)
- [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
## Core Features
<details open>
<summary>Core Features Details (Click to expand/collapse)</summary>
<summary>Core Features Details (Click to expand/collapse)</summary>
### 📧 Email Processing
### 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
@@ -116,7 +116,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
- [x] Spam detection and blacklist/whitelist configuration
- [x] Email forwarding feature with global forwarding address support
### 👥 User Management
### 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
@@ -125,7 +125,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
- [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
### Admin Features
- [x] Complete admin console
- [x] Create mailboxes without prefix in `admin` backend
@@ -134,7 +134,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
- [x] Get mailboxes with custom names, `admin` can configure blacklist
- [x] Add access password for use as a private site
### 🌐 Multi-language & Interface
### Multi-language & Interface
- [x] Both frontend and backend support multi-language
- [x] Modern UI design with responsive layout
@@ -142,7 +142,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
- [x] Use shadow DOM to prevent style pollution
- [x] Support URL JWT parameter auto-login
### 🤖 Integration & Extensions
### 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
@@ -152,19 +152,19 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
</details>
## 🏗️ Technical Architecture
## Technical Architecture
<details>
<summary>🏗️ Technical Architecture Details (Click to expand/collapse)</summary>
<summary>Technical Architecture Details (Click to expand/collapse)</summary>
### 🏛️ System Architecture
### 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
### Tech Stack
- **Frontend**: Vue 3 + Vite + TypeScript
- **Backend**: TypeScript + Cloudflare Workers
@@ -173,7 +173,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
- **Storage**: Cloudflare KV + R2 (optional S3)
- **Proxy Service**: Python SMTP/IMAP Proxy Server
### 📦 Main Components
### Main Components
- **Worker**: Core backend service
- **Frontend**: Vue 3 user interface
@@ -191,6 +191,6 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
nslookup -qt="mx" a.b.com 1.1.1.1
```
## 🌟 Join the Community
## Join the Community
- [Telegram](https://t.me/cloudflare_temp_email)

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.2.1",
"version": "1.5.0",
"private": true,
"type": "module",
"scripts": {
@@ -17,39 +17,44 @@
"deploy:actions:telegram": "npm run build:telegram && wrangler pages deploy ./dist",
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
"deploy": "npm run build && wrangler pages deploy ./dist --branch production",
"deploy:actions": "npm run build && wrangler pages deploy ./dist"
"deploy:actions": "npm run build && wrangler pages deploy ./dist",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@simplewebauthn/browser": "10.0.0",
"@unhead/vue": "^2.1.2",
"@vueuse/core": "^14.1.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.13.2",
"axios": "^1.13.6",
"dompurify": "^3.3.3",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.43.2",
"mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.44.1",
"postal-mime": "^2.7.3",
"vooks": "^0.2.12",
"vue": "^3.5.27",
"vue": "^3.5.30",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.2.8",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0",
"@vitejs/plugin-vue": "^6.0.3",
"@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.59.2"
"wrangler": "^4.72.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

2982
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,8 @@ const getOpenSettings = async (message, notification) => {
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;

View File

@@ -3,10 +3,11 @@ import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled } from '@vicons/material'
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem } from '../utils/email-parser'
import { utcToLocalDate } from '../utils';
import { buildReplyModel, buildForwardModel } from '../utils/mail-actions'
import MailContentRenderer from "./MailContentRenderer.vue";
import AiExtractInfo from "./AiExtractInfo.vue";
@@ -147,6 +148,7 @@ const { t } = useI18n({
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
pleaseSelectMail: "Please select mail",
emptyInbox: "Your inbox is empty",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete mail?',
reply: 'Reply',
@@ -171,6 +173,7 @@ const { t } = useI18n({
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择邮件",
emptyInbox: "收件箱为空",
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
reply: '回复',
@@ -274,30 +277,12 @@ const deleteMail = async () => {
};
const replyMail = async () => {
const emailRegex = /(.+?) <(.+?)>/;
let toMail = curMail.value.originalSource;
let toName = ""
const match = emailRegex.exec(curMail.value.source);
if (match) {
toName = match[1];
toMail = match[2];
}
Object.assign(sendMailModel.value, {
toName: toName,
toMail: toMail,
subject: `${t('reply')}: ${curMail.value.subject}`,
contentType: 'rich',
content: curMail.value.text ? `<p><br></p><blockquote>${curMail.value.text}</blockquote><p><br></p>` : '',
});
Object.assign(sendMailModel.value, buildReplyModel(curMail.value, t('reply')));
indexTab.value = 'sendmail';
};
const forwardMail = async () => {
Object.assign(sendMailModel.value, {
subject: `${t('forwardMail')}: ${curMail.value.subject}`,
contentType: curMail.value.message ? 'html' : 'text',
content: curMail.value.message || curMail.value.text,
});
Object.assign(sendMailModel.value, buildForwardModel(curMail.value, t('forwardMail')));
indexTab.value = 'sendmail';
};
@@ -446,7 +431,7 @@ onBeforeUnmount(() => {
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<template #1>
<div style="overflow: auto; min-height: 50vh; max-height: 100vh;">
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
@@ -506,7 +491,10 @@ onBeforeUnmount(() => {
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
<n-result status="info" :title="count === 0 ? t('emptyInbox') : t('pleaseSelectMail')">
<template #icon>
<n-icon :component="InboxRound" :size="100" />
</template>
</n-result>
</n-card>
</template>
@@ -531,7 +519,7 @@ onBeforeUnmount(() => {
<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">

View File

@@ -8,7 +8,7 @@ import { getDownloadEmlUrl } from '../utils/email-parser';
import { utcToLocalDate } from '../utils';
import { useGlobalState } from '../store';
const { preferShowTextMail, useIframeShowMail, useUTCDate } = useGlobalState();
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
const { t } = useI18n({
messages: {
@@ -184,22 +184,22 @@ const handleSaveToS3 = async (filename, blob) => {
<AiExtractInfo :metadata="mail.metadata" />
<!-- 邮件内容 -->
<div class="mail-content">
<div class="mail-content" :class="{ 'dark-mode': isDark }">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
</iframe>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
</div>
</div>
<n-drawer v-model:show="showFullscreen" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 100vh;">
<n-drawer-content :title="mail.subject" closable>
<div class="fullscreen-mail-content">
<div class="fullscreen-mail-content" :class="{ 'dark-mode': isDark }">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
</iframe>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
</div>
</n-drawer-content>
</n-drawer>
@@ -259,6 +259,10 @@ const handleSaveToS3 = async (filename, blob) => {
line-height: inherit;
}
.dark-mode .mail-text {
color: #e0e0e0;
}
.mail-iframe {
width: 100%;
height: 100%;
@@ -266,6 +270,10 @@ const handleSaveToS3 = async (filename, blob) => {
min-height: 400px;
}
.dark-mode .mail-iframe {
background-color: #fff;
}
.mail-html {
width: 100%;
height: 100%;

View File

@@ -5,6 +5,7 @@ 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()
@@ -52,6 +53,7 @@ const { t } = useI18n({
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',
@@ -64,6 +66,7 @@ const { t } = useI18n({
refresh: '刷新',
showCode: '切换查看元数据',
pleaseSelectMail: "请选择一封邮件查看。",
emptySent: "发件箱为空",
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
multiAction: '多选',
@@ -239,7 +242,7 @@ onMounted(async () => {
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<template #1>
<div style="overflow: auto; height: 80vh;">
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
@@ -297,7 +300,10 @@ onMounted(async () => {
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
<n-result status="info" :title="count === 0 ? t('emptySent') : t('pleaseSelectMail')">
<template #icon>
<n-icon :component="SendRound" :size="100" />
</template>
</n-result>
</n-card>
</template>
@@ -312,7 +318,7 @@ onMounted(async () => {
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-thing :title="row.subject">

View File

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

View File

@@ -17,17 +17,21 @@ const { locale, t } = useI18n({
}
});
const containerId = `cf-turnstile-${Math.random().toString(36).slice(2, 9)}`
const cfTurnstileId = ref("")
const turnstileLoading = ref(false)
const refresh = () => checkCfTurnstile(true)
defineExpose({ refresh })
const checkCfTurnstile = async (remove) => {
if (!openSettings.value.cfTurnstileSiteKey) return;
turnstileLoading.value = true;
try {
let container = document.getElementById("cf-turnstile");
let container = document.getElementById(containerId);
let count = 100;
while (!container && count-- > 0) {
container = document.getElementById("cf-turnstile");
container = document.getElementById(containerId);
await new Promise(r => setTimeout(r, 10));
}
count = 100;
@@ -38,7 +42,7 @@ const checkCfTurnstile = async (remove) => {
window.turnstile.remove(cfTurnstileId.value);
}
cfTurnstileId.value = window.turnstile.render(
"#cf-turnstile",
`#${containerId}`,
{
sitekey: openSettings.value.cfTurnstileSiteKey,
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
@@ -68,7 +72,7 @@ onMounted(() => {
<n-spin description="loading..." :show="turnstileLoading">
<n-form-item-row>
<n-flex vertical>
<div id="cf-turnstile"></div>
<div :id="containerId"></div>
<n-button text @click="checkCfTurnstile(true)">
{{ t('refresh') }}
</n-button>

View File

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

View File

@@ -1,5 +1,6 @@
export type UserOauth2Settings = {
name: string;
icon?: string; // SVG icon string for the provider
clientID: string;
clientSecret: string;
authorizationURL: string;
@@ -9,6 +10,9 @@ export type UserOauth2Settings = {
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

@@ -34,9 +34,12 @@ export const useGlobalState = createGlobalState(
cfTurnstileSiteKey: '',
enableWebhook: false,
isS3Enabled: false,
enableSendMail: false,
showGithub: true,
disableAdminPasswordCheck: false,
enableAddressPassword: false,
statusUrl: '',
enableGlobalTurnstileCheck: false,
})
const settings = ref({
fetched: false,
@@ -83,7 +86,7 @@ export const useGlobalState = createGlobalState(
fetched: false,
enable: false,
enableMailVerify: false,
/** @type {{ clientID: string, name: string }[]} */
/** @type {{ clientID: string, name: string, icon?: string }[]} */
oauth2ClientIDs: [],
});
const userSettings = ref({
@@ -109,8 +112,18 @@ export const useGlobalState = createGlobalState(
);
const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
const _oauth2StateSession = useSessionStorage('userOauth2SessionState', '');
const _oauth2StateFallback = useStorage('userOauth2SessionState_fb', '');
const userOauth2SessionState = computed({
get: () => _oauth2StateSession.value || _oauth2StateFallback.value,
set: (v) => { _oauth2StateSession.value = v; _oauth2StateFallback.value = v; }
});
const _oauth2ClientIDSession = useSessionStorage('userOauth2SessionClientID', '');
const _oauth2ClientIDFallback = useStorage('userOauth2SessionClientID_fb', '');
const userOauth2SessionClientID = computed({
get: () => _oauth2ClientIDSession.value || _oauth2ClientIDFallback.value,
set: (v) => { _oauth2ClientIDSession.value = v; _oauth2ClientIDFallback.value = v; }
});
const browserFingerprint = ref('');
return {
isDark,

View File

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

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

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

View File

@@ -6,13 +6,14 @@ import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useIsMobile } from '../utils/composables'
import {
DarkModeFilled, LightModeFilled, MenuFilled,
AdminPanelSettingsFilled
AdminPanelSettingsFilled, MonitorHeartFilled
} from '@vicons/material'
import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getRouterPathWithLang } from '../utils'
import { getRouterPathWithLang, hashPassword } from '../utils'
import Turnstile from '../components/Turnstile.vue'
const message = useMessage()
const notification = useNotification()
@@ -32,11 +33,22 @@ const menuValue = computed(() => {
return "home";
});
const cfToken = ref('')
const turnstileRef = ref(null)
const authFunc = async () => {
try {
await api.fetch('/open_api/site_login', {
method: 'POST',
body: JSON.stringify({
password: await hashPassword(auth.value),
cf_token: cfToken.value
})
});
location.reload()
} catch (error) {
message.error(error.message || "error");
turnstileRef.value?.refresh?.();
}
}
@@ -59,6 +71,7 @@ const { locale, t } = useI18n({
home: 'Home',
menu: 'Menu',
user: 'User',
status: 'Status',
ok: 'OK',
},
zh: {
@@ -70,6 +83,7 @@ const { locale, t } = useI18n({
home: '主页',
menu: '菜单',
user: '用户',
status: '状态',
ok: '确定',
}
}
@@ -179,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,
@@ -266,6 +299,7 @@ onMounted(async () => {
:title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="auth" type="password" show-password-on="click" />
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
<template #action>
<n-button :loading="loading" @click="authFunc" type="primary">
{{ t('ok') }}

View File

@@ -156,15 +156,15 @@ onMounted(() => {
</n-button>
</n-input-group>
</div>
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled"
<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 name="sendbox" :tab="t('sendbox')">
<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 name="sendmail" :tab="t('sendmail')">
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
<SendMail />
</n-tab-pane>
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">

View File

@@ -323,7 +323,21 @@ const columns = [
},
{
title: t('source_meta'),
key: "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'),

View File

@@ -25,7 +25,13 @@ const { t } = useI18n({
mailAllowList: 'Mail Address Allow List',
addOauth2: 'Add Oauth2',
name: 'Name',
icon: 'Icon (SVG, please ensure trusted source)',
iconPreview: 'Preview',
oauth2Type: 'Oauth2 Type',
enableEmailFormat: 'Enable Email Format',
userEmailFormat: 'Email Regex Pattern',
userEmailReplace: 'Replace Template',
userEmailFormatTip: 'Use regex to transform email. Example: ^(.+)@old\\.com$ with $1@new.com',
tip: 'Third-party login will automatically use the user\'s email to register an account (the same email will be regarded as the same account), this account is the same as the registered account, and you can also set the password through the forget password',
},
zh: {
@@ -38,12 +44,24 @@ const { t } = useI18n({
mailAllowList: '邮件地址白名单',
addOauth2: '添加 Oauth2',
name: '名称',
icon: '图标 (SVG, 请确保来源可信)',
iconPreview: '预览',
oauth2Type: 'Oauth2 类型',
enableEmailFormat: '启用邮箱格式转换',
userEmailFormat: '邮箱正则表达式',
userEmailReplace: '替换模板',
userEmailFormatTip: '使用正则转换邮箱。示例: ^(.+)@old\\.com$ 配合 $1@new.com',
tip: '第三方登录会自动使用用户邮箱注册账号(邮箱相同将视为同一账号), 此账号和注册的账号相同, 也可以通过忘记密码设置密码',
}
}
});
const OAUTH2_ICONS: Record<string, string> = {
github: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>',
linuxdo: '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em"><g><path d="m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z" fill="#EFEFEF"/><path d="m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z" fill="#FEB005"/><path d="m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z" fill="#1D1D1F"/></g></svg>',
authentik: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12zM11 7v6h2V7h-2zm0 8v2h2v-2h-2z"/></svg>',
};
const mailAllowOptions = constant.COMMOM_MAIL.map((item) => {
return { label: item, value: item }
})
@@ -75,80 +93,59 @@ const save = async () => {
}
const addNewOauth2 = () => {
const authorizationURL = () => {
switch (newOauth2Type.value) {
case 'github':
return 'https://github.com/login/oauth/authorize'
case 'authentik':
return 'https://youdomain/application/o/authorize/'
default:
return ''
}
}
const accessTokenURL = () => {
switch (newOauth2Type.value) {
case 'github':
return 'https://github.com/login/oauth/access_token'
case 'authentik':
return 'https://youdomain/application/o/token/'
default:
return ''
}
}
const accessTokenFormat = () => {
switch (newOauth2Type.value) {
case 'github':
return 'json'
case 'authentik':
return 'urlencoded'
default:
return ''
}
}
const userInfoURL = () => {
switch (newOauth2Type.value) {
case 'github':
return 'https://api.github.com/user'
case 'authentik':
return 'https://youdomain/application/o/userinfo/'
default:
return ''
}
}
const userEmailKey = () => {
switch (newOauth2Type.value) {
case 'github':
return 'email'
case 'authentik':
return 'email'
default:
return ''
}
}
const scope = () => {
switch (newOauth2Type.value) {
case 'github':
return 'user:email'
case 'authentik':
return 'email openid'
default:
return ''
}
const templates: Record<string, Partial<UserOauth2Settings>> = {
github: {
authorizationURL: 'https://github.com/login/oauth/authorize',
accessTokenURL: 'https://github.com/login/oauth/access_token',
accessTokenFormat: 'json',
userInfoURL: 'https://api.github.com/user',
userEmailKey: 'email',
scope: 'user:email',
icon: OAUTH2_ICONS.github,
},
linuxdo: {
authorizationURL: 'https://connect.linux.do/oauth2/authorize',
accessTokenURL: 'https://connect.linux.do/oauth2/token',
accessTokenFormat: 'urlencoded',
userInfoURL: 'https://connect.linux.do/api/user',
userEmailKey: 'id',
scope: 'user',
enableEmailFormat: true,
userEmailFormat: '^(.+)$',
userEmailReplace: 'linux_do_$1@oauth.linux.do',
icon: OAUTH2_ICONS.linuxdo,
},
authentik: {
authorizationURL: 'https://youdomain/application/o/authorize/',
accessTokenURL: 'https://youdomain/application/o/token/',
accessTokenFormat: 'urlencoded',
userInfoURL: 'https://youdomain/application/o/userinfo/',
userEmailKey: 'email',
scope: 'email openid',
icon: OAUTH2_ICONS.authentik,
},
custom: {},
}
const template = templates[newOauth2Type.value] || {}
userOauth2Settings.value.push({
name: newOauth2Name.value,
icon: '',
clientID: '',
clientSecret: '',
authorizationURL: authorizationURL(),
accessTokenURL: accessTokenURL(),
accessTokenFormat: accessTokenFormat(),
userInfoURL: userInfoURL(),
userEmailKey: userEmailKey(),
authorizationURL: '',
accessTokenURL: '',
accessTokenFormat: '',
userInfoURL: '',
userEmailKey: '',
redirectURL: `${window.location.origin}/user/oauth2/callback`,
logoutURL: '',
scope: scope(),
scope: '',
enableEmailFormat: false,
userEmailFormat: '',
userEmailReplace: '',
enableMailAllowList: false,
mailAllowList: constant.COMMOM_MAIL
mailAllowList: constant.COMMOM_MAIL,
...template,
} as UserOauth2Settings)
newOauth2Name.value = ''
showAddOauth2.value = false
@@ -174,6 +171,7 @@ onMounted(async () => {
<n-form-item-row :label="t('oauth2Type')" required>
<n-radio-group v-model:value="newOauth2Type">
<n-radio-button value="github" label="Github" />
<n-radio-button value="linuxdo" label="Linux Do" />
<n-radio-button value="authentik" label="Authentik" />
<n-radio-button value="custom" label="Custom" />
</n-radio-group>
@@ -214,11 +212,18 @@ onMounted(async () => {
<n-form-item-row :label="t('name')" required>
<n-input v-model:value="item.name" />
</n-form-item-row>
<n-form-item-row :label="t('icon')">
<n-input v-model:value="item.icon" type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }" style="width: 100%;" />
</n-form-item-row>
<n-form-item-row v-if="item.icon" :label="t('iconPreview')">
<span class="oauth2-icon-preview" v-html="item.icon"></span>
</n-form-item-row>
<n-form-item-row label="Client ID" required>
<n-input v-model:value="item.clientID" />
</n-form-item-row>
<n-form-item-row label="Client Secret" required>
<n-input v-model:value="item.clientSecret" type="password" show-password-on="click" />
<n-input v-model:value="item.clientSecret" />
</n-form-item-row>
<n-form-item-row label="Authorization URL" required>
<n-input v-model:value="item.authorizationURL" />
@@ -235,6 +240,27 @@ onMounted(async () => {
<n-form-item-row label="User Email Key (Support JSONPATH like $[0].email)" required>
<n-input v-model:value="item.userEmailKey" />
</n-form-item-row>
<n-form-item-row :label="t('enableEmailFormat')">
<n-checkbox v-model:checked="item.enableEmailFormat">
{{ t('enable') }}
</n-checkbox>
</n-form-item-row>
<n-form-item-row v-if="item.enableEmailFormat" :label="t('userEmailFormat')">
<n-tooltip trigger="hover">
<template #trigger>
<n-input v-model:value="item.userEmailFormat" :placeholder="'^(.+)@old\\.com$'" />
</template>
{{ t('userEmailFormatTip') }}
</n-tooltip>
</n-form-item-row>
<n-form-item-row v-if="item.enableEmailFormat" :label="t('userEmailReplace')">
<n-tooltip trigger="hover">
<template #trigger>
<n-input v-model:value="item.userEmailReplace" placeholder="$1@new.com" />
</template>
{{ t('userEmailFormatTip') }}
</n-tooltip>
</n-form-item-row>
<n-form-item-row label="Redirect URL" required>
<n-input v-model:value="item.redirectURL" />
</n-form-item-row>
@@ -271,4 +297,20 @@ onMounted(async () => {
place-items: center;
justify-content: center;
}
.oauth2-icon-preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--n-border-color);
border-radius: 4px;
padding: 4px;
}
.oauth2-icon-preview :deep(svg) {
width: 100%;
height: 100%;
}
</style>

View File

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

View File

@@ -47,6 +47,8 @@ const credential = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const loginCfToken = ref("")
const loginTurnstileRef = ref(null)
const loginMethod = ref('credential') // 'credential' or 'password'
const loginAddress = ref('')
const loginPassword = ref('')
@@ -72,7 +74,8 @@ const login = async () => {
method: 'POST',
body: JSON.stringify({
email: loginAddress.value,
password: await hashPassword(loginPassword.value)
password: await hashPassword(loginPassword.value),
cf_token: loginCfToken.value
})
});
jwt.value = res.jwt;
@@ -85,6 +88,7 @@ const login = async () => {
await router.push(getRouterPathWithLang("/", locale.value));
} catch (error) {
message.error(error.message || "error");
loginTurnstileRef.value?.refresh?.();
}
return;
}
@@ -93,6 +97,13 @@ const login = async () => {
return;
}
try {
await api.fetch('/open_api/credential_login', {
method: 'POST',
body: JSON.stringify({
credential: credential.value,
cf_token: loginCfToken.value
})
});
jwt.value = credential.value;
await api.getSettings();
try {
@@ -103,6 +114,7 @@ const login = async () => {
await router.push(getRouterPathWithLang("/", locale.value));
} catch (error) {
message.error(error.message || "error");
loginTurnstileRef.value?.refresh?.();
}
}
@@ -289,6 +301,9 @@ onMounted(async () => {
</n-form-item-row>
</div>
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck"
v-model:value="loginCfToken" />
<div class="switch-login-button">
<n-button v-if="openSettings?.enableAddressPassword"
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"

View File

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

View File

@@ -96,7 +96,7 @@ const send = async () => {
const requestAccess = async () => {
try {
await api.fetch(`/api/requset_send_mail_access`,
await api.fetch(`/api/request_send_mail_access`,
{
method: 'POST',
body: JSON.stringify({})

View File

@@ -69,7 +69,12 @@ const user = ref({
password: "",
code: ""
});
const cfToken = ref("")
const signupCfToken = ref("")
const resetCfToken = ref("")
const loginCfToken = ref("")
const signupTurnstileRef = ref(null)
const resetTurnstileRef = ref(null)
const loginTurnstileRef = ref(null)
const emailLogin = async () => {
if (!user.value.email || !user.value.password) {
@@ -82,13 +87,15 @@ const emailLogin = async () => {
body: JSON.stringify({
email: user.value.email,
// hash password
password: await hashPassword(user.value.password)
password: await hashPassword(user.value.password),
cf_token: loginCfToken.value
})
});
userJwt.value = res.jwt;
location.reload();
} catch (error) {
message.error(error.message || "login failed");
loginTurnstileRef.value?.refresh?.();
}
};
@@ -105,7 +112,8 @@ const sendVerificationCode = async () => {
message.error(t('pleaseInputEmail'));
return;
}
if (openSettings.value.cfTurnstileSiteKey && !cfToken.value && userOpenSettings.value.enableMailVerify) {
const currentCfToken = showModal.value ? resetCfToken.value : signupCfToken.value;
if (openSettings.value.cfTurnstileSiteKey && !currentCfToken && userOpenSettings.value.enableMailVerify) {
message.error(t('pleaseCompleteTurnstile'));
return;
}
@@ -114,7 +122,7 @@ const sendVerificationCode = async () => {
method: "POST",
body: JSON.stringify({
email: user.value.email,
cf_token: cfToken.value
cf_token: currentCfToken
})
});
if (res && res.expirationTtl) {
@@ -131,6 +139,11 @@ const sendVerificationCode = async () => {
} catch (error) {
message.error(error.message || "send verification code failed");
}
if (showModal.value) {
resetTurnstileRef.value?.refresh?.();
} else {
signupTurnstileRef.value?.refresh?.();
}
};
const emailSignup = async () => {
@@ -149,7 +162,8 @@ const emailSignup = async () => {
email: user.value.email,
// hash password
password: await hashPassword(user.value.password),
code: user.value.code
code: user.value.code,
cf_token: showModal.value ? resetCfToken.value : signupCfToken.value
}),
message: message
});
@@ -171,7 +185,7 @@ const passkeyLogin = async () => {
domain: location.hostname,
})
})
const credential = await startAuthentication(options)
const credential = await startAuthentication({ optionsJSON: options })
// Send the result to the server and return the promise.
const res = await api.fetch(`/user_api/passkey/authenticate_response`, {
@@ -218,6 +232,7 @@ onMounted(async () => {
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="loginCfToken" />
<n-button @click="emailLogin" type="primary" block secondary strong>
{{ t('login') }}
</n-button>
@@ -233,6 +248,9 @@ onMounted(async () => {
</n-button>
<n-button @click="oauth2Login(item.clientID)" v-for="item in userOpenSettings.oauth2ClientIDs"
:key="item.clientID" block secondary strong>
<template #icon v-if="item.icon">
<span class="oauth2-icon" v-html="item.icon"></span>
</template>
{{ t('loginWith', { provider: item.name }) }}
</n-button>
</n-form>
@@ -245,7 +263,7 @@ onMounted(async () => {
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile v-if="userOpenSettings.enableMailVerify" v-model:value="cfToken" />
<Turnstile ref="signupTurnstileRef" v-if="userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
<n-input-group>
<n-input v-model:value="user.code" />
@@ -256,6 +274,7 @@ onMounted(async () => {
</n-button>
</n-input-group>
</n-form-item-row>
<Turnstile ref="signupTurnstileRef" v-if="!userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
</n-form>
<n-button @click="emailSignup" type="primary" block secondary strong>
{{ t('register') }}
@@ -270,7 +289,7 @@ onMounted(async () => {
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile v-model:value="cfToken" />
<Turnstile ref="resetTurnstileRef" v-model:value="resetCfToken" />
<n-form-item-row :label="t('verifyCode')" required>
<n-input-group>
<n-input v-model:value="user.code" />
@@ -305,4 +324,17 @@ onMounted(async () => {
.n-button {
margin-top: 10px;
}
.oauth2-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
.oauth2-icon :deep(svg) {
width: 100%;
height: 100%;
}
</style>

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -1,2 +1,9 @@
proxy_url=https://temp-email-api.xxx.xxx
port=8025
imap_port=11143
# smtp_tls_cert=/path/to/cert.pem
# smtp_tls_key=/path/to/key.pem
# imap_tls_cert=/path/to/cert.pem
# imap_tls_key=/path/to/key.pem
# imap_cache_size=500
# imap_http_timeout=30.0

View File

@@ -1,5 +1,6 @@
import logging
from pydantic_settings import BaseSettings
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
@@ -14,9 +15,28 @@ class Settings(BaseSettings):
port: int = 8025
imap_port: int = 11143
basic_password: str = ""
smtp_tls_cert: str = ""
smtp_tls_key: str = ""
imap_tls_cert: str = ""
imap_tls_key: str = ""
imap_cache_size: int = 500
imap_http_timeout: float = 30.0
class Config:
env_file = ".env"
model_config = SettingsConfigDict(env_file=".env")
@field_validator("imap_cache_size")
@classmethod
def cache_size_positive(cls, v):
if v <= 0:
raise ValueError("imap_cache_size must be > 0")
return v
@field_validator("imap_http_timeout")
@classmethod
def timeout_positive(cls, v):
if v <= 0:
raise ValueError("imap_http_timeout must be > 0")
return v
settings = Settings()

View File

@@ -0,0 +1,69 @@
import logging
import httpx
from twisted.internet import defer, threads
from config import settings
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
class BackendClient:
"""Async HTTP client for IMAP backend communication.
All public methods return Deferred via deferToThread to avoid
blocking the Twisted reactor with synchronous HTTP calls.
"""
def __init__(self, password: str):
self.password = password.strip()
self._client = httpx.Client(
base_url=settings.proxy_url,
headers={
"Authorization": f"Bearer {password}",
"x-custom-auth": settings.basic_password,
"Content-Type": "application/json",
},
timeout=settings.imap_http_timeout,
)
def _get_endpoint(self, mailbox_name: str) -> str:
if mailbox_name == "INBOX":
return "/api/mails"
elif mailbox_name == "SENT":
return "/api/sendbox"
raise ValueError(f"Unknown mailbox: {mailbox_name}")
def _sync_get_message_count(self, mailbox_name: str) -> int:
endpoint = self._get_endpoint(mailbox_name)
res = self._client.get(f"{endpoint}?limit=1&offset=0")
res.raise_for_status()
return res.json()["count"]
def _sync_get_messages(
self, mailbox_name: str, limit: int, offset: int
) -> tuple[list[dict], int | None]:
"""Fetch messages from backend.
Returns (results, count) where count is only valid when offset=0.
"""
endpoint = self._get_endpoint(mailbox_name)
res = self._client.get(f"{endpoint}?limit={limit}&offset={offset}")
res.raise_for_status()
data = res.json()
count = data.get("count") if offset == 0 else None
return data["results"], count
def get_message_count(self, mailbox_name: str) -> defer.Deferred:
return threads.deferToThread(self._sync_get_message_count, mailbox_name)
def get_messages(
self, mailbox_name: str, limit: int, offset: int
) -> defer.Deferred:
return threads.deferToThread(
self._sync_get_messages, mailbox_name, limit, offset
)
def close(self):
self._client.close()

View File

@@ -0,0 +1,360 @@
import bisect
import logging
import time
from collections import OrderedDict
from twisted.internet import defer
from twisted.mail import imap4
from zope.interface import implementer
from config import settings
from imap_http_client import BackendClient
from imap_message import SimpleMessage
from parse_email import generate_email_model, parse_email, clean_raw_headers, fix_mojibake
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
# Use process start time as UIDVALIDITY so clients resync after restart
_UID_VALIDITY = int(time.time())
class MessageCache:
"""LRU cache for parsed email messages, keyed by backend id (=UID)."""
def __init__(self, max_size: int = 500):
self._cache: OrderedDict[int, SimpleMessage] = OrderedDict()
self._max_size = max_size
def get(self, uid: int):
if uid in self._cache:
self._cache.move_to_end(uid)
return self._cache[uid]
return None
def put(self, uid: int, message: SimpleMessage):
if uid in self._cache:
self._cache.move_to_end(uid)
self._cache[uid] = message
else:
if len(self._cache) >= self._max_size:
self._cache.popitem(last=False)
self._cache[uid] = message
def __contains__(self, uid: int) -> bool:
return uid in self._cache
def __len__(self) -> int:
return len(self._cache)
@implementer(imap4.IMailboxInfo, imap4.IMailbox, imap4.ISearchableMailbox)
class SimpleMailbox:
def __init__(self, name: str, client: BackendClient):
self.name = name
self._client = client
self.listeners = []
self.addListener = self.listeners.append
self.removeListener = self.listeners.remove
self._message_count = 0
self._uid_index: list[int] = []
self._flags: dict[int, set[str]] = {}
self._cache = MessageCache(max_size=settings.imap_cache_size)
self._uid_index_built = False
def getFlags(self):
return [r"\Seen", r"\Answered", r"\Flagged", r"\Deleted", r"\Draft"]
def getUIDValidity(self):
return _UID_VALIDITY
def getMessageCount(self):
return self._message_count
def getRecentCount(self):
return 0
def getUnseenCount(self):
return 0
def isWriteable(self):
return 1
def destroy(self):
pass
def getHierarchicalDelimiter(self):
return "/"
@defer.inlineCallbacks
def requestStatus(self, names):
if not self._uid_index_built:
yield self._build_uid_index()
else:
count = yield self._refresh_count()
if count != self._message_count:
self._message_count = count
yield self._build_uid_index()
r = {}
if "MESSAGES" in names:
r["MESSAGES"] = self._message_count
if "RECENT" in names:
r["RECENT"] = self.getRecentCount()
if "UIDNEXT" in names:
r["UIDNEXT"] = self.getUIDNext()
if "UIDVALIDITY" in names:
r["UIDVALIDITY"] = self.getUIDValidity()
if "UNSEEN" in names:
r["UNSEEN"] = self.getUnseenCount()
return r
def _refresh_count(self) -> defer.Deferred:
return self._client.get_message_count(self.name)
@defer.inlineCallbacks
def _build_uid_index(self):
"""Build UID index by fetching all message IDs from backend."""
count = yield self._client.get_message_count(self.name)
self._message_count = count
_logger.info("Building UID index for %s: count=%d", self.name, count)
if count == 0:
self._uid_index = []
self._uid_index_built = True
return
uid_set = set()
batch_size = 100
offset = 0
while offset < count:
limit = min(batch_size, count - offset)
results, _ = yield self._client.get_messages(
self.name, limit, offset
)
for item in results:
item_id = item.get("id")
if item_id is not None and item_id not in uid_set:
uid_set.add(item_id)
_logger.info(
"UID index batch: offset=%d limit=%d got=%d total_uids=%d",
offset, limit, len(results), len(uid_set),
)
offset += limit
self._uid_index = sorted(uid_set)
self._uid_index_built = True
_logger.info(
"UID index built for %s: %d UIDs, range=%s..%s",
self.name, len(self._uid_index),
self._uid_index[0] if self._uid_index else "N/A",
self._uid_index[-1] if self._uid_index else "N/A",
)
def _seq_to_uid(self, seq: int) -> int | None:
"""Convert 1-based sequence number to UID."""
if 1 <= seq <= len(self._uid_index):
return self._uid_index[seq - 1]
return None
def _uid_to_seq(self, uid: int) -> int | None:
"""Convert UID to 1-based sequence number."""
idx = bisect.bisect_left(self._uid_index, uid)
if idx < len(self._uid_index) and self._uid_index[idx] == uid:
return idx + 1
return None
def _resolve_message_set(self, messages, uid: bool) -> list[int]:
"""Resolve an IMAP MessageSet to a list of UIDs."""
result_uids = []
if not self._uid_index:
return result_uids
max_uid = self._uid_index[-1]
max_seq = len(self._uid_index)
_logger.info(
"Resolving message_set: uid=%s ranges=%s max_uid=%d max_seq=%d",
uid, list(messages.ranges), max_uid, max_seq,
)
for start, end in messages.ranges:
if uid:
actual_end = end if end is not None else max_uid
for u in self._uid_index:
if start <= u <= actual_end:
result_uids.append(u)
else:
actual_end = end if end is not None else max_seq
actual_start = max(start, 1)
actual_end = min(actual_end, max_seq)
for seq in range(actual_start, actual_end + 1):
u = self._seq_to_uid(seq)
if u is not None:
result_uids.append(u)
return result_uids
@defer.inlineCallbacks
def _fetch_and_cache_messages(self, uids: list[int]):
"""Fetch uncached messages from backend in batches."""
uncached = [u for u in uids if u not in self._cache]
if not uncached:
return
uncached_set = set(uncached)
id_to_data = {}
batch_size = 50
total = self._message_count
_logger.info(
"Fetching %d uncached messages (total=%d) for %s",
len(uncached), total, self.name,
)
if total == 0:
return
fetched_ids = set()
offset = 0
while offset < total and len(fetched_ids) < len(uncached):
limit = min(batch_size, total - offset)
results, _ = yield self._client.get_messages(
self.name, limit, offset
)
for item in results:
item_id = item.get("id")
if item_id in uncached_set and item_id not in fetched_ids:
id_to_data[item_id] = item
fetched_ids.add(item_id)
if len(fetched_ids) >= len(uncached):
break
offset += limit
_logger.info(
"Fetched %d/%d messages for %s",
len(id_to_data), len(uncached), self.name,
)
for uid_val in uncached:
if uid_val in id_to_data:
item = id_to_data[uid_val]
try:
if self.name == "INBOX":
raw = item.get("raw", "")
raw = fix_mojibake(raw)
raw = clean_raw_headers(raw)
email_model = parse_email(raw)
elif self.name == "SENT":
email_model, raw = generate_email_model(item)
else:
continue
if uid_val not in self._flags:
self._flags[uid_val] = {r"\Seen"}
flags = self._flags[uid_val]
msg = SimpleMessage(
uid_val, email_model, flags=flags, raw=raw,
created_at=item.get("created_at"),
)
self._cache.put(uid_val, msg)
except Exception as e:
_logger.error(f"Failed to parse message uid={uid_val}: {e}")
@defer.inlineCallbacks
def fetch(self, messages, uid):
if not self._uid_index_built:
yield self._build_uid_index()
else:
count = yield self._refresh_count()
if count != self._message_count:
self._message_count = count
yield self._build_uid_index()
target_uids = self._resolve_message_set(messages, uid)
_logger.info(
"FETCH: uid=%s target_uids=%d message_set=%s",
uid, len(target_uids),
target_uids[:5] if len(target_uids) > 5 else target_uids,
)
if not target_uids:
return []
yield self._fetch_and_cache_messages(target_uids)
result = []
for u in target_uids:
cached = self._cache.get(u)
if cached is not None:
flags = self._flags.get(u, set())
cached._flags = flags
seq = self._uid_to_seq(u)
if seq is not None:
result.append((seq, cached))
return result
def getUID(self, message):
return message
@defer.inlineCallbacks
def store(self, messages, flags, mode, uid):
if not self._uid_index_built:
yield self._build_uid_index()
if not self._uid_index:
return {}
target_uids = self._resolve_message_set(messages, uid)
result = {}
for u in target_uids:
current_flags = self._flags.get(u, set())
if mode == 1: # +FLAGS
current_flags = current_flags | set(flags)
elif mode == -1: # -FLAGS
current_flags = current_flags - set(flags)
elif mode == 0: # FLAGS (replace)
current_flags = set(flags)
self._flags[u] = current_flags
seq = self._uid_to_seq(u)
if seq is not None:
result[seq] = current_flags
return result
@defer.inlineCallbacks
def search(self, query, uid):
if not self._uid_index_built:
yield self._build_uid_index()
results = []
for term in query:
if isinstance(term, str) and term.upper() == "ALL":
if uid:
results = list(self._uid_index)
else:
results = list(range(1, len(self._uid_index) + 1))
break
if not results:
if uid:
results = list(self._uid_index)
else:
results = list(range(1, len(self._uid_index) + 1))
return results
def getUIDNext(self):
if self._uid_index:
return self._uid_index[-1] + 1
return 1
def expunge(self):
return defer.succeed([])

View File

@@ -0,0 +1,123 @@
from io import BytesIO
from datetime import datetime, timezone
from twisted.mail import imap4
from zope.interface import implementer
from models import EmailModel
# Locale-independent English names for IMAP date formatting
_MONTHS = ('', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
_DAYS = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
_CREATED_AT_FMTS = (
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S.%fZ",
"%Y-%m-%d %H:%M:%S.%f",
)
def parse_created_at(created_at: str) -> datetime | None:
"""Parse created_at string into datetime, returns None on failure."""
for fmt in _CREATED_AT_FMTS:
try:
return datetime.strptime(created_at, fmt)
except ValueError:
continue
return None
def format_imap_date(dt: datetime) -> str:
"""Format datetime as IMAP INTERNALDATE: '21-Mar-2026 13:04:59 +0000'."""
return (f"{dt.day:02d}-{_MONTHS[dt.month]}-{dt.year} "
f"{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d} +0000")
def format_rfc2822_date(dt: datetime) -> str:
"""Format datetime as RFC 2822: 'Thu, 13 Mar 2026 11:15:57 +0000'."""
return (f"{_DAYS[dt.weekday()]}, {dt.day:02d} {_MONTHS[dt.month]} {dt.year} "
f"{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d} +0000")
@implementer(imap4.IMessage, imap4.IMessageFile)
class SimpleMessage:
def __init__(self, uid: int, email_model: EmailModel,
flags: set[str] = None, raw: str = None, created_at: str = None):
self.uid = uid
self.email = email_model
self.subparts = self.email.subparts
self._flags = flags if flags is not None else set()
self._raw = raw
self._created_at = created_at
self._fill_date_header()
def _fill_date_header(self):
"""Fill empty/missing Date header from created_at."""
date_val = self.email.headers.get("Date", "").strip()
if date_val or not self._created_at:
return
dt = parse_created_at(self._created_at)
if dt:
self.email.headers["Date"] = format_rfc2822_date(dt)
def getUID(self):
return self.uid
def getHeaders(self, negate, *names):
names_lower = set()
for n in names:
if isinstance(n, bytes):
names_lower.add(n.decode("ascii", errors="replace").lower())
else:
names_lower.add(n.lower())
if not names_lower:
return {k.lower(): v for k, v in self.email.headers.items()}
if negate:
return {
k.lower(): v
for k, v in self.email.headers.items()
if k.lower() not in names_lower
}
return {
k.lower(): v
for k, v in self.email.headers.items()
if k.lower() in names_lower
}
def isMultipart(self):
return len(self.subparts) > 0
def getSubPart(self, part):
if not self.subparts:
if part == 0:
return SimpleMessage(self.uid, self.email, flags=self._flags)
raise IndexError(part)
return SimpleMessage(self.uid, self.subparts[part], flags=self._flags)
def getBodyFile(self):
return BytesIO(self.email.body.encode("utf-8"))
def getSize(self):
if self._raw is not None:
return len(self._raw.encode("utf-8"))
return self.email.size
def getFlags(self):
return list(self._flags)
def getInternalDate(self):
if self._created_at:
dt = parse_created_at(self._created_at)
if dt:
return format_imap_date(dt)
return self.email.headers.get("Date", "Mon, 1 Jan 1900 00:00:00 +0000")
# IMessageFile
def open(self):
"""Return complete raw MIME message for BODY[] requests."""
if self._raw is not None:
return BytesIO(self._raw.encode("utf-8"))
return BytesIO(self.email.body.encode("utf-8"))

View File

@@ -1,292 +1,134 @@
import json
import logging
import httpx
from io import BytesIO
import httpx
from twisted.mail import imap4
from zope.interface import implementer
from twisted.cred.portal import Portal, IRealm
from twisted.internet import protocol, reactor, defer
from twisted.internet import protocol, reactor, defer, ssl, threads
from twisted.cred import error as cred_error
from twisted.cred.checkers import ICredentialsChecker, IUsernamePassword
from config import settings
from parse_email import generate_email_model, parse_email
from models import EmailModel
from imap_http_client import BackendClient
from imap_mailbox import SimpleMailbox
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
@implementer(imap4.IMessage)
class SimpleMessage:
def __init__(self, uid=None, email_model: EmailModel = None):
self.uid = uid
self.email = email_model
self.subparts = self.email.subparts
def getUID(self):
return self.uid
def getHeaders(self, negate, *names):
self.got_headers = negate, names
return {
k.lower(): v
for k, v in self.email.headers.items()
}
def isMultipart(self):
return len(self.subparts) > 0
def getSubPart(self, part):
self.got_subpart = part
return SimpleMessage(email_model=self.subparts[part])
def getBodyFile(self):
return BytesIO(self.email.body.encode("utf-8"))
def getSize(self):
return self.email.size
def getFlags(self):
return ["\\Seen"]
def getInternalDate(self):
return self.email.headers.get("Date", "Mon, 1 Jan 1900 00:00:00 +0000")
@implementer(imap4.IMailboxInfo, imap4.IMailbox)
class SimpleMailbox:
def __init__(self, name, password):
self.name = name
self.password = password
self.listeners = []
self.addListener = self.listeners.append
self.removeListener = self.listeners.remove
self.message_count = 0
self._update_message_count()
def _update_message_count(self):
"""主动获取邮件总数"""
try:
if self.name == "INBOX":
endpoint = "/api/mails"
elif self.name == "SENT":
endpoint = "/api/sendbox"
else:
return
res = httpx.get(
f"{settings.proxy_url}{endpoint}?limit=1&offset=0",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)
if res.status_code == 200:
self.message_count = res.json()["count"]
# _logger.info(f"Updated {self.name} message count: {self.message_count}")
except Exception as e:
_logger.error(f"Failed to update message count for {self.name}: {e}")
def getFlags(self):
return ["\\Seen"]
def getUIDValidity(self):
return 0
def getMessageCount(self):
# 每次请求时更新邮件总数
self._update_message_count()
return self.message_count
def getRecentCount(self):
return 0
def getUnseenCount(self):
return 0
def isWriteable(self):
return 0
def destroy(self):
pass
def getHierarchicalDelimiter(self):
return "/"
def requestStatus(self, names):
# 在状态请求时也更新邮件总数
self._update_message_count()
r = {}
if "MESSAGES" in names:
r["MESSAGES"] = self.getMessageCount()
if "RECENT" in names:
r["RECENT"] = self.getRecentCount()
if "UIDNEXT" in names:
r["UIDNEXT"] = self.getMessageCount() + 1
if "UIDVALIDITY" in names:
r["UIDVALIDITY"] = self.getUIDValidity()
if "UNSEEN" in names:
r["UNSEEN"] = self.getUnseenCount()
return defer.succeed(r)
def fetch(self, messages, uid):
"""边查边返回邮件"""
result = []
for range_item in messages.ranges:
start, end = range_item
_logger.info(f"Fetching messages: {self.name}, range: {start}-{end}")
for email_data in self.fetchGenerator(start, end):
result.append(email_data)
# 返回列表而不是生成器,以支持 IMAP SEARCH 等需要索引访问的操作
return result
def fetchGenerator(self, start, end):
"""通用的邮件获取生成器,边查边返回"""
start = max(start, 1)
# 根据邮箱类型确定API端点
if self.name == "INBOX":
endpoint = "/api/mails"
elif self.name == "SENT":
endpoint = "/api/sendbox"
else:
return
# 首先获取服务端邮件总数
count_res = httpx.get(
f"{settings.proxy_url}{endpoint}?limit=1&offset=0",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)
if count_res.status_code != 200:
_logger.error(
f"Failed to get {self.name} email count: "
f"code=[{count_res.status_code}] text=[{count_res.text}]"
)
return
total_count = count_res.json()["count"]
self.message_count = total_count
if total_count == 0 or start > total_count:
return
# 分批处理,每次获取一小批就立即返回
batch_size = 20
current_start = start
current_end = min(end or total_count, total_count)
while current_start <= current_end:
batch_end = min(current_start + batch_size - 1, current_end)
# 计算这一批的参数
limit = batch_end - current_start + 1
server_offset = total_count - batch_end
server_offset = max(0, server_offset)
_logger.info(
f"Fetching batch: start={current_start}, end={batch_end}, "
f"total_count={total_count}, limit={limit}, "
f"server_offset={server_offset}"
)
res = httpx.get(
f"{settings.proxy_url}{endpoint}?limit={limit}&offset={server_offset}",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)
if res.status_code != 200:
_logger.error(
f"Failed to fetch {self.name} emails: "
f"code=[{res.status_code}] text=[{res.text}]"
)
break
emails = res.json()["results"]
for i, item in enumerate(reversed(emails)):
uid = total_count - server_offset - len(emails) + i + 1
if current_start <= uid <= batch_end:
if self.name == "INBOX":
email_model = parse_email(item["raw"])
elif self.name == "SENT":
email_model = generate_email_model(item)
# 立即返回这封邮件
yield (uid, SimpleMessage(uid, email_model))
current_start = batch_end + 1
def getUID(self, message):
return message.uid
def store(self, messages, flags, mode, uid):
# IMailboxIMAP.store
raise NotImplementedError
class Account(imap4.MemoryAccount):
def __init__(self, user, password):
self.password = password
super().__init__(user)
def isSubscribed(self, name):
return name.upper() in ["INBOX", "SENT"]
def _emptyMailbox(self, name, id):
_logger.info(f"New mailbox: {name}, {id}")
if name == "INBOX":
return SimpleMailbox(name, self.password)
if name == "SENT":
return SimpleMailbox(name, self.password)
raise imap4.NoSuchMailbox(name.encode("utf-8"))
def select(self, name, rw=1):
return imap4.MemoryAccount.select(self, name)
_logger.setLevel(logging.DEBUG)
logging.basicConfig(level=logging.DEBUG)
class SimpleIMAPServer(imap4.IMAP4Server):
def __init__(self, factory):
imap4.IMAP4Server.__init__(self)
self.factory = factory
def __init__(self, context_factory=None):
chal = {
b"LOGIN": imap4.LOGINCredentials,
b"PLAIN": imap4.PLAINCredentials,
}
imap4.IMAP4Server.__init__(
self, chal=chal, contextFactory=context_factory
)
def lineReceived(self, line):
# _logger.info(f"Received: {line}")
super().lineReceived(line)
_logger.debug("C: %s", line)
return imap4.IMAP4Server.lineReceived(self, line)
def sendLine(self, line):
# _logger.info(f"Sent: {line}")
super().sendLine(line)
_logger.debug("S: %s", line)
return imap4.IMAP4Server.sendLine(self, line)
def connectionMade(self):
"""Wrap transport to log raw data sent to client."""
imap4.IMAP4Server.connectionMade(self)
real_write_seq = self.transport.writeSequence
def logging_write_seq(data):
joined = b''.join(data)
for line in joined.split(b'\r\n'):
if line:
_logger.debug("S-RAW: %s", line[:300])
return real_write_seq(data)
self.transport.writeSequence = logging_write_seq
def _cbSelectWork(self, mbox, cmdName, tag):
"""Override to add UIDNEXT in SELECT response (RFC 3501)."""
if mbox is None:
self.sendNegativeResponse(tag, b"No such mailbox")
return
if "\\noselect" in [s.lower() for s in mbox.getFlags()]:
self.sendNegativeResponse(tag, "Mailbox cannot be selected")
return
flags = [imap4.networkString(flag) for flag in mbox.getFlags()]
self.sendUntaggedResponse(b"%d EXISTS" % (mbox.getMessageCount(),))
self.sendUntaggedResponse(b"%d RECENT" % (mbox.getRecentCount(),))
self.sendUntaggedResponse(b"FLAGS (" + b" ".join(flags) + b")")
self.sendPositiveResponse(
None, b"[UIDVALIDITY %d]" % (mbox.getUIDValidity(),)
)
self.sendPositiveResponse(
None, b"[UIDNEXT %d]" % (mbox.getUIDNext(),)
)
s = mbox.isWriteable() and b"READ-WRITE" or b"READ-ONLY"
mbox.addListener(self)
self.sendPositiveResponse(
tag, b"[" + s + b"] " + cmdName + b" successful"
)
self.state = "select"
self.mbox = mbox
class Account(imap4.MemoryAccount):
"""Custom account that initializes mailbox UID index on select."""
def _emptyMailbox(self, name, id):
"""Return a dummy mailbox for CREATE requests (e.g. Gmail creating Drafts)."""
_logger.debug("Accepting CREATE request for %s", name)
return SimpleMailbox(name, self._client)
def create(self, pathspec):
"""Accept CREATE silently without actually creating mailboxes."""
_logger.debug("Ignoring CREATE for %s", pathspec)
return True
def listMailboxes(self, ref, wildcard):
"""Only list INBOX and SENT, ignore client-created mailboxes."""
return [("INBOX", self.mailboxes["INBOX"]), ("SENT", self.mailboxes["SENT"])]
@defer.inlineCallbacks
def select(self, name, readwrite=1):
mbox = self.mailboxes.get(imap4._parseMbox(name.upper()))
if mbox is not None:
yield mbox._build_uid_index()
return mbox
@implementer(IRealm)
class SimpleRealm:
def requestAvatar(self, avatarId, mind, *interfaces):
res = json.loads(avatarId)
account = Account(res["username"], res["password"])
account.addMailbox("INBOX")
account.addMailbox("SENT")
return imap4.IAccount, account, lambda: None
username = res["username"]
password = res["password"]
client = BackendClient(password)
inbox = SimpleMailbox("INBOX", client)
sent = SimpleMailbox("SENT", client)
account = Account(username)
account._client = client
account.mailboxes = {"INBOX": inbox, "SENT": sent}
account.subscriptions = ["INBOX", "SENT"]
return imap4.IAccount, account, lambda: client.close()
class IMAPFactory(protocol.Factory):
def __init__(self, portal):
def __init__(self, portal, context_factory=None):
self.portal = portal
self._context_factory = context_factory
def buildProtocol(self, addr):
p = SimpleIMAPServer(self)
p = SimpleIMAPServer(context_factory=self._context_factory)
p.portal = self.portal
return p
@@ -295,20 +137,77 @@ class IMAPFactory(protocol.Factory):
class CustomChecker:
credentialInterfaces = (IUsernamePassword,)
@staticmethod
def _is_jwt(token: str) -> bool:
"""Check if token looks like a JWT (eyJ... with 3 dot-separated parts)."""
parts = token.split(".")
return len(parts) == 3 and parts[0].startswith("eyJ")
def requestAvatarId(self, credentials):
return defer.succeed(json.dumps({
"username": credentials.username.decode(),
"password": credentials.password.decode(),
}))
username = credentials.username.decode()
password = credentials.password.decode()
if self._is_jwt(password):
_logger.info("Login via JWT token")
return defer.succeed(json.dumps({
"username": username,
"password": password,
}))
# Not a JWT — try address+password login via backend
_logger.info("Login via address+password")
d = threads.deferToThread(self._login_with_password, username, password)
return d
@staticmethod
def _login_with_password(username: str, password: str) -> str:
"""Exchange address+password for a JWT via backend."""
res = httpx.post(
f"{settings.proxy_url}/api/address_login",
json={"email": username, "password": password},
headers={
"x-custom-auth": settings.basic_password,
"Content-Type": "application/json",
},
timeout=settings.imap_http_timeout,
)
if res.status_code == 200:
jwt = res.json().get("jwt")
if jwt:
return json.dumps({
"username": username,
"password": jwt,
})
raise cred_error.UnauthorizedLogin(f"address_login failed: {res.status_code}")
def start_imap_server():
_logger.info(f"Starting IMAP server on port {settings.imap_port}")
_logger.info("Starting IMAP server on port %s", settings.imap_port)
context_factory = None
has_cert = bool(settings.imap_tls_cert)
has_key = bool(settings.imap_tls_key)
if has_cert != has_key:
raise ValueError(
"Both imap_tls_cert and imap_tls_key must be set together"
)
if has_cert and has_key:
_logger.info("TLS enabled for IMAP (STARTTLS)")
context_factory = ssl.DefaultOpenSSLContextFactory(
settings.imap_tls_key,
settings.imap_tls_cert,
)
portal = Portal(SimpleRealm(), [CustomChecker()])
reactor.listenTCP(settings.imap_port, IMAPFactory(portal))
factory = IMAPFactory(portal, context_factory=context_factory)
reactor.listenTCP(settings.imap_port, factory)
reactor.run()
if __name__ == "__main__":
_logger.info(f"Starting server settings[{settings}]")
_logger.info(
"Starting IMAP server proxy_url=%s port=%s tls=%s",
settings.proxy_url, settings.imap_port,
bool(settings.imap_tls_cert and settings.imap_tls_key),
)
start_imap_server()

View File

@@ -9,7 +9,10 @@ _logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
if __name__ == '__main__':
_logger.info(f"Starting server settings[{settings}]")
_logger.info(
"Starting server proxy_url=%s smtp_port=%s imap_port=%s",
settings.proxy_url, settings.port, settings.imap_port,
)
process_list = [
multiprocessing.Process(target=start_smtp_server, args=()),
multiprocessing.Process(target=start_imap_server, args=()),

View File

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

View File

@@ -1,5 +1,6 @@
aiosmtpd==1.4.6
pydantic-settings==2.9.1
requests==2.32.4
pydantic-settings==2.13.1
Twisted==25.5.0
httpx==0.28.1
pyOpenSSL==26.0.0
service-identity==24.2.0

View File

@@ -1,6 +1,8 @@
import asyncio
import logging
import email
import ssl
import httpx
from aiosmtpd.controller import Controller
@@ -12,6 +14,15 @@ _logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
def _safe_decode_payload(payload, charset):
if payload is None:
return ""
try:
return payload.decode(charset or "utf-8", errors="replace")
except LookupError:
return payload.decode("utf-8", errors="replace")
class CustomSMTPHandler:
def authenticator(self, server, session, envelope, mechanism, auth_data):
@@ -49,7 +60,7 @@ class CustomSMTPHandler:
value = part.get_payload(decode=False)
else:
payload = part.get_payload(decode=True)
value = payload.decode(charset) if charset else payload
value = _safe_decode_payload(payload, charset)
if not value:
continue
content_list.append({
@@ -63,8 +74,8 @@ class CustomSMTPHandler:
value = msg.get_payload(decode=False)
else:
payload = msg.get_payload(decode=True)
value = payload.decode(charset) if charset else payload
_logger.info(f"Payload {msg._payload} charset {charset}")
value = _safe_decode_payload(payload, charset)
_logger.debug("Parsed content charset=%s", charset)
content_list.append({
"type": msg.get_content_type(),
"value": value
@@ -121,27 +132,41 @@ class CustomSMTPHandler:
return '250 OK'
handler = CustomSMTPHandler()
server = Controller(
handler,
hostname="",
port=settings.port,
auth_require_tls=False,
decode_data=True,
authenticator=handler.authenticator,
auth_exclude_mechanism=["DONT"]
)
def start_smtp_server():
handler = CustomSMTPHandler()
tls_context = None
has_cert = bool(settings.smtp_tls_cert)
has_key = bool(settings.smtp_tls_key)
if has_cert != has_key:
raise ValueError(
"Both smtp_tls_cert and smtp_tls_key must be set together"
)
if has_cert and has_key:
_logger.info("TLS enabled for SMTP (STARTTLS)")
tls_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
tls_context.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3
tls_context.load_cert_chain(settings.smtp_tls_cert, settings.smtp_tls_key)
async def start():
_logger.info(f"Starting server on port {settings.port}")
server = Controller(
handler,
hostname="",
port=settings.port,
auth_require_tls=bool(tls_context),
decode_data=True,
authenticator=handler.authenticator,
auth_exclude_mechanism=["DONT"],
tls_context=tls_context,
)
_logger.info(
"Starting SMTP server on port %s tls=%s",
settings.port, bool(tls_context),
)
server.start()
def start_smtp_server():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task = loop.create_task(start())
try:
loop.run_forever()
except KeyboardInterrupt:
@@ -150,5 +175,9 @@ def start_smtp_server():
if __name__ == "__main__":
_logger.info(f"Starting server settings[{settings}]")
_logger.info(
"Starting SMTP server proxy_url=%s port=%s tls=%s",
settings.proxy_url, settings.port,
bool(settings.smtp_tls_cert and settings.smtp_tls_key),
)
start_smtp_server()

View File

@@ -4,21 +4,32 @@ import { en } from './en'
export default defineConfig({
title: "Temp Mail Doc",
description: 'CloudFlare 免费收发临时域名邮箱 | Free temporary domain email on CloudFlare',
lang: 'zh-CN',
lastUpdated: true,
locales: {
root: { label: '简体中文', ...zh },
zh: { label: '简体中文', ...zh },
en: { label: 'English', ...en }
},
head: [
['link', { rel: 'icon', type: 'image/png', href: '/logo.png' }],
['meta', { name: 'theme-color', content: '#5f67ee' }],
['meta', { name: 'robots', content: 'index, follow' }],
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:locale', content: 'Temp Mail Doc' }],
['meta', { property: 'og:title', content: 'Temp Mail Doc' }],
['meta', { property: 'og:locale', content: 'zh_CN' }],
['meta', { property: 'og:locale:alternate', content: 'en_US' }],
['meta', { property: 'og:title', content: 'Temp Mail - CloudFlare 临时邮箱' }],
['meta', { property: 'og:description', content: 'CloudFlare 免费收发临时域名邮箱支持多域名、附件、Telegram Bot、Webhook、SMTP/IMAP' }],
['meta', { property: 'og:site_name', content: 'Temp Mail' }],
['meta', { property: 'og:image', content: 'https://temp-mail-docs.awsl.uk/logo.png' }],
['meta', { property: 'og:url', content: 'https://temp-mail-docs.awsl.uk' }],
['meta', { name: 'twitter:card', content: 'summary' }],
['meta', { name: 'twitter:title', content: 'Temp Mail - CloudFlare 临时邮箱' }],
['meta', { name: 'twitter:description', content: 'CloudFlare 免费收发临时域名邮箱' }],
['meta', { name: 'twitter:image', content: 'https://temp-mail-docs.awsl.uk/logo.png' }],
['link', { rel: 'alternate', hreflang: 'zh-Hans', href: 'https://temp-mail-docs.awsl.uk/zh/' }],
['link', { rel: 'alternate', hreflang: 'en', href: 'https://temp-mail-docs.awsl.uk/en/' }],
['link', { rel: 'alternate', hreflang: 'x-default', href: 'https://temp-mail-docs.awsl.uk/zh/' }],
],
sitemap: {
hostname: 'https://temp-mail-docs.awsl.uk',

View File

@@ -3,7 +3,12 @@ import { defineConfig, type DefaultTheme } from 'vitepress'
export const en = defineConfig({
title: "Temp Mail Doc",
lang: 'en-US',
description: 'CloudFlare Free sending and receiving of temporary domain name mailboxes',
description: 'Free temporary domain email powered by CloudFlare Workers, with multi-domain, attachments, Telegram Bot, Webhook, SMTP/IMAP support',
head: [
['meta', { property: 'og:locale', content: 'en_US' }],
['meta', { property: 'og:description', content: 'Free temporary domain email powered by CloudFlare Workers, with multi-domain, attachments, Telegram Bot, Webhook, SMTP/IMAP support' }],
],
themeConfig: {
nav: nav(),
@@ -144,19 +149,25 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
items: [
{ text: 'AI Email Recognition', link: 'feature/ai-extract' },
{ text: 'Configure SMTP IMAP Proxy', link: 'feature/config-smtp-proxy' },
{ text: 'Send Email API', link: 'feature/send-mail-api' },
{ text: 'View Email API', link: 'feature/mail-api' },
{ text: 'Configure Subdomain Email', link: 'feature/subdomain' },
{ text: 'Configure Telegram Bot', link: 'feature/telegram' },
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
{ text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' },
{ text: 'Configure Webhook', link: 'feature/webhook' },
{ text: 'New Address API', link: 'feature/new-address-api' },
{ text: 'OAuth2 Third-party Login', link: 'feature/user-oauth2' },
{ text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' },
{ text: 'Add Google Ads', link: 'feature/google-ads.md' },
]
},
{
text: 'API Endpoints',
collapsed: false,
items: [
{ text: 'New Address API', link: 'feature/new-address-api' },
{ text: 'View Email API', link: 'feature/mail-api' },
{ text: 'Send Email API', link: 'feature/send-mail-api' },
]
},
{
text: 'Feature Overview',
collapsed: false,

View File

@@ -3,7 +3,12 @@ import { defineConfig, type DefaultTheme } from 'vitepress'
export const zh = defineConfig({
title: "临时邮箱文档",
lang: 'zh-Hans',
description: 'CloudFlare 免费收发 临时域名邮箱',
description: 'CloudFlare 免费收发临时域名邮箱支持多域名、附件、Telegram Bot、Webhook、SMTP/IMAP',
head: [
['meta', { property: 'og:locale', content: 'zh_CN' }],
['meta', { property: 'og:description', content: 'CloudFlare 免费收发临时域名邮箱支持多域名、附件、Telegram Bot、Webhook、SMTP/IMAP' }],
],
themeConfig: {
nav: nav(),
@@ -53,7 +58,7 @@ function nav(): DefaultTheme.NavItem[] {
return [
{
text: '主页',
link: '/',
link: '/zh/',
},
{
text: '指南',
@@ -62,11 +67,11 @@ function nav(): DefaultTheme.NavItem[] {
},
{
text: '服务状态',
link: '/status',
link: '/zh/status',
},
{
text: '参考',
link: '/reference',
link: '/zh/reference',
},
{
text: process.env.TAG_NAME || 'v0.2.2',
@@ -144,19 +149,25 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
items: [
{ text: 'AI 邮件识别', link: 'feature/ai-extract' },
{ text: '配置 SMTP IMAP 代理服务', link: 'feature/config-smtp-proxy' },
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
{ text: '查看邮件 API', link: 'feature/mail-api' },
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
{ text: '配置 Telegram Bot', link: 'feature/telegram' },
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
{ text: '配置 webhook', link: 'feature/webhook' },
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
{ text: 'Oauth2 第三方登录', link: 'feature/user-oauth2' },
{ text: '配置其他worker增强', link: 'feature/another-worker-enhanced' },
{ text: '给网页增加 Google Ads', link: 'feature/google-ads.md' },
]
},
{
text: 'API 接口',
collapsed: false,
items: [
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
{ text: '查看邮件 API', link: 'feature/mail-api' },
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
]
},
{
text: '功能简介',
collapsed: false,
@@ -165,6 +176,6 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },
]
},
{ text: '参考', base: "/", link: 'reference' }
{ text: '参考', base: "/zh/", link: 'reference' }
]
}

View File

@@ -51,10 +51,59 @@ services:
- imap_port=11143
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `proxy_url` | `http://localhost:8787` | Worker backend URL |
| `port` | `8025` | SMTP port |
| `imap_port` | `11143` | IMAP port |
| `smtp_tls_cert` | empty | SMTP TLS certificate file path (PEM), enables STARTTLS when configured |
| `smtp_tls_key` | empty | SMTP TLS private key file path (PEM) |
| `imap_tls_cert` | empty | IMAP TLS certificate file path (PEM), enables STARTTLS when configured |
| `imap_tls_key` | empty | IMAP TLS private key file path (PEM) |
| `imap_cache_size` | `500` | Max cached messages per mailbox |
| `imap_http_timeout` | `30.0` | Backend HTTP request timeout (seconds) |
## Enabling STARTTLS
Configure the TLS certificate environment variables for SMTP and/or IMAP to enable STARTTLS support. SMTP and IMAP can share the same certificate.
```bash
# .env example
smtp_tls_cert=/path/to/cert.pem
smtp_tls_key=/path/to/key.pem
imap_tls_cert=/path/to/cert.pem
imap_tls_key=/path/to/key.pem
```
In Docker Compose:
```yaml
environment:
- smtp_tls_cert=/certs/cert.pem
- smtp_tls_key=/certs/key.pem
- imap_tls_cert=/certs/cert.pem
- imap_tls_key=/certs/key.pem
volumes:
- ./certs:/certs:ro
```
## IMAP Login Methods
Two login methods are supported:
| Method | Username | Password | Description |
|--------|----------|----------|-------------|
| JWT Credential | Email address | JWT token | Address credential from frontend, direct authentication |
| Address+Password | Email address | Address password | Verified via backend `/api/address_login` |
The system automatically detects the password format: a three-segment string starting with `eyJ` is treated as a JWT; otherwise it is treated as a password and verified via the backend.
## Using Thunderbird to Login
Download [Thunderbird](https://www.thunderbird.net/en-US/)
For password, enter the `email address credential`
For password, enter the `email address credential` or `email address password`
![imap](/feature/imap.png)

View File

@@ -11,12 +11,14 @@ res = requests.get(
f"https://<your-worker-address>/api/mails?limit={limit}&offset={offset}",
headers={
"Authorization": f"Bearer {your-JWT-password}",
# "x-custom-auth": "<your-website-password>", # If custom password is enabled
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
"Content-Type": "application/json"
}
)
```
**Note**: `/api/mails` returns raw RFC822 data by design (for example `source`/`raw`), and it does not guarantee parsed fields such as `subject`, `text`, or `html`. Parse the raw source on the client side (for example with `mail-parser-wasm` or `postal-mime`) if you need readable message content.
## Admin Mail API
Supports `address` filter
@@ -33,17 +35,110 @@ querystring = {
"address":"xxxx@awsl.uk"
}
headers = {"x-admin-auth": "<your-Admin-password>"}
headers = {
"x-admin-auth": "<your-Admin-password>",
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
}
response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**Note**: `/admin/mails` follows the same design as `/api/mails`: it returns stored raw MIME data. If you need readable subject/body, parse the raw content on the client side.
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.
## Admin Delete Mail API
Delete a single mail by mail ID.
```python
import requests
mail_id = 1
url = f"https://<your-worker-address>/admin/mails/{mail_id}"
headers = {
"x-admin-auth": "<your-Admin-password>",
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
}
response = requests.delete(url, headers=headers)
print(response.json())
```
## Admin Delete Address API
Delete an email address by address ID (also deletes associated mails, sender permissions, and user bindings).
```python
import requests
address_id = 1
url = f"https://<your-worker-address>/admin/delete_address/{address_id}"
headers = {
"x-admin-auth": "<your-Admin-password>",
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
}
response = requests.delete(url, headers=headers)
print(response.json())
```
## Admin Clear Inbox API
Clear all received mails for an address by address ID.
```python
import requests
address_id = 1
url = f"https://<your-worker-address>/admin/clear_inbox/{address_id}"
headers = {
"x-admin-auth": "<your-Admin-password>",
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
}
response = requests.delete(url, headers=headers)
print(response.json())
```
## Admin Clear Sent Items API
Clear all sent mails for an address by address ID.
```python
import requests
address_id = 1
url = f"https://<your-worker-address>/admin/clear_sent_items/{address_id}"
headers = {
"x-admin-auth": "<your-Admin-password>",
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
}
response = requests.delete(url, headers=headers)
print(response.json())
```
## User Mail API
::: warning Note: User JWT vs Address JWT
This endpoint uses **User JWT** (obtained via `/user_api/login` or `/user_api/register`), with `x-user-token` header.
**Do not confuse with Address JWT**:
- Address JWT uses `Authorization: Bearer <jwt>` to access `/api/*` endpoints
- User JWT uses `x-user-token: <jwt>` to access `/user_api/*` endpoints
:::
Supports `address` filter
```python
@@ -58,11 +153,16 @@ querystring = {
"address":"xxxx@awsl.uk"
}
headers = {"x-admin-auth": "<your-Admin-password>"}
headers = {
"x-user-token": "<your-user-JWT-token>",
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
}
response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**Note**: `/user_api/mails` also returns raw RFC822 content from storage; parse it in your client to extract `subject`, `text`, and `html`.
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.

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