Compare commits

..

19 Commits

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

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

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

* docs: consolidate IMAP changelog entries into single line

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

---------

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

* Apply suggestions from code review

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

---------

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

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

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

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

* docs: mention Android via browser in changelog

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

* chore: add missing entries to worker .gitignore

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

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

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

---------

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

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

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

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

Closes #894

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

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

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

* refactor: remove unused i18n attachment messages

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

* refactor: use sendMediaGroup for batch attachment sending

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

* refactor: remove redundant commonParseMail call, use cached result

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: unify attachment sending with sendMediaGroup for all cases

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

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

* chore: reduce sendMediaGroup batch size to 6

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

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

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

* chore: regenerate wasm parser patch with attachments support

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

* feat: add ENABLE_TG_PUSH_ATTACHMENT env var to control attachment push

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

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

---------

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

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

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

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

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

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

* docs: add ENABLE_LOGIN_TURNSTILE_CHECK to wrangler.toml.template

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

* fix: ensure openSettings loaded before admin login modal

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

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

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

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

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

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

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

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

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

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

* fix: use unique IDs for multiple Turnstile instances

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

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

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

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

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

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

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

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

* fix: add enableLoginTurnstileCheck to store defaults, simplify changelog

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

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

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

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

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

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

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

* refactor: rename to ENABLE_GLOBAL_TURNSTILE_CHECK and add isGlobalTurnstileEnabled helper

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

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

* refactor: use utils.isGlobalTurnstileEnabled instead of named import

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

* test: add E2E tests for turnstile login endpoints

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

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

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

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

* fix: validate JWT payload has address field in credential_login

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* feat: upgrade @simplewebauthn packages from v10 to v13

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

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

* test: add passkey API E2E tests

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

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

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

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

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

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

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

* fix: allow crypto.subtle in Docker browser tests

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

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

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

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

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

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

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

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

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

* fix: store userJwt without JSON.stringify in localStorage

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

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

* refactor: clean up passkey API test per review feedback

Remove unused variables and rename test to match actual behavior.

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

---------

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

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

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

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

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

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

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

* feat: upgrade version to v1.5.0

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

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

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

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

* refactor: address CodeRabbit review suggestions

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

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

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

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

---------

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

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

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

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

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

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

* fix: use hyphenated cache-clearing in release skill

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:36:19 +08:00
79 changed files with 2513 additions and 959 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,17 +14,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: Deploy Frontend for ${{ github.ref_name }}
@@ -51,17 +51,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: Deploy Telegram Frontend for ${{ github.ref_name }}

View File

@@ -32,17 +32,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: Deploy Frontend for ${{ github.ref_name }}

View File

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

View File

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

View File

@@ -10,17 +10,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: Build Frontend
@@ -39,17 +39,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: Build Telegram Frontend
@@ -68,17 +68,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: cp wrangler.toml

View File

@@ -6,7 +6,33 @@
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
</p>
## v1.4.0(main)
## v1.5.0(main)
### Features
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767
- feat: |Telegram| Telegram 推送支持发送邮件附件(单文件限制 50MB多附件通过 `sendMediaGroup` 批量发送,通过 `ENABLE_TG_PUSH_ATTACHMENT` 环境变量开启(#894
### Bug Fixes
- fix: |自动回复| 修复 `source_prefix` 为空字符串时自动回复不触发的问题(#459),空值现在正确匹配所有发件人
- fix: |OAuth2| 修复 Android via 浏览器等移动端 OAuth2 登录时 sessionStorage 丢失导致回调失败的问题,新增 localStorage 兜底(#900
- fix: |IMAP| 修复嵌套回复邮件乱码、Gmail 空 Content-Type 头解析失败、缺失 Date 头及 locale 依赖日期格式等问题
### Testing
- test: |E2E| 新增自动回复触发 E2E 测试,覆盖空前缀、前缀匹配、正则匹配和禁用状态场景
### Docs
- docs: |Telegram| 新增每用户邮件推送和全局推送功能说明文档(#769
- docs: |Webhook| 新增 Telegram Bot、企业微信、Discord 等常用推送平台的 Webhook 模板示例
- feat: |Webhook| 前端预设模板新增 Telegram Bot、企业微信、Discord 三个模板
### Improvements
## v1.4.0
### Features

View File

@@ -6,7 +6,33 @@
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
</p>
## v1.4.0(main)
## v1.5.0(main)
### Features
- feat: |Auto Reply| Add regex matching support for sender filter using `/pattern/` syntax (e.g. `/@example\.com$/`), backward compatible with prefix matching
- feat: |Turnstile| Add global Turnstile CAPTCHA for all login forms via `ENABLE_GLOBAL_TURNSTILE_CHECK` env var (#767)
- feat: |Telegram| Support sending email attachments in Telegram push (50MB per file limit), multiple attachments sent via `sendMediaGroup`, controlled by `ENABLE_TG_PUSH_ATTACHMENT` env var (#894)
### Bug Fixes
- fix: |Auto Reply| Fix auto-reply not triggering when `source_prefix` is empty string (#459), empty value now correctly matches all senders
- fix: |OAuth2| Fix OAuth2 login callback failure on Android via browser and other mobile browsers due to sessionStorage loss during redirect, add localStorage fallback (#900)
- fix: |IMAP| Fix nested reply email mojibake, Gmail empty Content-Type header parsing failure, missing Date header, and locale-dependent date formatting issues
### Testing
- test: |E2E| Add auto-reply trigger E2E tests covering empty prefix, prefix matching, regex matching, and disabled state
### Docs
- docs: |Telegram| Add per-user mail push and global push documentation (#769)
- docs: |Webhook| Add webhook template examples for Telegram Bot, WeChat Work, Discord and other common push platforms
- feat: |Webhook| Add Telegram Bot, WeChat Work, Discord preset templates to frontend webhook settings
### Improvements
## v1.4.0
### Features

View File

@@ -11,6 +11,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **SMTP/IMAP proxy**: `smtp_proxy_server/` — Python proxy server.
- **DB schema/migrations**: `db/` — SQLite via Cloudflare D1, dated migration patches.
- **Docs**: `vitepress-docs/` — VitePress documentation site (zh + en).
- **E2E tests**: `e2e/` — Playwright tests in Docker Compose (API, browser, SMTP proxy).
- **Changelogs**: `CHANGELOG.md` (中文) + `CHANGELOG_EN.md` (English).
## Build & Dev Commands
@@ -26,19 +27,58 @@ Run inside each subfolder with `pnpm`:
SMTP proxy: `pip install -r smtp_proxy_server/requirements.txt` then `python smtp_proxy_server/main.py`.
## E2E Tests
Tests run in Docker Compose with Playwright. From `e2e/`:
```bash
npm test # Build, run all tests, exit
npm run test:down # Clean up containers
```
Test categories: `tests/api/` (API tests), `tests/browser/` (UI tests with Chromium), `tests/smtp-proxy/` (SMTP/IMAP proxy tests).
The Docker frontend serves over **HTTPS** (self-signed cert) with Vite proxy to worker — required for WebAuthn (`navigator.credentials`) and `crypto.subtle` which need a secure context. Browser tests use `ignoreHTTPSErrors: true`.
Key patterns for browser tests:
- Frontend hashes passwords with SHA-256 (`crypto.subtle`) before sending — API test registration must use pre-hashed passwords if UI login is needed.
- VueUse `useStorage('key', '')` with string default uses **raw string** serialization — set localStorage with raw value, not `JSON.stringify()`.
- WebAuthn browser tests use CDP virtual authenticator (`WebAuthn.enable` + `WebAuthn.addVirtualAuthenticator`).
## Architecture
### Worker Auth Flow (`worker/src/worker.ts`)
Three auth layers applied via Hono middleware, each using different headers:
| Path prefix | Header | Purpose |
|-------------|--------|---------|
| `/api/*` | `Authorization: Bearer <jwt>` | Address (mailbox) credential |
| `/user_api/*` | `x-user-token` | User account JWT |
| `/admin/*` | `x-admin-auth` | Admin password |
| (any) | `x-user-access-token` | User role-based access token |
| (any) | `x-custom-auth` | Optional global access password |
| (any) | `x-lang` | Language preference (`en`/`zh`) |
Public endpoints (no auth): `/open_api/*`, `/user_api/login`, `/user_api/register`, `/user_api/passkey/authenticate_*`, `/user_api/oauth2/*`.
### Worker Email Flow (`worker/src/email/`)
Cloudflare Email Worker entry: `email()` in `worker/src/email/index.ts`. Processing pipeline:
1. Parse raw email → check junk → check address exists
2. Auto-reply if configured → forward if configured → webhook if enabled
3. Store in D1 database
### Frontend State (`frontend/src/store/index.js`, `frontend/src/api/index.js`)
Global state via VueUse `useStorage` for persistence. The `api` module wraps axios with auto-attached auth headers and fingerprinting. API base URL comes from `VITE_API_BASE` env var (empty = same origin).
## Coding Style
- `worker/` uses TypeScript + ESLint; `frontend/` uses Vue SFCs.
- Keep existing naming patterns: `*_api/` folders, `utils/`, `models/`.
- ESM imports only (`type: module`).
## Auth Headers
- Address JWT: `x-user-token`
- User JWT: `x-user-access-token`
- Admin: `x-admin-auth`
- Language: `x-lang`
## Commits & PRs
- Use Conventional Commits: `feat:`, `fix:`, `docs:`, `style:`, `refactor:`, `perf:`.
@@ -57,10 +97,6 @@ After completing any feature, bug fix, or improvement, **always check**:
- `api/` — API reference docs
3. **Both languages** — docs and changelogs exist in Chinese and English; always update both.
## Testing
No formal test runner. Validate with local dev servers and key flows (login, inbox, send/receive).
## Config
- Worker settings in `worker/wrangler.toml` (see `wrangler.toml.template` for bindings).

View File

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

View File

@@ -73,7 +73,7 @@ services:
dockerfile: e2e/Dockerfile.e2e
environment:
WORKER_URL: http://worker:8787
FRONTEND_URL: http://frontend:5173
FRONTEND_URL: https://frontend:5173
MAILPIT_API: http://mailpit:8025/api
SMTP_PROXY_HOST: smtp-proxy
SMTP_PROXY_SMTP_PORT: "8025"

View File

@@ -16,6 +16,7 @@ ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """

View File

@@ -29,6 +29,8 @@ export default defineConfig({
use: {
baseURL: FRONTEND_BASE,
...devices['Desktop Chrome'],
// Accept self-signed cert from Docker frontend (HTTPS for WebAuthn)
ignoreHTTPSErrors: true,
},
},
],

View File

@@ -16,7 +16,7 @@ done
echo "==> Waiting for frontend at $FRONTEND_URL ..."
for i in $(seq 1 60); do
if curl -sf "$FRONTEND_URL" > /dev/null 2>&1; then
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
echo " Frontend ready after ${i}s"
break
fi

View File

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

View File

@@ -0,0 +1,132 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
import * as crypto from 'crypto';
/**
* SHA-256 hash matching frontend hashPassword utility.
*/
function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex');
}
test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled)', () => {
test('settings returns enableGlobalTurnstileCheck as false', async ({ request }) => {
const res = await request.get(`${WORKER_URL}/open_api/settings`);
expect(res.ok()).toBe(true);
const settings = await res.json();
expect(settings.enableGlobalTurnstileCheck).toBe(false);
});
test.describe('/open_api/site_login', () => {
test('returns 401 when no PASSWORDS configured', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/site_login`, {
data: {
password: hashPassword('any-pass'),
cf_token: ''
}
});
expect(res.status()).toBe(401);
});
});
test.describe('/open_api/admin_login', () => {
test('correct hashed password succeeds', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
data: {
password: hashPassword('e2e-admin-pass'),
cf_token: ''
}
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
});
test('wrong password returns 401', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
data: {
password: hashPassword('wrong-admin'),
cf_token: ''
}
});
expect(res.status()).toBe(401);
});
test('empty password returns 401', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
data: {
password: '',
cf_token: ''
}
});
expect(res.status()).toBe(401);
});
});
test.describe('/open_api/credential_login', () => {
test('valid JWT credential succeeds', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'cred-login');
try {
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: {
credential: jwt,
cf_token: ''
}
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
} finally {
await deleteAddress(request, jwt);
}
});
test('invalid JWT returns 401', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: {
credential: 'invalid.jwt.token',
cf_token: ''
}
});
expect(res.status()).toBe(401);
});
test('empty credential returns 401', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: {
credential: '',
cf_token: ''
}
});
expect(res.status()).toBe(401);
});
});
test.describe('/api/address_login with cf_token', () => {
test('address login with empty cf_token works when turnstile disabled', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'addr-cf');
try {
// Set a password
await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: 'addr-pass-123' },
});
// Login with cf_token field present but empty
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: {
email: address,
password: 'addr-pass-123',
cf_token: ''
},
});
expect(loginRes.ok()).toBe(true);
const body = await loginRes.json();
expect(body.jwt).toBeTruthy();
} finally {
await deleteAddress(request, jwt);
}
});
});
});

View File

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

View File

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

View File

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

485
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -93,6 +93,7 @@ const getOpenSettings = async (message, notification) => {
isS3Enabled: res["isS3Enabled"] || false,
enableAddressPassword: res["enableAddressPassword"] || false,
statusUrl: res["statusUrl"] || "",
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
});
if (openSettings.value.needAuth) {
showAuth.value = true;

View File

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

View File

@@ -117,6 +117,55 @@ const presets: WebhookPreset[] = [
}, null, 2),
},
},
{
name: 'Telegram Bot',
doc: 'https://core.telegram.org/bots/api#sendmessage',
settings: {
enabled: true,
url: 'https://api.telegram.org/botYOUR_BOT_TOKEN/sendMessage',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"chat_id": "YOUR_CHAT_ID",
"text": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}, null, 2),
},
},
{
name: 'WeChat Work',
doc: 'https://developer.work.weixin.qq.com/document/path/91770',
settings: {
enabled: true,
url: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"msgtype": "text",
"text": {
"content": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}
}, null, 2),
},
},
{
name: 'Discord',
doc: 'https://discord.com/developers/docs/resources/webhook',
settings: {
enabled: true,
url: 'https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"content": "**New Email**\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}, null, 2),
},
},
]
const presetDropdownOptions: DropdownOption[] = presets.map((preset, index) => ({

View File

@@ -39,6 +39,7 @@ export const useGlobalState = createGlobalState(
disableAdminPasswordCheck: false,
enableAddressPassword: false,
statusUrl: '',
enableGlobalTurnstileCheck: false,
})
const settings = ref({
fetched: false,
@@ -111,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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,8 @@ res = requests.get(
)
```
**Note**: `/api/mails` returns raw RFC822 data by design (for example `source`/`raw`), and it does not guarantee parsed fields such as `subject`, `text`, or `html`. Parse the raw source on the client side (for example with `mail-parser-wasm` or `postal-mime`) if you need readable message content.
## Admin Mail API
Supports `address` filter
@@ -43,6 +45,8 @@ response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**Note**: `/admin/mails` follows the same design as `/api/mails`: it returns stored raw MIME data. If you need readable subject/body, parse the raw content on the client side.
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.
## Admin Delete Mail API
@@ -151,4 +155,6 @@ response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**Note**: `/user_api/mails` also returns raw RFC822 content from storage; parse it in your client to extract `subject`, `text`, and `html`.
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.

View File

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

View File

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

View File

@@ -33,7 +33,7 @@
| `DEFAULT_DOMAINS` | JSON | Default domains available to users (not logged in or users without assigned roles) | `["awsl.uk", "dreamhunter2333.xyz"]` |
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | Text/JSON | Whether to prioritize default domain when creating new addresses, if set to true, will use the first domain when no domain is specified, mainly for telegram bot scenarios | `false` |
| `DOMAIN_LABELS` | JSON | For Chinese domains, you can use DOMAIN_LABELS to display Chinese names | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies | `true` |
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance, will be 0 if not set | `1` |
| `ENABLE_ADDRESS_PASSWORD` | Text/JSON | Enable address password feature, when enabled, passwords will be auto-generated for new addresses, supports password login and modification | `true` |
@@ -101,8 +101,9 @@
| `ADMIN_CONTACT` | Text | Admin contact information, can be any string, hidden if not configured | `xxx@gmail.com` |
| `DISABLE_SHOW_GITHUB` | Text/JSON | Whether to show GitHub link | `true` |
| `STATUS_URL` | Text | Status monitoring page URL, shows Status menu button when configured | `https://status.example.com` |
| `CF_TURNSTILE_SITE_KEY` | Text/Secret | Turnstile CAPTCHA configuration | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | Text/Secret | Turnstile CAPTCHA configuration | `xxx` |
| `CF_TURNSTILE_SITE_KEY` | Text/Secret | Turnstile CAPTCHA configuration (for new address creation, registration code, etc.) | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | Text/Secret | Turnstile CAPTCHA configuration (for new address creation, registration code, etc.) | `xxx` |
| `ENABLE_GLOBAL_TURNSTILE_CHECK` | Text/JSON | Enable global Turnstile CAPTCHA for all login forms (admin login, user login, address password login), requires Turnstile keys above | `true` |
## Telegram Bot Related Variables
@@ -111,6 +112,7 @@
| `TG_MAX_ADDRESS` | Number | Maximum number of mailboxes that can be bound to telegram bot | `5` |
| `TG_BOT_INFO` | Text | Optional, telegram BOT_INFO, predefined BOT_INFO can reduce webhook latency | `{}` |
| `TG_ALLOW_USER_LANG` | Text/JSON | Allow users to switch language via `/lang` command, default `false` | `true` |
| `ENABLE_TG_PUSH_ATTACHMENT` | Boolean | Enable sending email attachments via Telegram push, default `false`, 50MB per file limit | `true` |
> [!NOTE]
> Telegram functionality requires email parsing, free tier CPU is limited, may cause large email parsing timeout

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@
| `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` |
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | 文本/JSON | 创建新地址时是否优先使用默认域名,如果设置为 true当未指定域名时将使用第一个域名, 主要用于 telegram bot 场景 | `false` |
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件 | `true` |
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/` | `true` |
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额,如果不设置,将为 0 | `1` |
| `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
@@ -101,8 +101,9 @@
| `ADMIN_CONTACT` | 文本 | admin 联系方式,可配置任意字符串, 不配置则不显示 | `xxx@gmail.com` |
| `DISABLE_SHOW_GITHUB` | 文本/JSON | 是否显示 GitHub 链接 | `true` |
| `STATUS_URL` | 文本 | 状态监控页面 URL配置后显示 Status 菜单按钮 | `https://status.example.com` |
| `CF_TURNSTILE_SITE_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
| `CF_TURNSTILE_SITE_KEY` | 文本/Secret | Turnstile 人机验证配置(用于新建邮箱、注册验证码等) | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | 文本/Secret | Turnstile 人机验证配置(用于新建邮箱、注册验证码等) | `xxx` |
| `ENABLE_GLOBAL_TURNSTILE_CHECK` | 文本/JSON | 启用全局登录表单的 Turnstile 人机验证(管理员登录、用户登录、邮箱密码登录),需同时配置上述 Turnstile 密钥 | `true` |
## Telegram Bot 相关变量
@@ -111,6 +112,7 @@
| `TG_MAX_ADDRESS` | 数字 | telegram bot 最多绑定邮箱数量 | `5` |
| `TG_BOT_INFO` | 文本 | 可不配置telegram BOT_INFO预定义的 BOT_INFO 可以降低 webhook 的延迟 | `{}` |
| `TG_ALLOW_USER_LANG`| 文本/JSON | 是否允许用户通过 `/lang` 命令切换语言,默认 `false` | `true`|
| `ENABLE_TG_PUSH_ATTACHMENT`| 布尔值 | 是否启用 Telegram 推送邮件附件,默认 `false`,单文件限制 50MB | `true`|
> [!NOTE]
> Telegram 功能需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时

View File

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

View File

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

View File

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

520
worker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -44,7 +44,8 @@ api.get('/open_api/settings', async (c) => {
"showGithub": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
"enableAddressPassword": utils.getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD),
"statusUrl": utils.getStringValue(c.env.STATUS_URL)
"statusUrl": utils.getStringValue(c.env.STATUS_URL),
"enableGlobalTurnstileCheck": utils.isGlobalTurnstileEnabled(c)
});
})

View File

@@ -456,7 +456,8 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
subject: string,
text: string,
html: string,
headers?: Record<string, string>[]
headers?: Record<string, string>[],
attachments?: ParsedEmailAttachment[],
} | undefined> => {
// check parsed email context is valid
if (!parsedEmailContext || !parsedEmailContext.rawEmail) {
@@ -467,7 +468,7 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
return parsedEmailContext.parsedEmail;
}
const raw_mail = parsedEmailContext.rawEmail;
// TODO: WASM parse email
// NOTE: WASM parse email
// try {
// const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
@@ -480,6 +481,12 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
// (header) => ({ key: header.key, value: header.value })
// ) || [],
// html: parsedEmail.body_html || "",
// attachments: (parsedEmail.attachments || []).map(att => ({
// filename: att.filename || "attachment",
// mimeType: att.content_type || "application/octet-stream",
// content: att.content,
// disposition: "attachment",
// })),
// };
// return parsedEmailContext.parsedEmail;
// } catch (e) {
@@ -494,6 +501,12 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
text: parsedEmail.text || "",
html: parsedEmail.html || "",
headers: parsedEmail.headers || [],
attachments: (parsedEmail.attachments || []).map(att => ({
filename: att.filename || "attachment",
mimeType: att.mimeType || "application/octet-stream",
content: new Uint8Array(att.content),
disposition: att.disposition || "attachment",
})),
};
return parsedEmailContext.parsedEmail;
}
@@ -605,7 +618,7 @@ export async function triggerWebhook(
subject: parsedEmail?.subject || "",
raw: parsedEmailContext.rawEmail || "",
parsedText: parsedEmail?.text || "",
parsedHtml: parsedEmail?.html || ""
parsedHtml: parsedEmail?.html || "",
}
for (const settings of webhookList) {
const res = await sendWebhook(settings, webhookMail);

View File

@@ -1,5 +1,5 @@
export const CONSTANTS = {
VERSION: 'v' + '1.4.0',
VERSION: 'v' + '1.5.0',
// DB Version
DB_VERSION_KEY: 'db_version',

View File

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

View File

@@ -1,6 +1,6 @@
import { Context } from 'hono';
import i18n from '../i18n';
import { getBooleanValue, hashPassword } from '../utils';
import utils, { getBooleanValue, hashPassword, checkCfTurnstile } from '../utils';
import { Jwt } from 'hono/utils/jwt';
export default {
@@ -49,6 +49,15 @@ export default {
return c.text(msgs.EmailPasswordRequiredMsg, 400);
}
// check cf turnstile if global turnstile is enabled
if (utils.isGlobalTurnstileEnabled(c)) {
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
}
// 查找地址
const address = await c.env.DB.prepare(
`SELECT * FROM address WHERE name = ?`

View File

@@ -130,7 +130,7 @@ api.post('/api/new_address', async (c) => {
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 500)
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
// Check if custom email names are disabled from environment variable
const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);

View File

@@ -8,7 +8,7 @@ import i18n from '../i18n';
import { CONSTANTS } from '../constants'
import { getJsonSetting, getDomains, getIntValue, getBooleanValue, getStringValue, getJsonObjectValue, getSplitStringListValue } from '../utils';
import { GeoData } from '../models'
import { handleListQuery } from '../common'
import { handleListQuery, updateAddressUpdatedAt } from '../common'
export const api = new Hono<HonoCustomType>()
@@ -222,6 +222,8 @@ export const sendMail = async (
console.warn(`Failed to update balance for ${address}`);
}
}
// update address updated_at
updateAddressUpdatedAt(c, address);
// save to sendbox
try {
const reqIp = c.req.raw.headers.get("cf-connecting-ip")

View File

@@ -2,7 +2,7 @@ import type {
AuthenticatorTransportFuture,
CredentialDeviceType,
Base64URLString,
} from '@simplewebauthn/types';
} from '@simplewebauthn/server';
export type Passkey = {
id: Base64URLString;

View File

@@ -0,0 +1,69 @@
import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt'
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword } from '../utils';
import i18n from '../i18n';
const api = new Hono<HonoCustomType>()
api.post('/open_api/site_login', async (c) => {
const { password, cf_token } = await c.req.json();
const msgs = i18n.getMessagesbyContext(c);
if (utils.isGlobalTurnstileEnabled(c)) {
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
}
const passwords = getPasswords(c);
const hashedPasswords = await Promise.all(passwords.map(p => hashPassword(p)));
if (!hashedPasswords.length || !password || !hashedPasswords.includes(password)) {
return c.text(msgs.CustomAuthPasswordMsg, 401)
}
return c.json({ success: true })
})
api.post('/open_api/admin_login', async (c) => {
const { password, cf_token } = await c.req.json();
const msgs = i18n.getMessagesbyContext(c);
if (utils.isGlobalTurnstileEnabled(c)) {
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
}
const adminPasswords = getAdminPasswords(c);
const hashedPasswords = await Promise.all(adminPasswords.map(p => hashPassword(p)));
if (!hashedPasswords.length || !password || !hashedPasswords.includes(password)) {
return c.text(msgs.NeedAdminPasswordMsg, 401)
}
return c.json({ success: true })
})
api.post('/open_api/credential_login', async (c) => {
const { credential, cf_token } = await c.req.json();
const msgs = i18n.getMessagesbyContext(c);
if (utils.isGlobalTurnstileEnabled(c)) {
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
}
if (!credential) {
return c.text(msgs.InvalidAddressCredentialMsg, 401)
}
try {
const payload = await Jwt.verify(credential, c.env.JWT_SECRET, "HS256");
if (!payload.address) {
return c.text(msgs.InvalidAddressCredentialMsg, 401)
}
} catch (error) {
return c.text(msgs.InvalidAddressCredentialMsg, 401)
}
return c.json({ success: true })
})
export { api }

View File

@@ -89,7 +89,7 @@ async function newTelegramAddress(c: Context<HonoCustomType>): Promise<Response>
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 500)
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
try {
const userId = await checkTelegramAuth(c, initData);

View File

@@ -6,12 +6,14 @@ import { callbackQuery } from "telegraf/filters";
import { CONSTANTS } from "../constants";
import { getBooleanValue, getDomains, getJsonObjectValue, getStringValue } from '../utils';
import { TelegramSettings } from "./settings";
import { sendTelegramAttachments } from "./tg_file_upload";
import { bindTelegramAddress, deleteTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, unbindTelegramByAddress } from "./common";
import { commonParseMail } from "../common";
import { UserFromGetMe } from "telegraf/types";
import i18n from "../i18n";
import { LocaleMessages } from "../i18n/type";
// Helper to get messages by userId
const getTgMessages = async (
c: Context<HonoCustomType>,
@@ -424,6 +426,7 @@ export async function sendMailToTelegram(
const buildAndSend = async (targetUserId: string, msgs: LocaleMessages) => {
const { mail } = await parseMail(msgs, parsedEmailContext, address, new Date().toUTCString());
if (!mail) return;
const attachments = parsedEmailContext.parsedEmail?.attachments || [];
const buttons = [];
if (settings?.miniAppUrl && mailId) {
const url = new URL(settings.miniAppUrl);
@@ -434,6 +437,11 @@ export async function sendMailToTelegram(
await bot.telegram.sendMessage(targetUserId, mail, {
...Markup.inlineKeyboard([...buttons])
});
// send attachments via native fetch (telegraf multipart upload is incompatible with CF Workers)
if (getBooleanValue(c.env.ENABLE_TG_PUSH_ATTACHMENT) && attachments.length > 0) {
const caption = `From: ${parsedEmailContext.parsedEmail?.sender || ""}\nSubject: ${parsedEmailContext.parsedEmail?.subject || ""}`;
await sendTelegramAttachments(c.env.TELEGRAM_BOT_TOKEN, targetUserId, attachments, caption);
}
};
if (globalPush) {

View File

@@ -0,0 +1,49 @@
const TG_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB Telegram Bot API limit
export async function sendTelegramAttachments(
botToken: string,
chatId: string,
attachments: ParsedEmailAttachment[],
caption: string
) {
try {
const validAttachments = attachments.filter(att => {
if (att.content.byteLength > TG_MAX_FILE_SIZE) {
console.log(`Skipping attachment ${att.filename}: ${(att.content.byteLength / 1024 / 1024).toFixed(1)}MB exceeds 50MB limit`);
return false;
}
return true;
});
if (validAttachments.length === 0) return;
const batchSize = 6;
for (let i = 0; i < validAttachments.length; i += batchSize) {
const batch = validAttachments.slice(i, i + batchSize);
const formData = new FormData();
const media: { type: string; media: string; caption?: string }[] = [];
for (let j = 0; j < batch.length; j++) {
const att = batch[j];
const attachKey = `file${j}`;
media.push({
type: 'document',
media: `attach://${attachKey}`,
...(i === 0 && j === 0 ? { caption } : {}),
});
const blob = new Blob([att.content], { type: 'application/octet-stream' });
formData.append(attachKey, blob, att.filename || `attachment_${j}`);
}
formData.append('chat_id', chatId);
formData.append('media', JSON.stringify(media));
const res = await fetch(
`https://api.telegram.org/bot${botToken}/sendMediaGroup`,
{ method: 'POST', body: formData }
);
if (!res.ok) {
const text = await res.text();
console.error(`Failed to send attachment batch ${i / batchSize + 1}: ${res.status} ${text.substring(0, 200)}`);
}
}
} catch (e) {
console.error("Failed to send telegram attachments:", e);
}
}

11
worker/src/types.d.ts vendored
View File

@@ -86,6 +86,7 @@ type Bindings = {
TG_MAX_ADDRESS: number | undefined
TG_BOT_INFO: string | object | undefined
TG_ALLOW_USER_LANG: string | boolean | undefined
ENABLE_TG_PUSH_ATTACHMENT: string | boolean | undefined
// webhook config
FRONTEND_URL: string | undefined
@@ -135,6 +136,13 @@ type RPCEmailMessage = {
headers: object | undefined | null,
}
type ParsedEmailAttachment = {
filename: string,
mimeType: string,
content: Uint8Array,
disposition: string,
}
type ParsedEmailContext = {
rawEmail: string,
parsedEmail?: {
@@ -142,7 +150,8 @@ type ParsedEmailContext = {
subject: string,
text: string,
html: string,
headers?: Record<string, string>[]
headers?: Record<string, string>[],
attachments?: ParsedEmailAttachment[],
} | undefined
}

View File

@@ -8,7 +8,7 @@ import {
} from '@simplewebauthn/server';
import { Passkey } from '../models';
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import i18n from '../i18n';
@@ -88,7 +88,7 @@ export default {
return true;
},
expectedOrigin: origin,
requireUserVerification: true,
requireUserVerification: false,
});
const { verified, registrationInfo } = verification;
@@ -97,20 +97,21 @@ export default {
}
const {
credentialID, credentialPublicKey,
counter, credentialDeviceType, credentialBackedUp,
} = registrationInfo;
id: credentialID, publicKey,
counter, deviceType, backedUp,
transports,
} = registrationInfo.credential;
// Base64URL encode ArrayBuffers.
const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);
const base64PublicKey = isoBase64URL.fromBuffer(publicKey);
const newPasskey: Passkey = {
id: credentialID,
publicKey: base64PublicKey,
counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: credential?.response?.transports,
deviceType,
backedUp,
transports,
};
// Store the credential ID in the database
@@ -161,9 +162,10 @@ export default {
},
expectedOrigin: origin,
expectedRPID: domain,
authenticator: {
credentialID: passkeyData.id,
credentialPublicKey: isoBase64URL.toBuffer(passkeyData.publicKey),
requireUserVerification: false,
credential: {
id: passkeyData.id,
publicKey: isoBase64URL.toBuffer(passkeyData.publicKey),
counter: counter || passkeyData.counter,
transports: passkeyData.transports,
},

View File

@@ -2,7 +2,7 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n';
import { checkCfTurnstile, getJsonSetting, checkUserPassword, getUserRoles, getStringValue } from "../utils"
import utils, { checkCfTurnstile, getJsonSetting, checkUserPassword, getUserRoles, getStringValue } from "../utils"
import { CONSTANTS } from "../constants";
import { GeoData, UserInfo, UserSettings } from "../models";
import { sendMail } from "../mails_api/send_mail_api";
@@ -15,7 +15,7 @@ export default {
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 500)
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value)
@@ -77,11 +77,20 @@ export default {
return c.text(msgs.UserRegistrationDisabledMsg, 403);
}
// check request
const { email, password, code } = await c.req.json();
const { email, password, code, cf_token } = await c.req.json();
if (!email || !password) {
return c.text(msgs.InvalidEmailOrPasswordMsg, 400)
}
checkUserPassword(password);
// check cf turnstile only when mail verify is disabled
// (when enabled, verify_code endpoint already checks turnstile)
if (!settings.enableMailVerify) {
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
}
if (settings.enableMailVerify && !code) {
return c.text(msgs.InvalidVerifyCodeMsg, 400)
}
@@ -173,9 +182,17 @@ export default {
return c.json({ success: true })
},
login: async (c: Context<HonoCustomType>) => {
const { email, password } = await c.req.json();
const { email, password, cf_token } = await c.req.json();
const msgs = i18n.getMessagesbyContext(c);
if (!email || !password) return c.text(msgs.InvalidEmailOrPasswordMsg, 400);
// check cf turnstile if global turnstile is enabled
if (utils.isGlobalTurnstileEnabled(c)) {
try {
await checkCfTurnstile(c, cf_token);
} catch (error) {
return c.text(msgs.TurnstileCheckFailedMsg, 400)
}
}
const { id: user_id, password: dbPassword } = await c.env.DB.prepare(
`SELECT id, password FROM users where user_email = ?`
).bind(email).first() || {};

View File

@@ -272,6 +272,12 @@ export const sendAdminInternalMail = async (
}
};
export const isGlobalTurnstileEnabled = (c: Context<HonoCustomType>): boolean => {
return getBooleanValue(c.env.ENABLE_GLOBAL_TURNSTILE_CHECK)
&& !!c.env.CF_TURNSTILE_SITE_KEY
&& !!c.env.CF_TURNSTILE_SECRET_KEY;
}
export const checkCfTurnstile = async (
c: Context<HonoCustomType>, token: string | undefined | null
): Promise<void> => {
@@ -369,6 +375,7 @@ export default {
checkIsAdmin,
getEnvStringList,
sendAdminInternalMail,
isGlobalTurnstileEnabled,
checkCfTurnstile,
checkUserPassword,
getJsonSetting,

View File

@@ -4,6 +4,7 @@ import { jwt } from 'hono/jwt'
import { Jwt } from 'hono/utils/jwt'
import { api as commonApi } from './commom_api';
import { api as openAuthApi } from './open_api/auth';
import { api as mailsApi } from './mails_api'
import { api as userApi } from './user_api';
import { api as adminApi } from './admin_api';
@@ -253,6 +254,7 @@ app.use('/admin/*', async (c, next) => {
app.route('/', commonApi)
app.route('/', openAuthApi)
app.route('/', mailsApi)
app.route('/', userApi)
app.route('/', adminApi)

View File

@@ -85,12 +85,16 @@ ENABLE_AUTO_REPLY = false
# Turnstile verification
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
# Enable global Turnstile check for all login forms (requires Turnstile keys above)
# ENABLE_GLOBAL_TURNSTILE_CHECK = true
# telegram bot
# TG_MAX_ADDRESS = 5
# telegram bot info, predefined bot info can reduce latency of the webhook
# TG_BOT_INFO = "{}"
# allow user to switch language via /lang command
# TG_ALLOW_USER_LANG = true
# enable sending email attachments via Telegram push (50MB per file limit)
# ENABLE_TG_PUSH_ATTACHMENT = true
# global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
# subdomain forward address list, if set, subdomain emails will be forwarded to these addresses