Compare commits

..

25 Commits

Author SHA1 Message Date
Dream Hunter
e4c96c9868 style: use softer blue for AI extraction info in dark mode (#817)
- Use Gmail's #A8C7FA color for AI extraction alert and tag in dark mode
- Update CHANGELOG.md and CHANGELOG_EN.md for v1.2.1

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

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

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

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

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

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

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

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

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

* docs: update changelog for address UI

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

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

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

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

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

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

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

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

* fix: improve Telegram bot language preference feature

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:18:20 +08:00
74 changed files with 5528 additions and 3395 deletions

View File

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

View File

@@ -36,6 +36,9 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- 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
with:
@@ -44,5 +47,5 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.REPO_LOWER }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.REGISTRY }}/${{ env.REPO_LOWER }}/${{ env.IMAGE_NAME }}:latest

35
AGENTS.md Normal file
View File

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

View File

@@ -1,7 +1,41 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
<!-- markdownlint-disable-file MD004 MD024 MD033 MD034 MD036 -->
# CHANGE LOG
## v1.1.0(main)
<p align="center">
<a href="CHANGELOG.md">🇨🇳 中文</a> |
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
</p>
## v1.2.1(main)
### Bug Fixes
- fix: |定时任务| 修复定时任务清理报错 `e.get is not a function`,使用可选链安全访问 Context 方法
### Improvements
- style: |AI 提取| 暗色模式下 AI 提取信息使用更柔和的蓝色 (#A8C7FA),减少视觉疲劳
## v1.2.0
### Breaking Changes
- |数据库| 新增 `source_meta` 字段,需执行 `db/2025-12-27-source-meta.sql` 更新数据库或到 admin 维护页面点击数据库更新按钮
### Features
- feat: |Admin| 新增管理员账号页面,显示当前登录方式并支持退出登录(仅限密码登录方式)
- fix: |GitHub Actions| 修复容器镜像名需要全部小写的问题
- feat: |邮件转发| 新增来源地址正则转发功能,支持按发件人地址过滤转发,完全向后兼容
- feat: |地址来源| 新增地址来源追踪功能记录地址创建来源Web 记录 IPTelegram 记录用户 IDAdmin 后台标记)
- feat: |邮件过滤| 移除后端 keyword 参数,改为前端过滤当前页邮件,优化查询性能
- feat: |前端| 地址切换统一为下拉组件,极简模式支持切换,主页提供地址管理入口
- feat: |数据库| 为 `message_id` 字段添加索引,优化邮件更新操作性能,需执行 `db/2025-12-15-message-id-index.sql` 更新数据库
- feat: |Admin| 维护页面增加自定义 SQL 清理功能,支持定时任务执行自定义清理语句
- feat: |国际化| 后端 API 错误消息全面支持中英文国际化
- feat: |Telegram| 机器人支持中英文切换,新增 `/lang` 命令设置语言偏好
## v1.1.0
- feat: |AI 提取| 增加 AI 邮件识别功能,使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
- 支持优先级提取:验证码 > 认证链接 > 服务链接 > 订阅链接 > 其他链接

588
CHANGELOG_EN.md Normal file
View File

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

View File

@@ -110,7 +110,7 @@
- [x] 使用 `rust wasm` 解析邮件解析速度快几乎所有邮件都能解析node 的解析模块解析邮件失败的邮件rust wasm 也能解析成功
- [x] **AI 邮件识别** - 使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
- [x] 支持发送邮件,支持 `DKIM` 验证
- [x] 支持 `SMTP``Resend` 等多种发送方式
- [x] 支持 `SMTP``Resend` 等多种发送方式
- [x] 增加查看 `附件` 功能,支持附件图片显示
- [x] 支持 S3 附件存储和删除功能
- [x] 垃圾邮件检测和黑白名单配置
@@ -184,6 +184,14 @@
</details>
### 提醒
- 在Resend添加域名记录时如果您域名解析服务商正在托管您的3级域名a.b.com请删除Resend生成的默认name中二级域名前缀b否则将会添加a.b.b.com导致验证失败。添加记录后可通过
```bash
nslookup -qt="mx" a.b.com 1.1.1.1
```
进行验证。
## 🌟 加入社区
- [Telegram](https://t.me/cloudflare_temp_email)

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,13 @@ CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
password TEXT,
source_meta TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -26,6 +29,8 @@ CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);
CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY,
source_prefix TEXT,

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.1.0",
"version": "1.2.1",
"private": true,
"type": "module",
"scripts": {
@@ -22,34 +22,34 @@
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@simplewebauthn/browser": "10.0.0",
"@unhead/vue": "^1.11.20",
"@vueuse/core": "^12.8.2",
"@unhead/vue": "^2.1.2",
"@vueuse/core": "^14.1.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.13.2",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.43.2",
"postal-mime": "^2.6.1",
"postal-mime": "^2.7.3",
"vooks": "^0.2.12",
"vue": "^3.5.25",
"vue": "^3.5.27",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.2.2",
"vue-router": "^4.6.3"
"vue-i18n": "^11.2.8",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0",
"@vitejs/plugin-vue": "^5.2.4",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.8.0",
"vite": "^6.4.1",
"@vitejs/plugin-vue": "^6.0.3",
"unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0",
"wrangler": "^4.53.0"
"wrangler": "^4.59.2"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

2691
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,15 @@ const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
const showAd = computed(() => !isMobile.value && adClient && adSlot);
const gridMaxCols = computed(() => showAd.value ? 8 : 12);
// Load Google Ad script at top level (not inside onMounted)
if (showAd.value) {
useScript({
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: "anonymous",
})
}
onMounted(async () => {
try {
await api.getUserSettings();
@@ -42,11 +51,6 @@ onMounted(async () => {
// check if google ad is enabled
if (showAd.value) {
useScript({
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: "anonymous",
});
(window.adsbygoogle = window.adsbygoogle || []).push({});
(window.adsbygoogle = window.adsbygoogle || []).push({});
}

View File

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

View File

@@ -3,8 +3,34 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ContentCopyOutlined, LinkRound, CodeRound } from '@vicons/material';
import { useMessage } from 'naive-ui';
import { useGlobalState } from '../store';
const message = useMessage();
const { isDark } = useGlobalState();
// Dark mode: use Gmail's softer blue (#A8C7FA) for better readability
const alertThemeOverrides = computed(() => {
if (isDark.value) {
return {
colorSuccess: 'rgba(168, 199, 250, 0.15)',
borderSuccess: '1px solid rgba(168, 199, 250, 0.3)',
iconColorSuccess: '#A8C7FA',
titleTextColorSuccess: '#A8C7FA',
}
}
return {}
});
const tagThemeOverrides = computed(() => {
if (isDark.value) {
return {
colorSuccess: 'rgba(168, 199, 250, 0.15)',
borderSuccess: '1px solid rgba(168, 199, 250, 0.3)',
textColorSuccess: '#A8C7FA',
}
}
return {}
});
const { t } = useI18n({
messages: {
@@ -108,7 +134,7 @@ const openLink = () => {
<template>
<div v-if="aiExtract && aiExtract.result" class="ai-extract-info">
<n-alert v-if="!compact" type="success" closable>
<n-alert v-if="!compact" type="success" closable :theme-overrides="alertThemeOverrides">
<template #icon>
<n-icon :component="typeIcon" />
</template>
@@ -132,7 +158,7 @@ const openLink = () => {
</n-button>
</n-space>
</n-alert>
<n-tag v-else type="success" @click="copyToClipboard" style="cursor: pointer;" size="small">
<n-tag v-else type="success" @click="copyToClipboard" style="cursor: pointer;" size="small" :theme-overrides="tagThemeOverrides">
<template #icon>
<n-icon :component="typeIcon" />
</template>

View File

@@ -49,20 +49,44 @@ const props = defineProps({
default: (mail_id, filename, blob) => { },
required: false
},
showFilterInput: {
type: Boolean,
default: false,
required: false
},
})
const localFilterKeyword = ref('')
const {
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
autoRefresh, configAutoRefreshInterval, sendMailModel
} = useGlobalState()
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
const data = ref([])
const rawData = ref([])
const timer = ref(null)
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
// Computed property for filtered data (only filter current page)
const data = computed(() => {
if (!localFilterKeyword.value || localFilterKeyword.value.trim() === '') {
return rawData.value;
}
const keyword = localFilterKeyword.value.toLowerCase();
return rawData.value.filter(mail => {
// Search in subject, text, message fields
const searchFields = [
mail.subject || '',
mail.text || '',
mail.message || ''
].map(field => field.toLowerCase());
return searchFields.some(field => field.includes(keyword));
});
})
const canGoPrevMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
@@ -136,6 +160,8 @@ const { t } = useI18n({
unselectAll: 'Unselect All',
prevMail: 'Previous',
nextMail: 'Next',
keywordQueryTip: 'Filter current page',
query: 'Query',
},
zh: {
success: '成功',
@@ -158,6 +184,8 @@ const { t } = useI18n({
unselectAll: '取消全选',
prevMail: '上一封',
nextMail: '下一封',
keywordQueryTip: '过滤当前页',
query: '查询',
}
}
});
@@ -197,7 +225,7 @@ const refresh = async () => {
pageSize.value, (page.value - 1) * pageSize.value
);
loading.value = true;
data.value = await Promise.all(results.map(async (item) => {
rawData.value = await Promise.all(results.map(async (item) => {
item.checked = false;
return await processItem(item);
}));
@@ -370,7 +398,7 @@ onBeforeUnmount(() => {
<div>
<div v-if="!isMobile" class="left">
<div style="margin-bottom: 10px;">
<n-space v-if="multiActionMode">
<n-space v-if="multiActionMode" align="center">
<n-button @click="multiActionModeClick(false)" tertiary>
{{ t('cancelMultiAction') }}
</n-button>
@@ -393,7 +421,7 @@ onBeforeUnmount(() => {
{{ t('downloadMail') }}
</n-button>
</n-space>
<n-space v-else>
<n-space v-else align="center">
<n-button @click="multiActionModeClick(true)" type="primary" tertiary>
{{ t('multiAction') }}
</n-button>
@@ -410,6 +438,9 @@ onBeforeUnmount(() => {
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
<n-input v-if="showFilterInput" v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
clearable />
</n-space>
</div>
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
@@ -482,10 +513,8 @@ onBeforeUnmount(() => {
</n-split>
</div>
<div class="left" v-else>
<n-space justify="center">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-space justify="space-around" align="center" :wrap="false" style="display: flex; align-items: center;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
<n-switch v-model:value="autoRefresh" size="small" :round="false">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
@@ -498,6 +527,10 @@ onBeforeUnmount(() => {
{{ t('refresh') }}
</n-button>
</n-space>
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
<n-input v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" size="small" clearable />
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
@@ -510,10 +543,14 @@ onBeforeUnmount(() => {
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
{{ showEMailTo ? "FROM: " + row.source : row.source }}
<n-ellipsis style="max-width: 240px;">
{{ showEMailTo ? "FROM: " + row.source : row.source }}
</n-ellipsis>
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
<n-ellipsis style="max-width: 240px;">
TO: {{ row.address }}
</n-ellipsis>
</n-tag>
<AiExtractInfo :metadata="row.metadata" compact />
</template>

View File

@@ -1,5 +1,5 @@
import { createApp } from 'vue'
import { createHead } from '@unhead/vue'
import { createHead } from '@unhead/vue/client'
import App from './App.vue'
import router from './router'

View File

@@ -1,9 +1,11 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getRouterPathWithLang } from '../utils'
import SenderAccess from './admin/SenderAccess.vue'
import Statistics from "./admin/Statistics.vue"
@@ -30,9 +32,11 @@ import AiExtractSettings from './admin/AiExtractSettings.vue';
const {
adminAuth, showAdminAuth, adminTab, loading,
globalTabplacement, showAdminPage, userSettings
globalTabplacement, showAdminPage, userSettings,
openSettings
} = useGlobalState()
const message = useMessage()
const router = useRouter()
const SendMail = defineAsyncComponent(() => {
loading.value = true;
@@ -49,7 +53,20 @@ const authFunc = async () => {
}
}
const { t } = useI18n({
const showLogoutModal = ref(false)
const handleLogout = async () => {
// 清空管理员认证
adminAuth.value = '';
// 重置管理员相关状态
showAdminAuth.value = false;
adminTab.value = 'account';
// 显示成功提示并跳转
message.success(t('logoutSuccess'));
await router.push(getRouterPathWithLang('/', locale.value));
}
const { t, locale } = useI18n({
messages: {
en: {
accessHeader: 'Admin Password',
@@ -80,6 +97,16 @@ const { t } = useI18n({
about: 'About',
ok: 'OK',
mailWebhook: 'Mail Webhook',
adminAccount: 'Admin',
loginMethod: 'Login Method',
loginViaPassword: 'Admin Password Login',
loginViaUserAdmin: 'User Admin Permission',
loginViaDisabledCheck: 'Disabled Password Check',
logout: 'Logout',
logoutConfirmTitle: 'Confirm Logout',
logoutConfirmContent: 'Are you sure you want to logout from admin panel?',
confirm: 'Confirm',
logoutSuccess: 'Logout successful',
},
zh: {
accessHeader: 'Admin 密码',
@@ -110,12 +137,36 @@ const { t } = useI18n({
about: '关于',
ok: '确定',
mailWebhook: '邮件 Webhook',
adminAccount: '管理员',
loginMethod: '登录方式',
loginViaPassword: 'Admin 密码登录',
loginViaUserAdmin: '用户管理员权限',
loginViaDisabledCheck: '已禁用密码检查',
logout: '退出登录',
logoutConfirmTitle: '确认退出',
logoutConfirmContent: '确定要退出管理员面板吗?',
confirm: '确认',
logoutSuccess: '退出成功',
}
}
});
const showAdminPasswordModal = computed(() => !showAdminPage.value || showAdminAuth.value)
const tmpAdminAuth = ref('')
// 判断是否通过 admin password 登录(而非用户管理员权限)
const isAdminPasswordLogin = computed(() => !!adminAuth.value)
// 获取当前登录方式
const currentLoginMethod = computed(() => {
if (adminAuth.value) {
return t('loginViaPassword');
} else if (userSettings.value.is_admin) {
return t('loginViaUserAdmin');
} else if (openSettings.value.disableAdminPasswordCheck) {
return t('loginViaDisabledCheck');
}
return '';
})
onMounted(async () => {
// make sure user_id is fetched
@@ -234,10 +285,32 @@ onMounted(async () => {
<n-tab-pane name="appearance" :tab="t('appearance')">
<Appearance />
</n-tab-pane>
<n-tab-pane name="adminAccount" :tab="t('adminAccount')">
<div style="display: flex; justify-content: center; padding: 20px;">
<n-card style="width: 600px;">
<n-space vertical>
<n-text strong>{{ t('loginMethod') }}</n-text>
<n-text>{{ currentLoginMethod }}</n-text>
<n-divider v-if="isAdminPasswordLogin" />
<n-button v-if="isAdminPasswordLogin" type="warning" @click="showLogoutModal = true" block>
{{ t('logout') }}
</n-button>
</n-space>
</n-card>
</div>
</n-tab-pane>
<n-tab-pane name="about" :tab="t('about')">
<About />
</n-tab-pane>
</n-tabs>
<n-modal v-model:show="showLogoutModal" preset="dialog" :title="t('logoutConfirmTitle')">
<p>{{ t('logoutConfirmContent') }}</p>
<template #action>
<n-button :loading="loading" @click="handleLogout" size="small" tertiary type="warning">
{{ t('confirm') }}
</n-button>
</template>
</n-modal>
</div>
</template>

View File

@@ -158,7 +158,7 @@ onMounted(() => {
</div>
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled"
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
</n-tab-pane>
<n-tab-pane name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"

View File

@@ -22,6 +22,7 @@ const { t } = useI18n({
updated_at: 'Update At',
mail_count: 'Mail Count',
send_count: 'Send Count',
source_meta: 'Source',
showCredential: 'Show Mail Address Credential',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
@@ -59,6 +60,7 @@ const { t } = useI18n({
updated_at: '更新时间',
mail_count: '邮件数量',
send_count: '发送数量',
source_meta: '来源',
showCredential: '查看邮箱地址凭证',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
@@ -319,6 +321,10 @@ const columns = [
title: t('updated_at'),
key: "updated_at"
},
{
title: t('source_meta'),
key: "source_meta"
},
{
title: t('mail_count'),
key: "mail_count",

View File

@@ -1,7 +1,7 @@
<script setup>
import { onMounted, ref, h } from 'vue';
import { useI18n } from 'vue-i18n'
import { NButton, NPopconfirm, NInput, NSelect } from 'naive-ui'
import { NButton, NPopconfirm, NInput, NSelect, NRadioGroup, NRadio } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
@@ -24,7 +24,7 @@ const { t } = useI18n({
fromBlockList: 'Block Keywords for receive email',
block_receive_unknow_address_email: 'Block receive unknow address email',
email_forwarding_config: 'Email Forwarding Configuration',
domain_list: 'Domain List',
domain_list: 'Domain List (Optional)',
forward_address: 'Forward Address',
actions: 'Actions',
select_domain: 'Select Domain',
@@ -32,10 +32,20 @@ const { t } = useI18n({
delete_rule: 'Delete',
delete_rule_confirm: 'Are you sure you want to delete this rule?',
delete_success: 'Delete Success',
forwarding_rule_warning: 'Each rule will run, if domains is empty, all emails will be forwarded, forward address needs to be a verified address',
forwarding_rule_warning: 'Each rule will run independently. Forward address needs to be a verified address.',
add: 'Add',
cancel: 'Cancel',
config: 'Config',
source_patterns: 'Source Address Regex (Optional)',
source_patterns_placeholder: 'e.g. gmail.com',
source_match_mode: 'Match Mode',
match_any: 'Any',
match_all: 'All',
source_patterns_tip: 'Domain list filters by recipient address, source regex filters by sender address. Both conditions must match for forwarding (AND logic). Leave either empty to skip that filter.',
regex_too_long: 'Regex pattern too long (max 200 characters)',
regex_invalid: 'Invalid regex pattern',
forward_address_required: 'Forward address is required',
rule_index: 'Rule',
},
zh: {
tip: '您可以手动输入以下多选输入框, 回车增加',
@@ -50,7 +60,7 @@ const { t } = useI18n({
fromBlockList: '接收邮件地址屏蔽关键词',
block_receive_unknow_address_email: '禁止接收未知地址邮件',
email_forwarding_config: '邮件转发配置',
domain_list: '域名列表',
domain_list: '域名列表(可选)',
forward_address: '转发地址',
actions: '操作',
select_domain: '选择域名',
@@ -58,10 +68,20 @@ const { t } = useI18n({
delete_rule: '删除',
delete_rule_confirm: '确定要删除这条规则吗?',
delete_success: '删除成功',
forwarding_rule_warning: '每条规则都会运行,如果 domains 为空,则转发所有邮件,转发地址需要为已验证的地址',
forwarding_rule_warning: '每条规则独立运行,转发地址需要为已验证的地址',
add: '添加',
cancel: '取消',
config: '配置',
source_patterns: '来源地址正则(可选)',
source_patterns_placeholder: '例如: gmail.com',
source_match_mode: '匹配模式',
match_any: '任一',
match_all: '全部',
source_patterns_tip: '域名列表按收件地址过滤来源正则按发件地址过滤两者均为可选。同时配置时需同时满足AND 逻辑),留空则跳过该条件。',
regex_too_long: '正则表达式过长最大200字符',
regex_invalid: '无效的正则表达式',
forward_address_required: '转发地址不能为空',
rule_index: '规则',
}
}
});
@@ -98,6 +118,39 @@ const emailForwardingColumns = [
})
}
},
{
title: t('source_patterns'),
key: 'sourcePatterns',
render: (row, index) => {
return h('div', { style: 'display: flex; flex-direction: column; gap: 4px;' }, [
h(NSelect, {
value: Array.isArray(row.sourcePatterns) ? row.sourcePatterns : [],
onUpdateValue: (val) => {
emailForwardingList.value[index].sourcePatterns = val
},
multiple: true,
filterable: true,
tag: true,
placeholder: t('source_patterns_placeholder')
}, {
empty: () => h('span', { style: 'color: #999; font-size: 12px;' }, t('manualInputPrompt'))
}),
h(NRadioGroup, {
value: row.sourceMatchMode || 'any',
onUpdateValue: (val) => {
emailForwardingList.value[index].sourceMatchMode = val
},
size: 'small',
style: 'margin-top: 4px;'
}, {
default: () => [
h(NRadio, { value: 'any' }, { default: () => t('match_any') }),
h(NRadio, { value: 'all' }, { default: () => t('match_all') })
]
})
])
}
},
{
title: t('forward_address'),
key: 'forward',
@@ -145,12 +198,50 @@ const addNewEmailForwardingItem = () => {
...emailForwardingList.value,
{
domains: [],
forward: ''
forward: '',
sourcePatterns: [],
sourceMatchMode: 'any'
}
]
}
const MAX_REGEX_LENGTH = 200
const validateForwardingRules = () => {
for (let i = 0; i < emailForwardingList.value.length; i++) {
const rule = emailForwardingList.value[i]
// 验证转发地址
if (!rule.forward || rule.forward.trim() === '') {
message.error(`${t('forward_address_required')} (${t('rule_index')} ${i + 1})`)
return false
}
// 验证正则表达式
if (rule.sourcePatterns && rule.sourcePatterns.length > 0) {
for (const pattern of rule.sourcePatterns) {
// 检查长度
if (pattern.length > MAX_REGEX_LENGTH) {
message.error(`${t('regex_too_long')}: ${pattern.substring(0, 30)}...`)
return false
}
// 检查正则有效性
try {
new RegExp(pattern, 'i')
} catch (e) {
message.error(`${t('regex_invalid')}: ${pattern}`)
return false
}
}
}
}
return true
}
const saveEmailForwardingConfig = () => {
if (!validateForwardingRules()) {
return
}
emailRuleSettings.value.emailForwardingList = [...emailForwardingList.value]
showEmailForwardingModal.value = false
}
@@ -164,9 +255,9 @@ const fetchData = async () => {
verifiedAddressList.value = res.verifiedAddressList || []
fromBlockList.value = res.fromBlockList || []
noLimitSendAddressList.value = res.noLimitSendAddressList || []
emailRuleSettings.value = res.emailRuleSettings || {
blockReceiveUnknowAddressEmail: false,
emailForwardingList: []
emailRuleSettings.value = {
blockReceiveUnknowAddressEmail: res.emailRuleSettings?.blockReceiveUnknowAddressEmail || false,
emailForwardingList: res.emailRuleSettings?.emailForwardingList || []
}
} catch (error) {
message.error(error.message || "error");
@@ -269,10 +360,12 @@ onMounted(async () => {
<!-- 邮件转发配置弹窗 -->
<n-modal v-model:show="showEmailForwardingModal" preset="card" :title="t('email_forwarding_config')"
style="max-width: 800px;">
style="max-width: 1000px;">
<n-space vertical>
<n-alert :show-icon="false" :bordered="false" type="warning">
<span>{{ t('forwarding_rule_warning') }}</span>
<br />
<span>{{ t('source_patterns_tip') }}</span>
</n-alert>
<n-space justify="end">
<n-button @click="addNewEmailForwardingItem">{{ t('add') }}</n-button>

View File

@@ -12,23 +12,19 @@ const { t } = useI18n({
messages: {
en: {
addressQueryTip: 'Leave blank to query all addresses',
keywordQueryTip: 'Leave blank to not query by keyword',
query: 'Query',
},
zh: {
addressQueryTip: '留空查询所有地址',
keywordQueryTip: '留空不按关键字查询',
query: '查询',
}
}
});
const mailBoxKey = ref("")
const mailKeyword = ref("")
const queryMail = () => {
adminMailTabAddress.value = adminMailTabAddress.value.trim();
mailKeyword.value = mailKeyword.value.trim();
mailBoxKey.value = Date.now();
}
@@ -38,7 +34,6 @@ const fetchMailData = async (limit, offset) => {
+ `?limit=${limit}`
+ `&offset=${offset}`
+ (adminMailTabAddress.value ? `&address=${adminMailTabAddress.value}` : '')
+ (mailKeyword.value ? `&keyword=${mailKeyword.value}` : '')
);
}
@@ -51,14 +46,13 @@ const deleteMail = async (curMailId) => {
<div style="margin-top: 10px;">
<n-input-group>
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')"
@keydown.enter="queryMail" />
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" @keydown.enter="queryMail" />
@keydown.enter="queryMail" clearable />
<n-button @click="queryMail" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="margin-top: 10px;"></div>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="true" :fetchMailData="fetchMailData"
:deleteMail="deleteMail" />
:deleteMail="deleteMail" :showFilterInput="true" />
</div>
</template>

View File

@@ -1,10 +1,12 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { CleaningServicesFilled } from '@vicons/material'
import { CleaningServicesFilled, AddFilled, DeleteFilled } from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { loading } = useGlobalState()
const message = useMessage()
const cleanupModel = ref({
enableMailsAutoCleanup: false,
@@ -21,6 +23,7 @@ const cleanupModel = ref({
cleanUnboundAddressDays: 30,
enableEmptyAddressAutoCleanup: false,
cleanEmptyAddressDays: 30,
customSqlCleanupList: []
})
const { t } = useI18n({
@@ -37,8 +40,18 @@ const { t } = useI18n({
cleanupNow: "Cleanup now",
autoCleanup: "Auto cleanup",
cleanupSuccess: "Cleanup success",
saveSuccess: "Save success",
save: "Save",
cronTip: "Enable cron cleanup, need to configure [crons] in worker, please refer to the document, setting 0 days means clear all",
basicCleanup: "Basic Cleanup",
customSqlCleanup: "Custom SQL Cleanup",
customSqlTip: "Add custom DELETE SQL statements for scheduled cleanup. Only single DELETE statement is allowed per entry.",
addCustomSql: "Add Custom SQL",
sqlName: "Name",
sqlStatement: "SQL Statement (DELETE only)",
sqlNamePlaceholder: "e.g. Clean old logs",
sqlPlaceholder: "e.g. DELETE FROM raw_mails WHERE source GLOB '*{'@'}example.com' AND created_at < datetime('now', '-3 day')",
deleteCustomSql: "Delete",
},
zh: {
tip: '请输入天数',
@@ -51,9 +64,19 @@ const { t } = useI18n({
emptyAddressLabel: "清理 n 天前空邮件的邮箱地址",
autoCleanup: "自动清理",
cleanupSuccess: "清理成功",
saveSuccess: "保存成功",
cleanupNow: "立即清理",
save: "保存",
cronTip: "启用定时清理, 需在 worker 配置 [crons] 参数, 请参考文档, 配置为 0 天表示全部清空",
basicCleanup: "基础清理",
customSqlCleanup: "自定义 SQL 清理",
customSqlTip: "添加自定义 DELETE SQL 语句进行定时清理。每条记录仅允许单条 DELETE 语句。",
addCustomSql: "添加自定义 SQL",
sqlName: "名称",
sqlStatement: "SQL 语句 (仅限 DELETE)",
sqlNamePlaceholder: "例如: 清理旧日志",
sqlPlaceholder: "例如: DELETE FROM raw_mails WHERE source GLOB '*{'@'}example.com' AND created_at < datetime('now', '-3 day')",
deleteCustomSql: "删除",
}
}
});
@@ -70,10 +93,29 @@ const cleanup = async (cleanType, cleanDays) => {
}
}
const addCustomSql = () => {
if (!cleanupModel.value.customSqlCleanupList) {
cleanupModel.value.customSqlCleanupList = [];
}
cleanupModel.value.customSqlCleanupList.push({
id: Date.now().toString(),
name: '',
sql: '',
enabled: false
});
}
const removeCustomSql = (index) => {
cleanupModel.value.customSqlCleanupList.splice(index, 1);
}
const fetchData = async () => {
try {
const res = await api.fetch('/admin/auto_cleanup');
if (res) Object.assign(cleanupModel.value, res);
if (!cleanupModel.value.customSqlCleanupList) {
cleanupModel.value.customSqlCleanupList = [];
}
} catch (error) {
message.error(error.message || "error");
}
@@ -85,7 +127,7 @@ const save = async () => {
method: 'POST',
body: JSON.stringify(cleanupModel.value)
});
message.success(t('cleanupSuccess'));
message.success(t('saveSuccess'));
} catch (error) {
message.error(error.message || "error");
}
@@ -108,92 +150,132 @@ onMounted(async () => {
{{ t('save') }}
</n-button>
</n-flex>
<n-form :model="cleanupModel">
<n-form-item-row :label="t('mailBoxLabel')">
<n-checkbox v-model:checked="cleanupModel.enableMailsAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails', cleanupModel.cleanMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('mailUnknowLabel')">
<n-checkbox v-model:checked="cleanupModel.enableUnknowMailsAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanUnknowMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails_unknow', cleanupModel.cleanUnknowMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('sendBoxLabel')">
<n-checkbox v-model:checked="cleanupModel.enableSendBoxAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanSendBoxDays" :placeholder="t('tip')" />
<n-button @click="cleanup('sendbox', cleanupModel.cleanSendBoxDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('addressCreateLabel')">
<n-checkbox v-model:checked="cleanupModel.enableAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('addressCreated', cleanupModel.cleanAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('inactiveAddressLabel')">
<n-checkbox v-model:checked="cleanupModel.enableInactiveAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanInactiveAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('inactiveAddress', cleanupModel.cleanInactiveAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('unboundAddressLabel')">
<n-checkbox v-model:checked="cleanupModel.enableUnboundAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanUnboundAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('unboundAddress', cleanupModel.cleanUnboundAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('emptyAddressLabel')">
<n-checkbox v-model:checked="cleanupModel.enableEmptyAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanEmptyAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('emptyAddress', cleanupModel.cleanEmptyAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
</n-form>
<n-tabs type="segment" style="margin-top: 16px;">
<n-tab-pane name="basic" :tab="t('basicCleanup')">
<n-form :model="cleanupModel">
<n-form-item-row :label="t('mailBoxLabel')">
<n-checkbox v-model:checked="cleanupModel.enableMailsAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails', cleanupModel.cleanMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('mailUnknowLabel')">
<n-checkbox v-model:checked="cleanupModel.enableUnknowMailsAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanUnknowMailsDays" :placeholder="t('tip')" />
<n-button @click="cleanup('mails_unknow', cleanupModel.cleanUnknowMailsDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('sendBoxLabel')">
<n-checkbox v-model:checked="cleanupModel.enableSendBoxAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanSendBoxDays" :placeholder="t('tip')" />
<n-button @click="cleanup('sendbox', cleanupModel.cleanSendBoxDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('addressCreateLabel')">
<n-checkbox v-model:checked="cleanupModel.enableAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('addressCreated', cleanupModel.cleanAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('inactiveAddressLabel')">
<n-checkbox v-model:checked="cleanupModel.enableInactiveAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanInactiveAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('inactiveAddress', cleanupModel.cleanInactiveAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('unboundAddressLabel')">
<n-checkbox v-model:checked="cleanupModel.enableUnboundAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanUnboundAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('unboundAddress', cleanupModel.cleanUnboundAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('emptyAddressLabel')">
<n-checkbox v-model:checked="cleanupModel.enableEmptyAddressAutoCleanup">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input-number v-model:value="cleanupModel.cleanEmptyAddressDays" :placeholder="t('tip')" />
<n-button @click="cleanup('emptyAddress', cleanupModel.cleanEmptyAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
</n-form>
</n-tab-pane>
<n-tab-pane name="custom_sql" :tab="t('customSqlCleanup')">
<n-alert :show-icon="false" :bordered="false" type="info" style="margin-bottom: 16px;">
<span>{{ t('customSqlTip') }}</span>
</n-alert>
<n-space vertical>
<n-card v-for="(item, index) in cleanupModel.customSqlCleanupList" :key="item.id" size="small">
<n-space vertical>
<n-space align="center">
<n-checkbox v-model:checked="item.enabled">
{{ t('autoCleanup') }}
</n-checkbox>
<n-input v-model:value="item.name" :placeholder="t('sqlNamePlaceholder')" style="width: 200px;" />
<n-button @click="removeCustomSql(index)" type="error" quaternary>
<template #icon>
<n-icon :component="DeleteFilled" />
</template>
{{ t('deleteCustomSql') }}
</n-button>
</n-space>
<n-input
v-model:value="item.sql"
type="textarea"
:placeholder="t('sqlPlaceholder')"
:autosize="{ minRows: 2 }"
class="sql-input"
/>
</n-space>
</n-card>
<n-button @click="addCustomSql">
<template #icon>
<n-icon :component="AddFilled" />
</template>
{{ t('addCustomSql') }}
</n-button>
</n-space>
</n-tab-pane>
</n-tabs>
</n-card>
</div>
</template>
@@ -214,8 +296,7 @@ onMounted(async () => {
margin-bottom: 20px;
}
.item {
display: flex;
margin: 10px;
.sql-input {
text-align: left;
}
</style>

View File

@@ -1,81 +1,51 @@
<script setup>
import useClipboard from 'vue-clipboard3'
import { computed, onMounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Copy, User, ExchangeAlt } from '@vicons/fa'
import { User, ExchangeAlt } from '@vicons/fa'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import Login from '../common/Login.vue'
import AddressManagement from '../user/AddressManagement.vue'
import TelegramAddress from './TelegramAddress.vue'
import LocalAddress from './LocalAddress.vue'
import AddressManagement from '../user/AddressManagement.vue'
import { getRouterPathWithLang } from '../../utils'
import AddressSelect from '../../components/AddressSelect.vue'
const { toClipboard } = useClipboard()
const message = useMessage()
const router = useRouter()
const {
jwt, settings, showAddressCredential, userJwt,
isTelegram, openSettings, addressPassword
isTelegram, addressPassword
} = useGlobalState()
const { locale, t } = useI18n({
messages: {
en: {
addressManage: 'Address Manage',
changeAddress: 'Change Address',
ok: 'OK',
copy: 'Copy',
copied: 'Copied',
fetchAddressError: 'Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.',
addressCredential: 'Mail Address Credential',
linkWithAddressCredential: 'Open to auto login email link',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
addressPassword: 'Address Password',
userLogin: 'User Login',
addressManage: 'Manage',
},
zh: {
addressManage: '地址管理',
changeAddress: '更换地址',
ok: '确定',
copy: '复制',
copied: '已复制',
fetchAddressError: '邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。',
addressCredential: '邮箱地址凭证',
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
addressPassword: '地址密码',
userLogin: '用户登录',
addressManage: '地址管理',
}
}
});
const showChangeAddress = ref(false)
const showTelegramChangeAddress = ref(false)
const showLocalAddress = ref(false)
const addressLabel = computed(() => {
if (settings.value.address) {
const domain = settings.value.address.split('@')[1]
const domainLabel = openSettings.value.domains.find(
d => d.value === domain
)?.label;
if (!domainLabel) return settings.value.address;
return settings.value.address.replace('@' + domain, `@${domainLabel}`);
}
return settings.value.address;
})
const copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
}
}
const showAddressManage = ref(false)
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${jwt.value}`
@@ -97,29 +67,25 @@ onMounted(async () => {
</n-card>
<div v-else-if="settings.address">
<n-alert type="info" :show-icon="false" :bordered="false">
<span>
<b>{{ addressLabel }}</b>
<n-button v-if="isTelegram" style="margin-left: 10px" @click="showTelegramChangeAddress = true"
size="small" tertiary type="primary">
<n-icon :component="ExchangeAlt" /> {{ t('addressManage') }}
</n-button>
<n-button v-else-if="userJwt" style="margin-left: 10px" @click="showChangeAddress = true"
size="small" tertiary type="primary">
<n-icon :component="ExchangeAlt" /> {{ t('changeAddress') }}
</n-button>
<n-button v-else style="margin-left: 10px" @click="showLocalAddress = true" size="small" tertiary
type="primary">
<n-icon :component="ExchangeAlt" /> {{ t('addressManage') }}
</n-button>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</span>
<AddressSelect>
<template #actions>
<n-button class="address-manage" size="small" tertiary type="primary"
@click="showAddressManage = true">
<n-icon :component="ExchangeAlt" />
{{ t('addressManage') }}
</n-button>
</template>
</AddressSelect>
</n-alert>
</div>
<div v-else-if="isTelegram">
<TelegramAddress />
</div>
<div v-else-if="userJwt" class="center">
<n-card :bordered="false" embedded style="max-width: 900px; width: 100%;">
<AddressManagement />
</n-card>
</div>
<div v-else class="center">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert v-if="jwt" type="warning" :show-icon="false" :bordered="false" closable>
@@ -135,15 +101,6 @@ onMounted(async () => {
</n-button>
</n-card>
</div>
<n-modal v-model:show="showTelegramChangeAddress" preset="card" :title="t('changeAddress')">
<TelegramAddress />
</n-modal>
<n-modal v-model:show="showChangeAddress" preset="card" :title="t('changeAddress')">
<AddressManagement />
</n-modal>
<n-modal v-model:show="showLocalAddress" preset="card" :title="t('changeAddress')">
<LocalAddress />
</n-modal>
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
@@ -165,6 +122,11 @@ onMounted(async () => {
</n-collapse>
</n-card>
</n-modal>
<n-modal v-model:show="showAddressManage" preset="card" :title="t('addressManage')">
<TelegramAddress v-if="isTelegram" />
<AddressManagement v-else-if="userJwt" />
<LocalAddress v-else />
</n-modal>
</div>
</template>
@@ -186,4 +148,9 @@ onMounted(async () => {
justify-content: center;
margin: 20px;
}
.address-manage {
flex: 0 0 auto;
white-space: nowrap;
}
</style>

View File

@@ -48,7 +48,7 @@ const data = computed(() => {
}
return localAddressCache.value.map((curJwt: string) => {
try {
var payload = JSON.parse(
const payload = JSON.parse(
decodeURIComponent(
atob(curJwt.split(".")[1]
.replace(/-/g, "+").replace(/_/g, "/")

View File

@@ -17,6 +17,7 @@ import Login from '../common/Login.vue'
import AccountSettings from './AccountSettings.vue'
import { processItem } from '../../utils/email-parser'
import MailContentRenderer from '../../components/MailContentRenderer.vue'
import AddressSelect from '../../components/AddressSelect.vue'
const { jwt, settings, useSimpleIndex, showAddressCredential, openSettings, loading } = useGlobalState()
const message = useMessage()
@@ -171,7 +172,7 @@ onBeforeUnmount(() => {
<div v-else>
<n-card :bordered="false" embedded>
<div style="text-align: center; margin-bottom: 16px; font-size: 18px;">
<n-text strong size="large">{{ settings.address }}</n-text>
<AddressSelect :showCopy="false" size="small" />
</div>
<n-flex justify="center">
<n-button @click="refreshMails" :loading="loading" type="primary" tertiary size="small">

View File

@@ -5,17 +5,16 @@ import { useI18n } from 'vue-i18n'
import { api } from '../../api'
import MailBox from '../../components/MailBox.vue';
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
addressQueryTip: 'Leave blank to query all addresses',
keywordQueryTip: 'Leave blank to not query by keyword',
query: 'Query',
},
zh: {
addressQueryTip: '留空查询所有地址',
keywordQueryTip: '留空不按关键字查询',
query: '查询',
}
}
@@ -23,12 +22,10 @@ const { t } = useI18n({
const mailBoxKey = ref("")
const addressFilter = ref();
const mailKeyword = ref("")
const addressFilterOptions = ref([]);
const queryMail = () => {
addressFilter.value = addressFilter.value ? addressFilter.value.trim() : addressFilter.value;
mailKeyword.value = mailKeyword.value.trim();
mailBoxKey.value = Date.now();
}
@@ -38,7 +35,6 @@ const fetchMailData = async (limit, offset) => {
+ `?limit=${limit}`
+ `&offset=${offset}`
+ (addressFilter.value ? `&address=${addressFilter.value}` : '')
+ (mailKeyword.value ? `&keyword=${mailKeyword.value}` : '')
);
}
@@ -77,13 +73,12 @@ onMounted(() => {
<n-input-group>
<n-select v-model:value="addressFilter" :options="addressFilterOptions" clearable
:placeholder="t('addressQueryTip')" />
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" @keydown.enter="queryMail" />
<n-button @click="queryMail" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="margin-top: 10px;"></div>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="true" :fetchMailData="fetchMailData"
:deleteMail="deleteMail" />
:deleteMail="deleteMail" :showFilterInput="true" />
</div>
</template>

View File

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

View File

@@ -24,6 +24,10 @@ Extraction results are automatically saved to the `metadata` field in the databa
| `ENABLE_AI_EMAIL_EXTRACT` | Text/JSON | Whether to enable AI email recognition feature | `true` |
| `AI_EXTRACT_MODEL` | Text | AI model name, choose from [models supporting JSON mode](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models) | `@cf/meta/llama-3.1-8b-instruct` |
## Content Length Limit
To avoid AI model token limits, the maximum email content length for processing is **4000 characters**. Email content exceeding this limit will be truncated before AI analysis.
## Workers AI Binding
Configure Workers AI binding in `wrangler.toml`:

View File

@@ -19,7 +19,7 @@ res = requests.get(
## Admin Mail API
Supports `address` filter and `keyword` filter
Supports `address` filter
```python
import requests
@@ -29,9 +29,8 @@ url = "https://<your-worker-address>/admin/mails"
querystring = {
"limit":"20",
"offset":"0",
# address and keyword are optional parameters
"address":"xxxx@awsl.uk",
"keyword":"xxxx"
# address is optional parameter
"address":"xxxx@awsl.uk"
}
headers = {"x-admin-auth": "<your-Admin-password>"}
@@ -41,9 +40,11 @@ response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**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.
## User Mail API
Supports `address` filter and `keyword` filter
Supports `address` filter
```python
import requests
@@ -53,9 +54,8 @@ url = "https://<your-worker-address>/user_api/mails"
querystring = {
"limit":"20",
"offset":"0",
# address and keyword are optional parameters
"address":"xxxx@awsl.uk",
"keyword":"xxxx"
# address is optional parameter
"address":"xxxx@awsl.uk"
}
headers = {"x-admin-auth": "<your-Admin-password>"}
@@ -64,3 +64,5 @@ response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**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

@@ -36,6 +36,25 @@ pnpm wrangler secret put TELEGRAM_BOT_TOKEN
![telegram](/feature/telegram.png)
## Language Switching
> [!NOTE]
> This feature is available since v1.2.0
Telegram Bot supports Chinese and English switching. Users can set their language preference via the `/lang` command.
### Enable Language Switching
You need to configure `TG_ALLOW_USER_LANG = true` in worker variables to enable this feature.
### Usage
- `/lang zh` - Switch to Chinese
- `/lang en` - Switch to English
- `/lang` - View current language setting
Language preferences are saved to KV, and each user can set their preference independently.
## Mini App
Can be deployed via command line or UI interface

View File

@@ -105,10 +105,11 @@
## Telegram Bot Related Variables
| Variable Name | Type | Description | Example |
| ---------------- | ------ | --------------------------------------------------------------------------- | ------- |
| `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 | `{}` |
| Variable Name | Type | Description | Example |
| -------------------- | --------- | --------------------------------------------------------------------------- | ------- |
| `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` |
> [!NOTE]
> Telegram functionality requires email parsing, free tier CPU is limited, may cause large email parsing timeout
@@ -117,6 +118,33 @@
>
> Refer to [Configure worker to use wasm for email parsing](/en/guide/feature/mail_parser_wasm_worker)
## Email Forwarding Related Variables
| Variable Name | Type | Description | Example |
| --------------------------------- | ---- | ---------------------------------------------------------------------------------------- | --------- |
| `SUBDOMAIN_FORWARD_ADDRESS_LIST` | JSON | Subdomain/rule forwarding configuration, supports filtering by domain and source regex | See below |
> [!NOTE] SUBDOMAIN_FORWARD_ADDRESS_LIST Configuration
>
> v1.2.0 added `sourcePatterns` and `sourceMatchMode` fields for filtering by sender address regex:
>
> - `domains`: Target domain list, matches all domains if empty
> - `forward`: Forward destination address
> - `sourcePatterns`: Source address regex list (optional)
> - `sourceMatchMode`: Match mode, `any` (match any, default) or `all` (match all)
>
> Regex pattern max length is 200 characters to prevent ReDoS attacks
>
> ```toml
> SUBDOMAIN_FORWARD_ADDRESS_LIST = """
> [
> {"domains":[""],"forward":"xxx1@xxx.com"},
> {"domains":["subdomain-1.domain.com","subdomain-2.domain.com"],"forward":"xxx2@xxx.com"},
> {"domains":["example.com"],"forward":"admin@xxx.com","sourcePatterns":[".*@github.com",".*@gitlab.com"],"sourceMatchMode":"any"}
> ]
> """
> ```
## Other Variables
| Variable Name | Type | Description | Example |

View File

@@ -24,6 +24,10 @@ AI 邮件识别功能使用 Cloudflare Workers AI 自动分析收到的邮件内
| `ENABLE_AI_EMAIL_EXTRACT` | 文本/JSON | 是否启用 AI 邮件识别功能 | `true` |
| `AI_EXTRACT_MODEL` | 文本 | AI 模型名称,从[支持 JSON 模式的模型](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models)中选择 | `@cf/meta/llama-3.1-8b-instruct` |
## 内容长度限制
为避免 AI 模型 token 限制,邮件内容最大处理长度为 **4000 字符**。超过此长度的邮件内容将被截断后再进行 AI 分析。
## Workers AI 绑定
需要在 `wrangler.toml` 中配置 Workers AI 绑定:

View File

@@ -19,7 +19,7 @@ res = requests.get(
## admin 邮件 API
支持 `address` filter 和 `keyword` filter
支持 `address` 过滤
```python
import requests
@@ -29,9 +29,8 @@ url = "https://<你的worker地址>/admin/mails"
querystring = {
"limit":"20",
"offset":"0",
# adress 和 keyword 为可选参数
"address":"xxxx@awsl.uk",
"keyword":"xxxx"
# address 为可选参数
"address":"xxxx@awsl.uk"
}
headers = {"x-admin-auth": "<你的Admin密码>"}
@@ -41,9 +40,11 @@ response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。
## user 邮件 API
支持 `address` filter 和 `keyword` filter
支持 `address` 过滤
```python
import requests
@@ -53,9 +54,8 @@ url = "https://<你的worker地址>/user_api/mails"
querystring = {
"limit":"20",
"offset":"0",
# adress 和 keyword 为可选参数
"address":"xxxx@awsl.uk",
"keyword":"xxxx"
# address 为可选参数
"address":"xxxx@awsl.uk"
}
headers = {"x-admin-auth": "<你的Admin密码>"}
@@ -64,3 +64,5 @@ response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。

View File

@@ -36,6 +36,25 @@ pnpm wrangler secret put TELEGRAM_BOT_TOKEN
![telegram](/feature/telegram.png)
## 语言切换功能
> [!NOTE]
> 此功能从 v1.2.0 版本开始支持
Telegram Bot 支持中英文切换,用户可以通过 `/lang` 命令设置语言偏好。
### 启用语言切换
需要在 worker 变量中配置 `TG_ALLOW_USER_LANG = true` 才能启用此功能。
### 使用方法
- `/lang zh` - 切换为中文
- `/lang en` - 切换为英文
- `/lang` - 查看当前语言设置
语言偏好会保存到 KV 中,每个用户可以独立设置。
## Mini App
可以通过命令行部署,或者 UI 界面部署

View File

@@ -105,10 +105,11 @@
## Telegram Bot 相关变量
| 变量名 | 类型 | 说明 | 示例 |
| ---------------- | ---- | ---------------------------------------------------------------------- | ---- |
| `TG_MAX_ADDRESS` | 数字 | telegram bot 最多绑定邮箱数量 | `5` |
| `TG_BOT_INFO` | 文本 | 可不配置telegram BOT_INFO预定义的 BOT_INFO 可以降低 webhook 的延迟 | `{}` |
| 变量名 | 类型 | 说明 | 示例 |
| ------------------- | --------- | ---------------------------------------------------------------------- | ----- |
| `TG_MAX_ADDRESS` | 数字 | telegram bot 最多绑定邮箱数量 | `5` |
| `TG_BOT_INFO` | 文本 | 可不配置telegram BOT_INFO预定义的 BOT_INFO 可以降低 webhook 的延迟 | `{}` |
| `TG_ALLOW_USER_LANG`| 文本/JSON | 是否允许用户通过 `/lang` 命令切换语言,默认 `false` | `true`|
> [!NOTE]
> Telegram 功能需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时
@@ -117,6 +118,33 @@
>
> 参考 [配置 worker 使用 wasm 解析邮件](/zh/guide/feature/mail_parser_wasm_worker)
## 邮件转发相关变量
| 变量名 | 类型 | 说明 | 示例 |
| --------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------- | ------ |
| `SUBDOMAIN_FORWARD_ADDRESS_LIST` | JSON | 子域名/规则转发配置,支持按域名和来源地址正则过滤转发 | 见下方 |
> [!NOTE] SUBDOMAIN_FORWARD_ADDRESS_LIST 配置说明
>
> v1.2.0 新增 `sourcePatterns` 和 `sourceMatchMode` 字段,支持按发件人地址正则过滤转发:
>
> - `domains`: 目标域名列表,为空则匹配所有域名
> - `forward`: 转发目标地址
> - `sourcePatterns`: 来源地址正则表达式列表(可选)
> - `sourceMatchMode`: 匹配模式,`any`(任一匹配,默认) 或 `all`(全部匹配)
>
> 正则表达式最大长度 200 字符,防止 ReDoS 攻击
>
> ```toml
> SUBDOMAIN_FORWARD_ADDRESS_LIST = """
> [
> {"domains":[""],"forward":"xxx1@xxx.com"},
> {"domains":["subdomain-1.domain.com","subdomain-2.domain.com"],"forward":"xxx2@xxx.com"},
> {"domains":["example.com"],"forward":"admin@xxx.com","sourcePatterns":[".*@github.com",".*@gitlab.com"],"sourceMatchMode":"any"}
> ]
> """
> ```
## 其他变量
| 变量名 | 类型 | 说明 | 示例 |

View File

@@ -1,12 +1,12 @@
{
"name": "temp-mail-docs",
"private": true,
"version": "1.1.0",
"version": "1.2.1",
"type": "module",
"devDependencies": {
"@types/node": "^24.10.1",
"@types/node": "^25.0.9",
"vitepress": "^1.6.4",
"wrangler": "^4.53.0"
"wrangler": "^4.59.2"
},
"scripts": {
"dev": "vitepress dev docs",

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.1.0",
"version": "1.2.1",
"private": true,
"type": "module",
"scripts": {
@@ -11,24 +11,24 @@
"build": "wrangler deploy --dry-run --outdir dist --minify"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20251205.0",
"@eslint/js": "9.18.0",
"@cloudflare/workers-types": "^4.20260118.0",
"@eslint/js": "9.39.1",
"@simplewebauthn/types": "10.0.0",
"@types/node": "^22.19.1",
"eslint": "9.18.0",
"globals": "^15.15.0",
"typescript-eslint": "^8.48.1",
"wrangler": "^4.53.0"
"@types/node": "^25.0.9",
"eslint": "9.39.1",
"globals": "^16.5.0",
"typescript-eslint": "^8.53.0",
"wrangler": "^4.59.2"
},
"dependencies": {
"@aws-sdk/client-s3": "3.888.0",
"@aws-sdk/s3-request-presigner": "3.888.0",
"@simplewebauthn/server": "10.0.1",
"hono": "^4.10.7",
"hono": "^4.11.4",
"jsonpath-plus": "^10.3.0",
"mimetext": "^3.0.27",
"postal-mime": "^2.6.1",
"resend": "^4.8.0",
"postal-mime": "^2.7.3",
"resend": "^6.7.0",
"telegraf": "4.16.3",
"worker-mailer": "^1.2.1"
},

1615
worker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,14 +3,12 @@ import { handleListQuery } from "../common";
export default {
getMails: async (c: Context<HonoCustomType>) => {
const { address, limit, offset, keyword } = c.req.query();
const { address, limit, offset } = c.req.query();
const addressQuery = address ? `address = ?` : "";
const addressParams = address ? [address] : [];
const keywordQuery = keyword ? `raw like ?` : "";
const keywordParams = keyword ? [`%${keyword}%`] : [];
const filterQuerys = [addressQuery, keywordQuery].filter((item) => item).join(" and ");
const filterQuerys = [addressQuery].filter((item) => item).join(" and ");
const finalQuery = filterQuerys.length > 0 ? `where ${filterQuerys}` : "";
const filterParams = [...addressParams, ...keywordParams]
const filterParams = [...addressParams]
return await handleListQuery(c,
`SELECT * FROM raw_mails ${finalQuery}`,
`SELECT count(*) as count FROM raw_mails ${finalQuery}`,

View File

@@ -14,23 +14,24 @@ export default {
return c.json(settings)
},
saveSetting: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const value = await c.req.json();
const settings = new UserSettings(value);
if (settings.enableMailVerify && !c.env.KV) {
return c.text("Please enable KV first if you want to enable mail verify", 403)
return c.text(msgs.EnableKVForMailVerifyMsg, 403)
}
if (settings.enableMailVerify && !settings.verifyMailSender) {
return c.text("Please provide verifyMailSender", 400)
return c.text(msgs.VerifyMailSenderNotSetMsg, 400)
}
if (settings.enableMailVerify && settings.verifyMailSender) {
const mailDomain = settings.verifyMailSender.split("@")[1];
const domains = getDomains(c);
if (!domains.includes(mailDomain)) {
return c.text(`VerifyMailSender(${settings.verifyMailSender}) domain must in ${JSON.stringify(domains, null, 2)}`, 400)
return c.text(`${msgs.VerifyMailDomainInvalidMsg} ${JSON.stringify(domains, null, 2)}`, 400)
}
}
if (settings.maxAddressCount < 0) {
return c.text("Invalid maxAddressCount", 400)
return c.text(msgs.InvalidMaxAddressCountMsg, 400)
}
await saveSetting(c, CONSTANTS.USER_SETTINGS_KEY, JSON.stringify(settings));
return c.json({ success: true })
@@ -60,9 +61,10 @@ export default {
);
},
createUser: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { email, password } = await c.req.json();
if (!email || !password) {
return c.text("Invalid email or password", 400)
return c.text(msgs.InvalidEmailOrPasswordMsg, 400)
}
// geo data
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
@@ -77,14 +79,14 @@ export default {
email, password, JSON.stringify(userInfo)
).run();
if (!success) {
return c.text("Failed to register", 500)
return c.text(msgs.FailedToRegisterMsg, 500)
}
} catch (e) {
const errorMsg = (e as Error).message;
if (errorMsg && errorMsg.includes("UNIQUE")) {
return c.text("User already exists", 400)
return c.text(msgs.UserAlreadyExistsMsg, 400)
}
return c.text(`Failed to register: ${errorMsg}`, 500)
return c.text(`${msgs.FailedToRegisterMsg}: ${errorMsg}`, 500)
}
return c.json({ success: true })
},
@@ -99,7 +101,7 @@ export default {
`DELETE FROM users_address WHERE user_id = ?`
).bind(user_id).run();
if (!success || !addressSuccess) {
return c.text("Failed to delete user", 500)
return c.text(msgs.FailedDeleteUserMsg, 500)
}
return c.json({ success: true })
},
@@ -114,28 +116,29 @@ export default {
`UPDATE users SET password = ? WHERE id = ?`
).bind(password, user_id).run();
if (!success) {
return c.text("Failed to reset password", 500)
return c.text(msgs.FailedUpdatePasswordMsg, 500)
}
} catch (e) {
return c.text(`Failed to reset password: ${(e as Error).message}`, 500)
return c.text(`${msgs.FailedUpdatePasswordMsg}: ${(e as Error).message}`, 500)
}
return c.json({ success: true });
},
updateUserRoles: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { user_id, role_text } = await c.req.json();
if (!user_id) return c.text("Invalid user_id", 400);
if (!user_id) return c.text(msgs.InvalidUserIdMsg, 400);
if (!role_text) {
const { success } = await c.env.DB.prepare(
`DELETE FROM user_roles WHERE user_id = ?`
).bind(user_id).run();
if (!success) {
return c.text("Failed to update user roles", 500)
return c.text(msgs.FailedUpdateUserDefaultRoleMsg, 500)
}
return c.json({ success: true })
}
const user_roles = getUserRoles(c);
if (!user_roles.find((r) => r.role === role_text)) {
return c.text("Invalid role_text", 400)
return c.text(msgs.InvalidRoleTextMsg, 400)
}
const { success } = await c.env.DB.prepare(
`INSERT INTO user_roles (user_id, role_text)`
@@ -143,7 +146,7 @@ export default {
+ ` ON CONFLICT(user_id) DO UPDATE SET role_text = ?, updated_at = datetime('now')`
).bind(user_id, role_text, role_text).run();
if (!success) {
return c.text("Failed to update user roles", 500)
return c.text(msgs.FailedUpdateUserDefaultRoleMsg, 500)
}
return c.json({ success: true })
},

View File

@@ -3,16 +3,105 @@ import { Context } from 'hono';
import { cleanup } from '../common';
import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting } from '../utils';
import { CleanupSettings } from '../models';
import { CleanupSettings, CustomSqlCleanup } from '../models';
import i18n from '../i18n';
import { LocaleMessages } from '../i18n/type';
// SQL validation error types
type SqlValidationError = 'empty' | 'too_long' | 'not_delete' | 'multiple_statements' | 'has_comments';
// Normalize SQL: trim and remove trailing semicolon
const normalizeSql = (sql: string): string => {
let normalized = sql.trim();
if (normalized.endsWith(';')) {
normalized = normalized.slice(0, -1).trim();
}
return normalized;
};
// Get error message from error type
const getValidationErrorMsg = (errorType: SqlValidationError, msgs: LocaleMessages): string => {
switch (errorType) {
case 'empty': return msgs.SqlEmptyMsg;
case 'too_long': return msgs.SqlTooLongMsg;
case 'not_delete': return msgs.SqlOnlyDeleteMsg;
case 'multiple_statements': return msgs.SqlSingleStatementMsg;
case 'has_comments': return msgs.SqlNoCommentsMsg;
}
};
// Validate custom SQL cleanup statement
export const validateCustomSql = (sql: string): { valid: boolean; errorType?: SqlValidationError } => {
if (!sql || !sql.trim()) {
return { valid: false, errorType: 'empty' };
}
const trimmedSql = normalizeSql(sql);
// Check SQL length (max 1000 characters)
if (trimmedSql.length > 1000) {
return { valid: false, errorType: 'too_long' };
}
const sqlUpper = trimmedSql.toUpperCase();
// Only allow DELETE statements
if (!sqlUpper.startsWith('DELETE ')) {
return { valid: false, errorType: 'not_delete' };
}
// Only allow single statement (no semicolons after trimming)
if (trimmedSql.includes(';')) {
return { valid: false, errorType: 'multiple_statements' };
}
// Forbid SQL comments
if (/--/.test(trimmedSql) || /\/\*/.test(trimmedSql)) {
return { valid: false, errorType: 'has_comments' };
}
return { valid: true };
};
// Execute custom SQL cleanup
export const executeCustomSqlCleanup = async (
c: Context<HonoCustomType>,
customSql: CustomSqlCleanup
): Promise<{ success: boolean; rowsAffected?: number; error?: string }> => {
const msgs = i18n.getMessagesbyContext(c);
if (!customSql || !customSql.sql) {
return { success: false, error: msgs.InvalidCleanupConfigMsg };
}
const validation = validateCustomSql(customSql.sql);
if (!validation.valid) {
return { success: false, error: getValidationErrorMsg(validation.errorType!, msgs) };
}
const sql = normalizeSql(customSql.sql);
try {
console.log(`Executing custom SQL cleanup [${customSql.name}]: ${sql}`);
const result = await c.env.DB.prepare(sql).run();
const rowsAffected = result.meta?.changes ?? 0;
console.log(`Custom SQL cleanup [${customSql.name}] completed, rows affected: ${rowsAffected}`);
return { success: true, rowsAffected };
} catch (error) {
const errorMessage = (error as Error).message || "Unknown error";
console.error(`Custom SQL cleanup [${customSql.name}] failed:`, errorMessage);
return { success: false, error: errorMessage };
}
};
export default {
cleanup: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { cleanType, cleanDays } = await c.req.json();
try {
await cleanup(c, cleanType, cleanDays);
} catch (error) {
console.error(error);
return c.text(`Failed to cleanup ${(error as Error).message}`, 500)
return c.text(`${msgs.OperationFailedMsg}: ${(error as Error).message}`, 500)
}
return c.json({ success: true })
},
@@ -21,7 +110,22 @@ export default {
return c.json(cleanupSetting)
},
saveCleanup: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const cleanupSetting = await c.req.json<CleanupSettings>();
// Validate custom SQL cleanup list
if (cleanupSetting.customSqlCleanupList && cleanupSetting.customSqlCleanupList.length > 0) {
for (const customSql of cleanupSetting.customSqlCleanupList) {
if (customSql.sql) {
const validation = validateCustomSql(customSql.sql);
if (!validation.valid) {
const errorMsg = getValidationErrorMsg(validation.errorType!, msgs);
return c.text(`[${customSql.name || 'unnamed'}]: ${errorMsg}`, 400);
}
}
}
}
await saveSetting(c, CONSTANTS.AUTO_CLEANUP_KEY, JSON.stringify(cleanupSetting));
return c.json({ success: true })
}

View File

@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
password TEXT,
source_meta TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -31,6 +32,8 @@ CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);
CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY,
source_prefix TEXT,
@@ -138,14 +141,42 @@ export default {
},
migrate: async (c: Context<HonoCustomType>) => {
const version = await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY);
if (version == "v0.0.2") {
// example migration from v0.0.2 to v0.0.3
const query = `ALTER TABLE address ADD password TEXT;`
await c.env.DB.exec(query);
if (version && version <= "v0.0.2") {
// migration to v0.0.3: add password column
const tableInfo = await c.env.DB.prepare(
`PRAGMA table_info(address)`
).all();
const hasPassword = tableInfo.results?.some(
(col: any) => col.name === 'password'
);
if (!hasPassword) {
await c.env.DB.exec(`ALTER TABLE address ADD COLUMN password TEXT;`);
}
}
if (version == "v0.0.3") {
// migration from v0.0.3 to v0.0.4
await c.env.DB.exec(`ALTER TABLE raw_mails ADD COLUMN metadata TEXT;`);
if (version && version <= "v0.0.3") {
// migration to v0.0.4: add metadata column
const tableInfo = await c.env.DB.prepare(
`PRAGMA table_info(raw_mails)`
).all();
const hasMetadata = tableInfo.results?.some(
(col: any) => col.name === 'metadata'
);
if (!hasMetadata) {
await c.env.DB.exec(`ALTER TABLE raw_mails ADD COLUMN metadata TEXT;`);
}
}
if (version && version <= "v0.0.4") {
// migration to v0.0.5: add source_meta column
const tableInfo = await c.env.DB.prepare(
`PRAGMA table_info(address)`
).all();
const hasSourceMeta = tableInfo.results?.some(
(col: any) => col.name === 'source_meta'
);
if (!hasSourceMeta) {
await c.env.DB.exec(`ALTER TABLE address ADD COLUMN source_meta TEXT;`);
await c.env.DB.exec(`CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);`);
}
}
if (version != CONSTANTS.DB_VERSION) {
// remove all \r and \n characters from the query string

View File

@@ -45,10 +45,9 @@ api.get('/admin/address', async (c) => {
api.post('/admin/new_address', async (c) => {
const { name, domain, enablePrefix } = await c.req.json();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (!name) {
return c.text("Please provide a name", 400)
return c.text(msgs.RequiredFieldMsg, 400)
}
try {
const res = await newAddress(c, {
@@ -57,6 +56,7 @@ api.post('/admin/new_address', async (c) => {
addressPrefix: null,
checkAllowDomains: false,
enableCheckNameRegex: false,
sourceMeta: 'admin'
});
return c.json(res);
@@ -66,19 +66,20 @@ api.post('/admin/new_address', async (c) => {
})
api.delete('/admin/delete_address/:id', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE id = ? `
).bind(id).run();
if (!success) {
return c.text("Failed to delete address", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text("Failed to delete mails", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
const { success: sendAccess } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE address IN`
@@ -93,13 +94,14 @@ api.delete('/admin/delete_address/:id', async (c) => {
})
api.delete('/admin/clear_inbox/:id', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!mailSuccess) {
return c.text("Failed to clear inbox", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({
success: mailSuccess
@@ -107,13 +109,14 @@ api.delete('/admin/clear_inbox/:id', async (c) => {
})
api.delete('/admin/clear_sent_items/:id', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { success: sendboxSuccess } = await c.env.DB.prepare(
`DELETE FROM sendbox WHERE address IN`
+ ` (select name from address where id = ?) `
).bind(id).run();
if (!sendboxSuccess) {
return c.text("Failed to clear sent items", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({
success: sendboxSuccess
@@ -135,15 +138,16 @@ api.get('/admin/show_password/:id', async (c) => {
})
api.post('/admin/address/:id/reset_password', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { id } = c.req.param();
const { password } = await c.req.json();
// 检查功能是否启用
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {
return c.text("Password management is disabled", 403);
return c.text(msgs.PasswordChangeDisabledMsg, 403);
}
if (!password) {
return c.text("Password is required", 400);
return c.text(msgs.NewPasswordRequiredMsg, 400);
}
const hashedPassword = await hashPassword(password);
@@ -152,7 +156,7 @@ api.post('/admin/address/:id/reset_password', async (c) => {
).bind(hashedPassword, id).run();
if (!success) {
return c.text("Failed to reset password", 500);
return c.text(msgs.FailedUpdatePasswordMsg, 500);
}
return c.json({ success: true });
@@ -180,18 +184,19 @@ api.get('/admin/address_sender', async (c) => {
})
api.post('/admin/address_sender', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
/* eslint-disable prefer-const */
let { address, address_id, balance, enabled } = await c.req.json();
/* eslint-enable prefer-const */
if (!address_id) {
return c.text("Invalid address_id", 400)
return c.text(msgs.InvalidAddressIdMsg, 400)
}
enabled = enabled ? 1 : 0;
const { success } = await c.env.DB.prepare(
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
).bind(enabled, balance, address_id).run();
if (!success) {
return c.text("Failed to update address sender", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
await sendAdminInternalMail(
c, address, "Account Send Access Updated",
@@ -290,16 +295,17 @@ api.get('/admin/account_settings', async (c) => {
})
api.post('/admin/account_settings', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const {
blockList, sendBlockList, noLimitSendAddressList,
verifiedAddressList, fromBlockList, emailRuleSettings
} = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text("Invalid blockList or sendBlockList", 400)
return c.text(msgs.InvalidInputMsg, 400)
}
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
return c.text("Please enable SEND_MAIL to use verifiedAddressList", 400)
return c.text(msgs.EnableSendMailMsg, 400)
}
await saveSetting(
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
@@ -314,7 +320,7 @@ api.post('/admin/account_settings', async (c) => {
JSON.stringify(verifiedAddressList)
)
if (fromBlockList?.length > 0 && !c.env.KV) {
return c.text("Please enable KV to use fromBlockList", 400)
return c.text(msgs.EnableKVMsg, 400)
}
if (fromBlockList) {
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList || []))

View File

@@ -2,6 +2,7 @@ import { Context } from "hono";
import { CONSTANTS } from "../constants";
import { getJsonSetting, saveSetting } from "../utils";
import { IpBlacklistSettings } from "../ip_blacklist";
import i18n from "../i18n";
/**
* Get IP blacklist settings from database
@@ -26,55 +27,47 @@ async function getIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Respo
* Save IP blacklist settings to database
*/
async function saveIpBlacklistSettings(c: Context<HonoCustomType>): Promise<Response> {
const msgs = i18n.getMessagesbyContext(c);
const settings = await c.req.json<IpBlacklistSettings>();
// Validate settings
if (typeof settings.enabled !== 'boolean') {
return c.text("Invalid enabled value", 400);
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: enabled`, 400);
}
if (!Array.isArray(settings.blacklist)) {
return c.text("Invalid blacklist value", 400);
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: blacklist`, 400);
}
if (!Array.isArray(settings.asnBlacklist)) {
return c.text("Invalid asnBlacklist value", 400);
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: asnBlacklist`, 400);
}
if (!Array.isArray(settings.fingerprintBlacklist)) {
return c.text("Invalid fingerprintBlacklist value", 400);
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: fingerprintBlacklist`, 400);
}
if (typeof settings.enableDailyLimit !== 'boolean') {
return c.text("Invalid enableDailyLimit value", 400);
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: enableDailyLimit`, 400);
}
const limit = Number(settings.dailyRequestLimit);
if (isNaN(limit) || limit < 1 || limit > 1000000) {
return c.text("Invalid dailyRequestLimit value (must be between 1 and 1000000)", 400);
return c.text(`${msgs.InvalidIpBlacklistSettingMsg}: dailyRequestLimit (1-1000000)`, 400);
}
// Add size limit
const MAX_BLACKLIST_SIZE = 1000;
if (settings.blacklist.length > MAX_BLACKLIST_SIZE) {
return c.text(
`Blacklist exceeds maximum size (${MAX_BLACKLIST_SIZE} entries)`,
400
);
return c.text(`${msgs.BlacklistExceedsMaxSizeMsg}: blacklist (${MAX_BLACKLIST_SIZE})`, 400);
}
if (settings.asnBlacklist.length > MAX_BLACKLIST_SIZE) {
return c.text(
`ASN blacklist exceeds maximum size (${MAX_BLACKLIST_SIZE} entries)`,
400
);
return c.text(`${msgs.BlacklistExceedsMaxSizeMsg}: asnBlacklist (${MAX_BLACKLIST_SIZE})`, 400);
}
if (settings.fingerprintBlacklist.length > MAX_BLACKLIST_SIZE) {
return c.text(
`Fingerprint blacklist exceeds maximum size (${MAX_BLACKLIST_SIZE} entries)`,
400
);
return c.text(`${msgs.BlacklistExceedsMaxSizeMsg}: fingerprintBlacklist (${MAX_BLACKLIST_SIZE})`, 400);
}
// Sanitize patterns (trim and remove empty strings)

View File

@@ -5,6 +5,7 @@ import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles,
import { unbindTelegramByAddress } from './telegram_api/common';
import { CONSTANTS } from './constants';
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
import i18n from './i18n';
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
@@ -123,6 +124,7 @@ export const newAddress = async (
addressPrefix = null,
checkAllowDomains = true,
enableCheckNameRegex = true,
sourceMeta = null,
}: {
name: string, domain: string | undefined | null,
enablePrefix: boolean,
@@ -130,8 +132,10 @@ export const newAddress = async (
addressPrefix?: string | undefined | null,
checkAllowDomains?: boolean,
enableCheckNameRegex?: boolean,
sourceMeta?: string | undefined | null,
}
): Promise<{ address: string, jwt: string, password?: string | null }> => {
const msgs = i18n.getMessagesbyContext(c);
// trim whitespace and remove special characters
name = name.trim().replace(getNameRegex(c), '')
// check name
@@ -151,10 +155,10 @@ export const newAddress = async (
);
// check name length
if (name.length < minAddressLength) {
throw new Error(`Name too short (min ${minAddressLength})`);
throw new Error(`${msgs.NameTooShortMsg} (min ${minAddressLength})`);
}
if (name.length > maxAddressLength) {
throw new Error(`Name too long (max ${maxAddressLength})`);
throw new Error(`${msgs.NameTooLongMsg} (max ${maxAddressLength})`);
}
// create address with prefix
if (typeof addressPrefix === "string") {
@@ -175,24 +179,35 @@ export const newAddress = async (
}
// check domain is valid
if (!domain || !allowDomains.includes(domain)) {
throw new Error("Invalid domain")
throw new Error(msgs.InvalidDomainMsg)
}
// create address
name = name + "@" + domain;
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO address(name) VALUES(?)`
).bind(name).run();
if (!success) {
throw new Error("Failed to create address")
// Try insert with source_meta field first
const result = await c.env.DB.prepare(
`INSERT INTO address(name, source_meta) VALUES(?, ?)`
).bind(name, sourceMeta).run();
if (!result.success) {
throw new Error(msgs.FailedCreateAddressMsg)
}
await updateAddressUpdatedAt(c, name);
} catch (e) {
const message = (e as Error).message;
if (message && message.includes("UNIQUE")) {
throw new Error("Address already exists")
// Fallback: source_meta field may not exist, try without it
if (message && message.includes("source_meta")) {
const result = await c.env.DB.prepare(
`INSERT INTO address(name) VALUES(?)`
).bind(name).run();
if (!result.success) {
throw new Error(msgs.FailedCreateAddressMsg)
}
await updateAddressUpdatedAt(c, name);
} else if (message && message.includes("UNIQUE")) {
throw new Error(msgs.AddressAlreadyExistsMsg)
} else {
throw new Error(msgs.FailedCreateAddressMsg)
}
throw new Error("Failed to create address")
}
const address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
@@ -234,8 +249,9 @@ export const cleanup = async (
cleanType: string | undefined | null,
cleanDays: number | undefined | null
): Promise<boolean> => {
const msgs = i18n.getMessagesbyContext(c);
if (!cleanType || typeof cleanDays !== 'number' || cleanDays < 0 || cleanDays > 1000) {
throw new Error("Invalid cleanType or cleanDays")
throw new Error(msgs.InvalidCleanupConfigMsg)
}
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
switch (cleanType) {
@@ -281,7 +297,7 @@ export const cleanup = async (
)
break;
default:
throw new Error("Invalid cleanType")
throw new Error(msgs.InvalidCleanTypeMsg)
}
return true;
}
@@ -323,11 +339,12 @@ export const deleteAddressWithData = async (
address: string | undefined | null,
address_id: number | undefined | null
): Promise<boolean> => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
throw new Error("Delete email is disabled")
throw new Error(msgs.UserDeleteEmailDisabledMsg)
}
if (!address && !address_id) {
throw new Error("Address or address_id required")
throw new Error(msgs.RequiredFieldMsg)
}
// get address_id or address
if (!address_id) {
@@ -341,7 +358,7 @@ export const deleteAddressWithData = async (
}
// check address again
if (!address || !address_id) {
throw new Error("Can't find address");
throw new Error(msgs.AddressNotFoundMsg);
}
// unbind telegram
await unbindTelegramByAddress(c, address);
@@ -365,7 +382,7 @@ export const deleteAddressWithData = async (
`DELETE FROM address WHERE name = ? `
).bind(address).run();
if (!success || !mailSuccess || !sendboxSuccess || !addressSuccess || !sendAccess || !autoReplySuccess) {
throw new Error("Failed to delete address")
throw new Error(msgs.OperationFailedMsg)
}
return true;
}
@@ -376,6 +393,7 @@ export const handleListQuery = async (
limit: string | number | undefined | null,
offset: string | number | undefined | null
): Promise<Response> => {
const msgs = i18n.getMessagesbyContext(c);
if (typeof limit === "string") {
limit = parseInt(limit);
}
@@ -383,10 +401,10 @@ export const handleListQuery = async (
offset = parseInt(offset);
}
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
return c.text(msgs.InvalidLimitMsg, 400)
}
if (offset == null || offset == undefined || offset < 0) {
return c.text("Invalid offset", 400)
return c.text(msgs.InvalidOffsetMsg, 400)
}
const resultsQuery = `${query} order by id desc limit ? offset ?`;
const { results } = await c.env.DB.prepare(resultsQuery).bind(

View File

@@ -3,7 +3,7 @@ export const CONSTANTS = {
// DB Version
DB_VERSION_KEY: 'db_version',
DB_VERSION: "v0.0.4",
DB_VERSION: "v0.0.5",
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',

139
worker/src/email/forward.ts Normal file
View File

@@ -0,0 +1,139 @@
import { Context } from "hono";
import { getEnvStringList, getJsonObjectValue, getJsonSetting } from "../utils";
import { EmailRuleSettings } from "../models";
import { CONSTANTS } from "../constants";
// 正则表达式最大长度限制,防止 ReDoS 攻击
const MAX_REGEX_PATTERN_LENGTH = 200;
/**
* 安全地测试单个正则表达式
*/
function safeRegexTest(pattern: string, input: string): boolean {
try {
// 限制正则复杂度:最大长度限制
if (pattern.length > MAX_REGEX_PATTERN_LENGTH) {
console.warn("source pattern too long, skipped:", pattern.substring(0, 50) + "...");
return false;
}
const regex = new RegExp(pattern, 'i');
return regex.test(input);
} catch (regexError) {
console.error("regex test error for pattern:", pattern, regexError);
return false;
}
}
/**
* 检查来源地址是否匹配正则规则
*/
function matchSourcePatterns(
from: string,
sourcePatterns: string[] | undefined | null,
sourceMatchMode: 'any' | 'all' | undefined
): boolean {
if (!sourcePatterns || sourcePatterns.length === 0) {
// 未配置来源正则,默认匹配
return true;
}
const matchMode = sourceMatchMode || 'any';
if (matchMode === 'all') {
// 全部匹配模式:所有正则都必须匹配
return sourcePatterns.every(pattern => safeRegexTest(pattern, from));
} else {
// 任一匹配模式(默认):任一正则匹配即可
return sourcePatterns.some(pattern => safeRegexTest(pattern, from));
}
}
/**
* 全局转发:转发到 FORWARD_ADDRESS_LIST 中的所有地址
*/
async function forwardToGlobalAddresses(
message: ForwardableEmailMessage,
env: Bindings
): Promise<void> {
try {
const forwardAddressList = getEnvStringList(env.FORWARD_ADDRESS_LIST);
for (const forwardAddress of forwardAddressList) {
await message.forward(forwardAddress);
}
} catch (error) {
console.error("forward email error", error);
}
}
/**
* 规则转发:根据域名和来源地址正则规则转发
*/
async function forwardByRules(
message: ForwardableEmailMessage,
env: Bindings
): Promise<void> {
try {
// 获取环境变量配置
const subdomainForwardAddressList = getJsonObjectValue<SubdomainForwardAddressList[]>(
env.SUBDOMAIN_FORWARD_ADDRESS_LIST
) || [];
// 获取数据库配置
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(
{ env: env } as Context<HonoCustomType>,
CONSTANTS.EMAIL_RULE_SETTINGS_KEY
);
// 合并两个配置env 里的配置优先级更高
const allRules = [
...(subdomainForwardAddressList || []),
...(emailRuleSettings?.emailForwardingList || []),
];
for (const rule of allRules) {
// 检查来源地址是否匹配正则
if (!matchSourcePatterns(message.from, rule.sourcePatterns, rule.sourceMatchMode)) {
continue;
}
// 检查目标地址是否匹配域名,并转发
// 保持原始逻辑:每个匹配的 domain 都会触发一次转发
if (rule.domains && rule.domains.length > 0) {
for (const domain of rule.domains) {
if (message.to.endsWith(domain) && rule.forward) {
await message.forward(rule.forward);
}
}
} else {
// 域名为空,转发所有邮件
if (rule.forward) {
await message.forward(rule.forward);
}
}
}
} catch (error) {
console.error("forward by rules error", error);
}
}
/**
* 执行所有转发逻辑
*/
async function forwardEmail(
message: ForwardableEmailMessage,
env: Bindings
): Promise<void> {
// 全局转发
await forwardToGlobalAddresses(message, env);
// 规则转发
await forwardByRules(message, env);
}
export {
forwardEmail,
forwardToGlobalAddresses,
forwardByRules,
matchSourcePatterns,
};

View File

@@ -1,6 +1,6 @@
import { Context } from "hono";
import { getEnvStringList, getJsonObjectValue, getJsonSetting } from "../utils";
import { getJsonSetting } from "../utils";
import { sendMailToTelegram } from "../telegram_api";
import { auto_reply } from "./auto_reply";
import { isBlocked } from "./black_list";
@@ -8,6 +8,7 @@ import { triggerWebhook, triggerAnotherWorker, commonParseMail } from "../common
import { check_if_junk_mail } from "./check_junk";
import { remove_attachment_if_need } from "./check_attachment";
import { extractEmailInfo } from "./ai_extract";
import { forwardEmail } from "./forward";
import { EmailRuleSettings } from "../models";
import { CONSTANTS } from "../constants";
@@ -79,46 +80,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
}
// forward email
try {
const forwardAddressList = getEnvStringList(env.FORWARD_ADDRESS_LIST)
for (const forwardAddress of forwardAddressList) {
await message.forward(forwardAddress);
}
} catch (error) {
console.error("forward email error", error);
}
// forward subdomain email
try {
// 遍历 FORWARD_ADDRESS_LIST
const subdomainForwardAddressList = getJsonObjectValue<SubdomainForwardAddressList[]>(env.SUBDOMAIN_FORWARD_ADDRESS_LIST) || [];
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(
{ env: env } as Context<HonoCustomType>, CONSTANTS.EMAIL_RULE_SETTINGS_KEY
);
// 合并两个配置, env 里的配置优先级更高
const allSubdomainForwardAddressList = [
...(subdomainForwardAddressList || []),
...(emailRuleSettings?.emailForwardingList || []),
];
for (const subdomainForwardAddress of allSubdomainForwardAddressList) {
// 检查邮件是否匹配 domains
if (subdomainForwardAddress.domains && subdomainForwardAddress.domains.length > 0) {
for (const domain of subdomainForwardAddress.domains) {
if (message.to.endsWith(domain) && subdomainForwardAddress.forward) {
// 转发邮件
await message.forward(subdomainForwardAddress.forward);
// 支持多邮箱转发收件,不进行截止
// break;
}
}
} else {
// 如果 domains 为空,则转发所有邮件
await message.forward(subdomainForwardAddress.forward);
}
}
} catch (error) {
console.error("subdomain forward email error", error);
}
await forwardEmail(message, env);
// send email to telegram
try {

View File

@@ -46,6 +46,129 @@ const messages: LocaleMessages = {
PasswordLoginDisabledMsg: "Password login is disabled",
EmailPasswordRequiredMsg: "Email and password are required",
AddressNotFoundMsg: "Address not found",
// Common messages (merged similar ones)
OperationFailedMsg: "Operation failed",
RequiredFieldMsg: "Required field is missing",
InvalidInputMsg: "Invalid input",
// Address related
NameTooShortMsg: "Name is too short",
NameTooLongMsg: "Name is too long",
InvalidDomainMsg: "Invalid domain",
AddressAlreadyExistsMsg: "Address already exists",
MaxAddressCountReachedMsg: "Max address count reached",
AddressNotBindedMsg: "Address is not binded",
AddressAlreadyBindedMsg: "Address is already binded, please unbind first",
TargetUserNotFoundMsg: "Target user not found",
// Send mail related
NoBalanceMsg: "No balance",
AddressBlockedMsg: "Address is blocked",
SubjectEmptyMsg: "Subject is empty",
ContentEmptyMsg: "Content is empty",
AlreadyRequestedMsg: "Already requested",
EnableResendOrSmtpMsg: "Please enable resend or smtp for this domain",
EnableResendOrSmtpWithVerifiedMsg: "Please enable resend or smtp for this domain, or add recipient to verified address list",
InvalidToMailMsg: "Invalid recipient address",
// Admin related
InvalidAddressIdMsg: "Invalid address_id",
EnableKVMsg: "Please enable KV first",
EnableSendMailMsg: "Please enable SEND_MAIL first",
InvalidCleanupConfigMsg: "Invalid cleanType or cleanDays",
InvalidCleanTypeMsg: "Invalid cleanType",
EnableKVForMailVerifyMsg: "Please enable KV first if you want to enable mail verify",
VerifyMailDomainInvalidMsg: "VerifyMailSender domain must be in",
InvalidMaxAddressCountMsg: "Invalid maxAddressCount",
FailedDeleteUserMsg: "Failed to delete user",
InvalidUserIdMsg: "Invalid user_id",
InvalidRoleTextMsg: "Invalid role_text",
// SQL validation
SqlEmptyMsg: "SQL statement is empty",
SqlTooLongMsg: "SQL statement is too long (max 1000 characters)",
SqlOnlyDeleteMsg: "Only DELETE statements are allowed",
SqlSingleStatementMsg: "Only single SQL statement is allowed",
SqlNoCommentsMsg: "SQL comments are not allowed",
// Passkey related
InvalidPasskeyNameMsg: "Invalid passkey name",
PasskeyNotFoundMsg: "Passkey not found",
AuthenticationFailedMsg: "Authentication failed",
RegistrationFailedMsg: "Registration failed",
// Auto reply related
AutoReplyDisabledMsg: "Auto reply is disabled",
InvalidAutoReplyMsg: "Invalid subject or message",
SubjectOrMessageTooLongMsg: "Subject or message is too long",
// Bind address related
NoAddressOrUserTokenMsg: "No address or user token",
InvalidAddressOrUserTokenMsg: "Invalid address or user token",
// Pagination related
InvalidLimitMsg: "Invalid limit",
InvalidOffsetMsg: "Invalid offset",
// Clear inbox/sent items related
FailedClearInboxMsg: "Failed to clear inbox",
FailedClearSentItemsMsg: "Failed to clear sent items",
// Webhook related
WebhookNotAllowedForUserMsg: "Webhook settings is not allowed for this user",
// IP blacklist related
InvalidIpBlacklistSettingMsg: "Invalid IP blacklist setting",
BlacklistExceedsMaxSizeMsg: "Blacklist exceeds maximum size",
// Telegram bot messages
TgUnableGetUserInfoMsg: "Unable to get user info",
TgNoPermissionMsg: "You don't have permission to use this bot",
TgWelcomeMsg: "Welcome! You can open the mini app",
TgCurrentPrefixMsg: "Current prefix enabled:",
TgCurrentDomainsMsg: "Available domains:",
TgAvailableCommandsMsg: "Available commands:",
TgCreateSuccessMsg: "Address created successfully:",
TgCreateFailedMsg: "Failed to create address:",
TgBindSuccessMsg: "Binding successful:",
TgBindFailedMsg: "Binding failed:",
TgUnbindSuccessMsg: "Unbinding successful:",
TgUnbindFailedMsg: "Unbinding failed:",
TgDeleteSuccessMsg: "Deleted successfully:",
TgDeleteFailedMsg: "Delete failed:",
TgAddressListMsg: "Address list:",
TgGetAddressFailedMsg: "Failed to get address list:",
TgCleanSuccessMsg: "Invalid addresses cleaned:",
TgCurrentAddressListMsg: "Current address list:",
TgCleanFailedMsg: "Failed to clean invalid addresses:",
TgNotBoundAddressMsg: "This address is not bound:",
TgInvalidAddressMsg: "Invalid address",
TgNoMoreMailsMsg: "No more mails",
TgNoMailMsg: "No mail",
TgGetMailFailedMsg: "Failed to get mail:",
TgParseMailFailedMsg: "Failed to parse mail:",
TgViewMailBtnMsg: "View Mail",
TgPrevBtnMsg: "Prev",
TgNextBtnMsg: "Next",
TgPleaseInputCredentialMsg: "Please enter credential",
TgPleaseInputAddressMsg: "Please enter address",
TgAddressMsg: "Address:",
TgPasswordMsg: "Password:",
TgCredentialMsg: "Credential:",
TgNoSenderMsg: "No sender",
TgMsgTooLongMsg: "Message too long, please view in mini app",
TgParseFailedViewInAppMsg: "Parse failed, please view in mini app",
TgMaxAddressReachedMsg: "Maximum address limit reached",
TgMaxAddressReachedCleanMsg: "Maximum address limit reached, please /cleaninvalidaddress first",
TgInvalidCredentialMsg: "Invalid credential",
TgAddressNotYoursMsg: "This address does not belong to you",
TgLangSetSuccessMsg: "Language set successfully:",
TgCurrentLangMsg: "Current language:",
TgSelectLangMsg: "Please select language:",
TgNoPermissionViewMailMsg: "No permission to view this mail",
TgBotTokenRequiredMsg: "TELEGRAM_BOT_TOKEN is required",
TgLangFeatureDisabledMsg: "Language setting feature is disabled. System default language is used.",
}
export default messages;

View File

@@ -17,7 +17,7 @@ export default {
getMessagesbyContext: (
c: Context<HonoCustomType>
): LocaleMessages => {
const locale = c.get("lang") || c.env.DEFAULT_LANG;
const locale = c?.get?.("lang") || c.env?.DEFAULT_LANG;
// multi-language support
if (locale === "en") return en;
if (locale === "zh") return zh;

View File

@@ -44,4 +44,127 @@ export type LocaleMessages = {
PasswordLoginDisabledMsg: string
EmailPasswordRequiredMsg: string
AddressNotFoundMsg: string
// Common messages (merged similar ones)
OperationFailedMsg: string
RequiredFieldMsg: string
InvalidInputMsg: string
// Address related
NameTooShortMsg: string
NameTooLongMsg: string
InvalidDomainMsg: string
AddressAlreadyExistsMsg: string
MaxAddressCountReachedMsg: string
AddressNotBindedMsg: string
AddressAlreadyBindedMsg: string
TargetUserNotFoundMsg: string
// Send mail related
NoBalanceMsg: string
AddressBlockedMsg: string
SubjectEmptyMsg: string
ContentEmptyMsg: string
AlreadyRequestedMsg: string
EnableResendOrSmtpMsg: string
EnableResendOrSmtpWithVerifiedMsg: string
InvalidToMailMsg: string
// Admin related
InvalidAddressIdMsg: string
EnableKVMsg: string
EnableSendMailMsg: string
InvalidCleanupConfigMsg: string
InvalidCleanTypeMsg: string
EnableKVForMailVerifyMsg: string
VerifyMailDomainInvalidMsg: string
InvalidMaxAddressCountMsg: string
FailedDeleteUserMsg: string
InvalidUserIdMsg: string
InvalidRoleTextMsg: string
// SQL validation
SqlEmptyMsg: string
SqlTooLongMsg: string
SqlOnlyDeleteMsg: string
SqlSingleStatementMsg: string
SqlNoCommentsMsg: string
// Passkey related
InvalidPasskeyNameMsg: string
PasskeyNotFoundMsg: string
AuthenticationFailedMsg: string
RegistrationFailedMsg: string
// Auto reply related
AutoReplyDisabledMsg: string
InvalidAutoReplyMsg: string
SubjectOrMessageTooLongMsg: string
// Bind address related
NoAddressOrUserTokenMsg: string
InvalidAddressOrUserTokenMsg: string
// Pagination related
InvalidLimitMsg: string
InvalidOffsetMsg: string
// Clear inbox/sent items related
FailedClearInboxMsg: string
FailedClearSentItemsMsg: string
// Webhook related
WebhookNotAllowedForUserMsg: string
// IP blacklist related
InvalidIpBlacklistSettingMsg: string
BlacklistExceedsMaxSizeMsg: string
// Telegram bot messages
TgUnableGetUserInfoMsg: string
TgNoPermissionMsg: string
TgWelcomeMsg: string
TgCurrentPrefixMsg: string
TgCurrentDomainsMsg: string
TgAvailableCommandsMsg: string
TgCreateSuccessMsg: string
TgCreateFailedMsg: string
TgBindSuccessMsg: string
TgBindFailedMsg: string
TgUnbindSuccessMsg: string
TgUnbindFailedMsg: string
TgDeleteSuccessMsg: string
TgDeleteFailedMsg: string
TgAddressListMsg: string
TgGetAddressFailedMsg: string
TgCleanSuccessMsg: string
TgCurrentAddressListMsg: string
TgCleanFailedMsg: string
TgNotBoundAddressMsg: string
TgInvalidAddressMsg: string
TgNoMoreMailsMsg: string
TgNoMailMsg: string
TgGetMailFailedMsg: string
TgParseMailFailedMsg: string
TgViewMailBtnMsg: string
TgPrevBtnMsg: string
TgNextBtnMsg: string
TgPleaseInputCredentialMsg: string
TgPleaseInputAddressMsg: string
TgAddressMsg: string
TgPasswordMsg: string
TgCredentialMsg: string
TgNoSenderMsg: string
TgMsgTooLongMsg: string
TgParseFailedViewInAppMsg: string
TgMaxAddressReachedMsg: string
TgMaxAddressReachedCleanMsg: string
TgInvalidCredentialMsg: string
TgAddressNotYoursMsg: string
TgLangSetSuccessMsg: string
TgCurrentLangMsg: string
TgSelectLangMsg: string
TgNoPermissionViewMailMsg: string
TgBotTokenRequiredMsg: string
TgLangFeatureDisabledMsg: string
}

View File

@@ -46,6 +46,129 @@ const messages: LocaleMessages = {
PasswordLoginDisabledMsg: "密码登录已禁用",
EmailPasswordRequiredMsg: "邮箱和密码不能为空",
AddressNotFoundMsg: "邮箱地址不存在",
// Common messages (merged similar ones)
OperationFailedMsg: "操作失败",
RequiredFieldMsg: "缺少必填字段",
InvalidInputMsg: "输入无效",
// Address related
NameTooShortMsg: "名称太短",
NameTooLongMsg: "名称太长",
InvalidDomainMsg: "无效的域名",
AddressAlreadyExistsMsg: "邮箱地址已存在",
MaxAddressCountReachedMsg: "已达到最大地址数量限制",
AddressNotBindedMsg: "邮箱地址未绑定",
AddressAlreadyBindedMsg: "邮箱地址已绑定, 请先解绑",
TargetUserNotFoundMsg: "目标用户不存在",
// Send mail related
NoBalanceMsg: "余额不足",
AddressBlockedMsg: "地址已被屏蔽",
SubjectEmptyMsg: "主题不能为空",
ContentEmptyMsg: "内容不能为空",
AlreadyRequestedMsg: "已经申请过了",
EnableResendOrSmtpMsg: "请先为此域名启用 resend 或 smtp",
EnableResendOrSmtpWithVerifiedMsg: "请先为此域名启用 resend 或 smtp或将收件人添加到已验证地址列表",
InvalidToMailMsg: "收件人地址无效",
// Admin related
InvalidAddressIdMsg: "无效的 address_id",
EnableKVMsg: "请先启用 KV",
EnableSendMailMsg: "请先启用 SEND_MAIL",
InvalidCleanupConfigMsg: "无效的 cleanType 或 cleanDays",
InvalidCleanTypeMsg: "无效的 cleanType",
EnableKVForMailVerifyMsg: "如果要启用邮件验证,请先启用 KV",
VerifyMailDomainInvalidMsg: "验证邮件发送者域名必须在",
InvalidMaxAddressCountMsg: "无效的 maxAddressCount",
FailedDeleteUserMsg: "删除用户失败",
InvalidUserIdMsg: "无效的 user_id",
InvalidRoleTextMsg: "无效的 role_text",
// SQL validation
SqlEmptyMsg: "SQL 语句为空",
SqlTooLongMsg: "SQL 语句过长 (最大 1000 字符)",
SqlOnlyDeleteMsg: "只允许 DELETE 语句",
SqlSingleStatementMsg: "只允许单条 SQL 语句",
SqlNoCommentsMsg: "不允许 SQL 注释",
// Passkey related
InvalidPasskeyNameMsg: "无效的 passkey 名称",
PasskeyNotFoundMsg: "Passkey 不存在",
AuthenticationFailedMsg: "认证失败",
RegistrationFailedMsg: "注册失败",
// Auto reply related
AutoReplyDisabledMsg: "自动回复已禁用",
InvalidAutoReplyMsg: "无效的主题或消息",
SubjectOrMessageTooLongMsg: "主题或消息太长",
// Bind address related
NoAddressOrUserTokenMsg: "缺少地址或用户令牌",
InvalidAddressOrUserTokenMsg: "无效的地址或用户令牌",
// Pagination related
InvalidLimitMsg: "无效的 limit 参数",
InvalidOffsetMsg: "无效的 offset 参数",
// Clear inbox/sent items related
FailedClearInboxMsg: "清空收件箱失败",
FailedClearSentItemsMsg: "清空已发送邮件失败",
// Webhook related
WebhookNotAllowedForUserMsg: "此用户不允许使用 Webhook 设置",
// IP blacklist related
InvalidIpBlacklistSettingMsg: "无效的 IP 黑名单设置",
BlacklistExceedsMaxSizeMsg: "黑名单超出最大条目限制",
// Telegram bot messages
TgUnableGetUserInfoMsg: "无法获取用户信息",
TgNoPermissionMsg: "您没有权限使用此机器人",
TgWelcomeMsg: "欢迎使用本机器人, 您可以打开 mini app",
TgCurrentPrefixMsg: "当前已启用前缀:",
TgCurrentDomainsMsg: "当前可用域名:",
TgAvailableCommandsMsg: "请使用以下命令:",
TgCreateSuccessMsg: "创建地址成功:",
TgCreateFailedMsg: "创建地址失败:",
TgBindSuccessMsg: "绑定成功:",
TgBindFailedMsg: "绑定失败:",
TgUnbindSuccessMsg: "解绑成功:",
TgUnbindFailedMsg: "解绑失败:",
TgDeleteSuccessMsg: "删除成功:",
TgDeleteFailedMsg: "删除失败:",
TgAddressListMsg: "地址列表:",
TgGetAddressFailedMsg: "获取地址列表失败:",
TgCleanSuccessMsg: "清理无效地址成功:",
TgCurrentAddressListMsg: "当前地址列表:",
TgCleanFailedMsg: "清理无效地址失败:",
TgNotBoundAddressMsg: "未绑定此地址:",
TgInvalidAddressMsg: "无效地址",
TgNoMoreMailsMsg: "已经没有邮件了",
TgNoMailMsg: "无邮件",
TgGetMailFailedMsg: "获取邮件失败:",
TgParseMailFailedMsg: "解析邮件失败:",
TgViewMailBtnMsg: "查看邮件",
TgPrevBtnMsg: "上一条",
TgNextBtnMsg: "下一条",
TgPleaseInputCredentialMsg: "请输入凭证",
TgPleaseInputAddressMsg: "请输入地址",
TgAddressMsg: "地址:",
TgPasswordMsg: "密码:",
TgCredentialMsg: "凭证:",
TgNoSenderMsg: "无发件人",
TgMsgTooLongMsg: "消息过长请到 mini app 查看",
TgParseFailedViewInAppMsg: "解析失败,请打开 mini app 查看",
TgMaxAddressReachedMsg: "绑定地址数量已达上限",
TgMaxAddressReachedCleanMsg: "绑定地址数量已达上限, 请先 /cleaninvalidaddress",
TgInvalidCredentialMsg: "无效凭证",
TgAddressNotYoursMsg: "此地址不属于您",
TgLangSetSuccessMsg: "语言设置成功:",
TgCurrentLangMsg: "当前语言:",
TgSelectLangMsg: "请选择语言:",
TgNoPermissionViewMailMsg: "无权查看此邮件",
TgBotTokenRequiredMsg: "需要设置 TELEGRAM_BOT_TOKEN",
TgLangFeatureDisabledMsg: "语言设置功能已禁用,使用系统默认语言",
}
export default messages;

View File

@@ -7,8 +7,7 @@ export default {
// 修改地址密码
changePassword: async (c: Context<HonoCustomType>) => {
const { new_password } = await c.req.json();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
const { address, address_id } = c.get("jwtPayload");
// 检查功能是否启用
@@ -39,8 +38,7 @@ export default {
// 地址密码登录
login: async (c: Context<HonoCustomType>) => {
const { email, password, cf_token } = await c.req.json();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
// 检查功能是否启用
if (!getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD)) {

View File

@@ -1,11 +1,13 @@
import { Context } from "hono";
import { getBooleanValue } from "../utils";
import i18n from "../i18n";
export default {
getAutoReply: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_AUTO_REPLY)) {
return c.text("Auto reply is disabled", 403)
return c.text(msgs.AutoReplyDisabledMsg, 403)
}
const { address } = c.get("jwtPayload")
const results = await c.env.DB.prepare(
@@ -23,17 +25,18 @@ export default {
})
},
saveAutoReply: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_AUTO_REPLY)) {
return c.text("Auto reply is disabled", 403)
return c.text(msgs.AutoReplyDisabledMsg, 403)
}
const { address } = c.get("jwtPayload");
const { auto_reply } = await c.req.json();
const { name, subject, source_prefix, message, enabled } = auto_reply;
if ((!subject || !message) && enabled) {
return c.text("Invalid subject or message", 400)
return c.text(msgs.InvalidAutoReplyMsg, 400)
}
else if (subject.length > 255 || message.length > 255) {
return c.text("Subject or message too long", 400)
return c.text(msgs.SubjectOrMessageTooLongMsg, 400)
}
const { success } = await c.env.DB.prepare(
`INSERT OR REPLACE INTO auto_reply_mails`
@@ -44,7 +47,7 @@ export default {
subject || '', message || '', enabled ? 1 : 0
).run();
if (!success) {
return c.text("Failed to auto_reply settings", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({
success: success

View File

@@ -45,8 +45,7 @@ api.get('/api/mail/:mail_id', async (c) => {
})
api.delete('/api/mails/:id', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
@@ -64,8 +63,7 @@ api.delete('/api/mails/:id', async (c) => {
api.get('/api/settings', async (c) => {
const { address, address_id } = c.get("jwtPayload")
const user_role = c.get("userRolePayload")
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (address_id && address_id > 0) {
try {
const db_address_id = await c.env.DB.prepare(
@@ -106,8 +104,7 @@ api.get('/api/settings', async (c) => {
})
api.post('/api/new_address', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (getBooleanValue(c.env.DISABLE_ANONYMOUS_USER_CREATE_EMAIL)
&& !c.get("userPayload")
) {
@@ -144,11 +141,17 @@ api.post('/api/new_address', async (c) => {
}
try {
const addressPrefix = await getAddressPrefix(c);
// Get client IP for source tracking
const sourceMeta = c.req.header('CF-Connecting-IP')
|| c.req.header('X-Forwarded-For')?.split(',')[0]?.trim()
|| c.req.header('X-Real-IP')
|| 'web:unknown';
const res = await newAddress(c, {
name, domain,
enablePrefix: true,
checkLengthByConfig: true,
addressPrefix
addressPrefix,
sourceMeta
});
return c.json(res);
} catch (e) {
@@ -165,8 +168,7 @@ api.delete('/api/delete_address', async (c) => {
})
api.delete('/api/clear_inbox', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
@@ -175,7 +177,7 @@ api.delete('/api/clear_inbox', async (c) => {
`DELETE FROM raw_mails WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text("Failed to clear inbox", 500)
return c.text(msgs.FailedClearInboxMsg, 500)
}
return c.json({
success: success
@@ -183,8 +185,7 @@ api.delete('/api/clear_inbox', async (c) => {
})
api.delete('/api/clear_sent_items', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}
@@ -193,7 +194,7 @@ api.delete('/api/clear_sent_items', async (c) => {
`DELETE FROM sendbox WHERE address = ?`
).bind(address).run();
if (!success) {
return c.text("Failed to clear sent items", 500)
return c.text(msgs.FailedClearSentItemsMsg, 500)
}
return c.json({
success: success

View File

@@ -14,9 +14,10 @@ import { handleListQuery } from '../common'
export const api = new Hono<HonoCustomType>()
api.post('/api/requset_send_mail_access', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { address } = c.get("jwtPayload")
if (!address) {
return c.text("No address", 400)
return c.text(msgs.AddressNotFoundMsg, 400)
}
try {
const default_balance = getIntValue(c.env.DEFAULT_SEND_BALANCE, 0);
@@ -26,14 +27,14 @@ api.post('/api/requset_send_mail_access', async (c) => {
address, default_balance, default_balance > 0 ? 1 : 0
).run();
if (!success) {
return c.text("Failed to request send mail access", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
} catch (e) {
const message = (e as Error).message;
if (message && message.includes("UNIQUE")) {
return c.text("Already requested", 400)
return c.text(msgs.AlreadyRequestedMsg, 400)
}
return c.text("Failed to request send mail access", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({ status: "ok" })
})
@@ -126,14 +127,15 @@ export const sendMail = async (
isAdmin?: boolean
}
): Promise<void> => {
const msgs = i18n.getMessagesbyContext(c);
if (!address) {
throw new Error("No address")
throw new Error(msgs.AddressNotFoundMsg)
}
// check domain
const mailDomain = address.split("@")[1];
const domains = getDomains(c);
if (!domains.includes(mailDomain)) {
throw new Error("Invalid domain")
throw new Error(msgs.InvalidDomainMsg)
}
const user_role = c.get("userRolePayload");
const no_limit_roles = getSplitStringListValue(c.env.NO_LIMIT_SEND_ROLE);
@@ -150,7 +152,7 @@ export const sendMail = async (
where address = ? and enabled = 1`
).bind(address).first<number>("balance");
if (!balance || balance <= 0) {
throw new Error("No balance")
throw new Error(msgs.NoBalanceMsg)
}
}
const {
@@ -158,18 +160,18 @@ export const sendMail = async (
subject, content, is_html
} = reqJson;
if (!to_mail) {
throw new Error("Invalid to mail")
throw new Error(msgs.InvalidToMailMsg)
}
// check SEND_BLOCK_LIST_KEY
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY) as string[];
if (sendBlockList && sendBlockList.some((item) => to_mail.includes(item))) {
throw new Error("to_mail address is blocked")
throw new Error(msgs.AddressBlockedMsg)
}
if (!subject) {
throw new Error("Subject is empty")
throw new Error(msgs.SubjectEmptyMsg)
}
if (!content) {
throw new Error("Content is empty")
throw new Error(msgs.ContentEmptyMsg)
}
// send to verified address list, do not update balance
@@ -202,9 +204,9 @@ export const sendMail = async (
}
else {
if (c.env.SEND_MAIL) {
throw new Error(`Please enable resend or smtp for domain ${mailDomain}. Or add ${to_mail} to verified address list`);
throw new Error(`${msgs.EnableResendOrSmtpWithVerifiedMsg} (${mailDomain})`);
}
throw new Error(`Please enable resend or smtp for domain ${mailDomain}`);
throw new Error(`${msgs.EnableResendOrSmtpMsg} (${mailDomain})`);
}
// update balance
@@ -253,11 +255,12 @@ api.post('/api/send_mail', async (c) => {
})
api.post('/external/api/send_mail', async (c) => {
const msgs = i18n.getMessagesbyContext(c);
const { token } = await c.req.json();
try {
const { address } = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
if (!address) {
return c.text("No address", 400)
return c.text(msgs.AddressNotFoundMsg, 400)
}
const reqJson = await c.req.json();
await sendMail(c, address as string, reqJson);
@@ -289,8 +292,7 @@ api.get('/api/sendbox', async (c) => {
})
api.delete('/api/sendbox/:id', async (c) => {
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
return c.text(msgs.UserDeleteEmailDisabledMsg, 403)
}

View File

@@ -2,13 +2,15 @@ import { Context } from "hono";
import { CONSTANTS } from "../constants";
import { AdminWebhookSettings, WebhookSettings } from "../models";
import { commonParseMail, sendWebhook } from "../common";
import i18n from "../i18n";
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const msgs = i18n.getMessagesbyContext(c);
const { address } = c.get("jwtPayload")
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (adminSettings?.enableAllowList && !adminSettings?.allowList.includes(address)) {
return c.text("Webhook settings is not allowed for this user", 403);
return c.text(msgs.WebhookNotAllowedForUserMsg, 403);
}
const settings = await c.env.KV.get<WebhookSettings>(
`${CONSTANTS.WEBHOOK_KV_USER_SETTINGS_KEY}:${address}`, "json"
@@ -18,10 +20,11 @@ async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response>
async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const msgs = i18n.getMessagesbyContext(c);
const { address } = c.get("jwtPayload")
const adminSettings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
if (adminSettings?.enableAllowList && !adminSettings?.allowList.includes(address)) {
return c.text("Webhook settings is not allowed for this user", 403);
return c.text(msgs.WebhookNotAllowedForUserMsg, 403);
}
const settings = await c.req.json<WebhookSettings>();
await c.env.KV.put(

View File

@@ -34,6 +34,13 @@ export type WebhookMail = {
parsedHtml: string;
}
export type CustomSqlCleanup = {
id: string; // Unique identifier
name: string; // Cleanup task name
sql: string; // Custom SQL statement (DELETE only)
enabled: boolean; // Whether to enable auto cleanup
}
export type CleanupSettings = {
enableMailsAutoCleanup: boolean | undefined;
@@ -50,6 +57,7 @@ export type CleanupSettings = {
cleanUnboundAddressDays: number;
enableEmptyAddressAutoCleanup: boolean | undefined;
cleanEmptyAddressDays: number;
customSqlCleanupList: CustomSqlCleanup[] | undefined;
}
export class GeoData {

View File

@@ -3,6 +3,7 @@ import { cleanup } from './common'
import { CONSTANTS } from './constants'
import { getJsonSetting } from './utils';
import { CleanupSettings } from './models';
import { executeCustomSqlCleanup } from './admin_api/cleanup_api';
export async function scheduled(event: ScheduledEvent, env: Bindings, ctx: any) {
console.log("Scheduled event: ", event);
@@ -64,4 +65,18 @@ export async function scheduled(event: ScheduledEvent, env: Bindings, ctx: any)
autoCleanupSetting.cleanEmptyAddressDays
);
}
// Execute custom SQL cleanup tasks
if (autoCleanupSetting.customSqlCleanupList && autoCleanupSetting.customSqlCleanupList.length > 0) {
for (const customSql of autoCleanupSetting.customSqlCleanupList) {
if (customSql.enabled && customSql.sql) {
const result = await executeCustomSqlCleanup(
{ env: env, } as Context<HonoCustomType>,
customSql
);
if (!result.success) {
console.error(`Custom SQL cleanup [${customSql.name}] failed: ${result.error}`);
}
}
}
}
}

View File

@@ -3,9 +3,11 @@ import { Jwt } from "hono/utils/jwt";
import { CONSTANTS } from "../constants";
import { getBooleanValue, getIntValue, getJsonSetting } from "../utils";
import { deleteAddressWithData, newAddress, generateRandomName } from "../common";
import { LocaleMessages } from "../i18n/type";
export const tgUserNewAddress = async (
c: Context<HonoCustomType>, userId: string, address: string
c: Context<HonoCustomType>, userId: string, address: string,
msgs: LocaleMessages
): Promise<{ address: string, jwt: string, password?: string | null }> => {
if (c.env.RATE_LIMITER) {
const { success } = await c.env.RATE_LIMITER.limit(
@@ -23,7 +25,7 @@ export const tgUserNewAddress = async (
const [name, domain] = trimmedAddress.includes("@") ? trimmedAddress.split("@") : [trimmedAddress, null];
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
throw Error("绑定地址数量已达上限");
throw Error(msgs.TgMaxAddressReachedMsg);
}
// Generate name if disabled or not provided
const finalName = (!name || disableCustomAddressName) ? generateRandomName(c) : name;
@@ -38,7 +40,8 @@ export const tgUserNewAddress = async (
const res = await newAddress(c, {
name: finalName,
domain,
enablePrefix: true
enablePrefix: true,
sourceMeta: `tg:${userId}`
});
// for mail push to telegram
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, res.jwt]));
@@ -47,7 +50,8 @@ export const tgUserNewAddress = async (
}
export const jwtListToAddressData = async (
c: Context<HonoCustomType>, jwtList: string[]
c: Context<HonoCustomType>, jwtList: string[],
msgs: LocaleMessages
): Promise<{
addressList: string[], addressIdMap: Record<string, number>,
invalidJwtList: string[]
@@ -62,35 +66,36 @@ export const jwtListToAddressData = async (
`SELECT name FROM address WHERE id = ? `
).bind(address_id).first("name");
if (!name) {
addressList.push("无效地址");
addressList.push(msgs.TgInvalidAddressMsg);
invalidJwtList.push(jwt);
continue;
}
addressList.push(address as string);
addressIdMap[address as string] = address_id as number;
} catch (e) {
addressList.push("无效凭证");
addressList.push(msgs.TgInvalidCredentialMsg);
invalidJwtList.push(jwt);
console.log(`获取地址列表失败: ${(e as Error).message}`);
console.log(`Failed to get address list: ${(e as Error).message}`);
}
}
return { addressList, addressIdMap, invalidJwtList };
}
export const bindTelegramAddress = async (
c: Context<HonoCustomType>, userId: string, jwt: string
c: Context<HonoCustomType>, userId: string, jwt: string,
msgs: LocaleMessages
): Promise<string> => {
const { address } = await Jwt.verify(jwt, c.env.JWT_SECRET, "HS256");
if (!address) {
throw Error("无效凭证");
throw Error(msgs.TgInvalidCredentialMsg);
}
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const { addressIdMap } = await jwtListToAddressData(c, jwtList);
const { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
if (address as string in addressIdMap) {
return address as string;
}
if (jwtList.length >= getIntValue(c.env.TG_MAX_ADDRESS, 5)) {
throw Error("绑定地址数量已达上限, 请先 /cleaninvalidaddress");
throw Error(msgs.TgMaxAddressReachedCleanMsg);
}
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify([...jwtList, jwt]));
// for mail push to telegram
@@ -132,12 +137,13 @@ export const unbindTelegramByAddress = async (
export const deleteTelegramAddress = async (
c: Context<HonoCustomType>, userId: string, address: string
c: Context<HonoCustomType>, userId: string, address: string,
msgs: LocaleMessages
): Promise<boolean> => {
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const { addressIdMap } = await jwtListToAddressData(c, jwtList);
const { addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
if (!(address in addressIdMap)) {
throw Error("此地址不属于您");
throw Error(msgs.TgAddressNotYoursMsg);
}
await deleteAddressWithData(c, null, addressIdMap[address])
return true;

View File

@@ -5,26 +5,29 @@ import { Writable } from 'node:stream'
import { newTelegramBot, initTelegramBotCommands, sendMailToTelegram } from './telegram'
import settings from './settings'
import miniapp from './miniapp'
import i18n from '../i18n'
export const api = new Hono<HonoCustomType>();
export { sendMailToTelegram }
api.use("/telegram/*", async (c, next) => {
const msgs = i18n.getMessagesbyContext(c);
if (!c.env.TELEGRAM_BOT_TOKEN) {
return c.text("TELEGRAM_BOT_TOKEN is required", 400);
return c.text(msgs.TgBotTokenRequiredMsg, 400);
}
if (!c.env.KV) {
return c.text("KV is required", 400);
return c.text(msgs.KVNotAvailableMsg, 400);
}
return await next();
});
api.use("/admin/telegram/*", async (c, next) => {
const msgs = i18n.getMessagesbyContext(c);
if (!c.env.TELEGRAM_BOT_TOKEN) {
return c.text("TELEGRAM_BOT_TOKEN is required", 400);
return c.text(msgs.TgBotTokenRequiredMsg, 400);
}
if (!c.env.KV) {
return c.text("KV is required", 400);
return c.text(msgs.KVNotAvailableMsg, 400);
}
return await next();
});
@@ -51,7 +54,7 @@ api.post("/admin/telegram/init", async (c) => {
console.log(`setting webhook to ${webhookUrl}`);
const bot = newTelegramBot(c, token);
await bot.telegram.setWebhook(webhookUrl)
await initTelegramBotCommands(bot);
await initTelegramBotCommands(c, bot);
return c.json({
message: "webhook set successfully",
});

View File

@@ -84,8 +84,7 @@ async function getTelegramBindAddress(c: Context<HonoCustomType>): Promise<Respo
async function newTelegramAddress(c: Context<HonoCustomType>): Promise<Response> {
const { initData, address, cf_token } = await c.req.json();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
// check cf turnstile
try {
await checkCfTurnstile(c, cf_token);
@@ -95,7 +94,7 @@ async function newTelegramAddress(c: Context<HonoCustomType>): Promise<Response>
try {
const userId = await checkTelegramAuth(c, initData);
// get the address list from the KV
const res = await tgUserNewAddress(c, userId, address)
const res = await tgUserNewAddress(c, userId, address, msgs)
return c.json(res);
}
catch (e) {
@@ -105,9 +104,10 @@ async function newTelegramAddress(c: Context<HonoCustomType>): Promise<Response>
async function bindAddress(c: Context<HonoCustomType>): Promise<Response> {
const { initData, jwt } = await c.req.json();
const msgs = i18n.getMessagesbyContext(c);
try {
const userId = await checkTelegramAuth(c, initData);
await bindTelegramAddress(c, userId, jwt);
await bindTelegramAddress(c, userId, jwt, msgs);
return c.json({ success: true });
}
catch (e) {
@@ -129,10 +129,11 @@ async function unbindAddress(c: Context<HonoCustomType>): Promise<Response> {
async function getMail(c: Context<HonoCustomType>): Promise<Response> {
const { initData, mailId } = await c.req.json();
const msgs = i18n.getMessagesbyContext(c);
try {
const userId = await checkTelegramAuth(c, initData);
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const { addressList, addressIdMap } = await jwtListToAddressData(c, jwtList);
const { addressList, addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
const result = await c.env.DB.prepare(
`SELECT * FROM raw_mails where id = ?`
).bind(mailId).first();
@@ -140,14 +141,14 @@ async function getMail(c: Context<HonoCustomType>): Promise<Response> {
const superUser = settings?.enableGlobalMailPush && settings?.globalMailPushList.includes(userId);
if (!superUser) {
if (result?.address && !(result.address as string in addressIdMap)) {
return c.text("无权查看此邮件", 403);
return c.text(msgs.TgNoPermissionViewMailMsg, 403);
}
const address_id = addressIdMap[result?.address as string];
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ? `
).bind(address_id).first("id");
if (!db_address_id) {
return c.text("无权查看此邮件", 403);
return c.text(msgs.TgNoPermissionViewMailMsg, 403);
}
}
return c.json(result);

View File

@@ -4,48 +4,79 @@ import { Telegraf, Context as TgContext, Markup } from "telegraf";
import { callbackQuery } from "telegraf/filters";
import { CONSTANTS } from "../constants";
import { getDomains, getJsonObjectValue, getStringValue } from '../utils';
import { getBooleanValue, getDomains, getJsonObjectValue, getStringValue } from '../utils';
import { TelegramSettings } from "./settings";
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>,
ctx?: TgContext,
userId?: string | null
): Promise<LocaleMessages> => {
// Check if user language config is enabled (default false)
if (!getBooleanValue(c.env.TG_ALLOW_USER_LANG)) {
return i18n.getMessages(c.env.DEFAULT_LANG || 'zh');
}
const uid = userId || ctx?.message?.from?.id?.toString() || ctx?.callbackQuery?.from?.id?.toString();
if (uid) {
const savedLang = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:lang:${uid}`);
if (savedLang) { return i18n.getMessages(savedLang); }
}
return i18n.getMessages(c.env.DEFAULT_LANG || 'zh');
};
// Bilingual command descriptions with full usage instructions
const COMMANDS = [
{
command: "start",
description: "开始使用"
description: "开始使用 | Get started"
},
{
command: "new",
description: "新建邮箱地址, 如果要自定义邮箱地址, 请输入 /new, 通过 /new <name>@<domain> 可以指定, name [a-z0-9] 有效, name 为空随机生成, @<domain> 可选"
description: "新建邮箱, /new <name>@<domain>, name[a-z0-9]有效, 为空随机生成, @domain可选 | Create address, /new <name>@<domain>, name[a-z0-9] valid, empty=random, @domain optional"
},
{
command: "address",
description: "查看邮箱地址列表"
description: "查看邮箱地址列表 | View address list"
},
{
command: "bind",
description: "绑定邮箱地址, 请输入 /bind <邮箱地址凭证>"
description: "绑定邮箱, /bind <邮箱地址凭证> | Bind address, /bind <credential>"
},
{
command: "unbind",
description: "解绑邮箱地址, 请输入 /unbind <邮箱地址>"
description: "解绑邮箱, /unbind <邮箱地址> | Unbind address, /unbind <address>"
},
{
command: "delete",
description: "删除邮箱地址, 请输入 /delete <邮箱地址>"
description: "删除邮箱, /delete <邮箱地址> | Delete address, /delete <address>"
},
{
command: "mails",
description: "查看邮件, 请输入 /mails <邮箱地址>, 不输入地址默认查看第一个地址"
description: "查看邮件, /mails <邮箱地址>, 不输入地址默认第一个 | View mails, /mails <address>, default first if empty"
},
{
command: "cleaninvalidaddress",
description: "清理无效地址, 请输入 /cleaninvalidaddress"
description: "清理无效地址 | Clean invalid addresses"
},
{
command: "lang",
description: "设置语言 /lang <zh|en> | Set language /lang <zh|en>"
},
]
export const getTelegramCommands = (c: Context<HonoCustomType>) => {
return getBooleanValue(c.env.TG_ALLOW_USER_LANG)
? COMMANDS
: COMMANDS.filter(cmd => cmd.command !== "lang");
}
export function newTelegramBot(c: Context<HonoCustomType>, token: string): Telegraf {
const bot = new Telegraf(token);
const botInfo = getJsonObjectValue<UserFromGetMe>(c.env.TG_BOT_INFO);
@@ -61,14 +92,16 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
const userId = ctx?.message?.from?.id || ctx.callbackQuery?.message?.chat?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
const msgs = await getTgMessages(c, ctx);
return await ctx.reply(msgs.TgUnableGetUserInfoMsg);
}
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
if (settings?.enableAllowList && settings?.enableAllowList
if (settings?.enableAllowList
&& !settings.allowList.includes(userId.toString())
) {
return await ctx.reply("您没有权限使用此机器人");
const msgs = await getTgMessages(c, ctx);
return await ctx.reply(msgs.TgNoPermissionMsg);
}
try {
await next();
@@ -79,153 +112,192 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
})
bot.command("start", async (ctx: TgContext) => {
const msgs = await getTgMessages(c, ctx);
const prefix = getStringValue(c.env.PREFIX)
const domains = getDomains(c);
const commands = getTelegramCommands(c);
return await ctx.reply(
"欢迎使用本机器人, 您可以打开 mini app \n\n"
+ (prefix ? `当前已启用前缀: ${prefix}\n` : '')
+ `当前可用域名: ${JSON.stringify(domains)}\n`
+ "请使用以下命令:\n"
+ COMMANDS.map(c => `/${c.command}: ${c.description}`).join("\n")
`${msgs.TgWelcomeMsg}\n\n`
+ (prefix ? `${msgs.TgCurrentPrefixMsg} ${prefix}\n` : '')
+ `${msgs.TgCurrentDomainsMsg} ${JSON.stringify(domains)}\n`
+ `${msgs.TgAvailableCommandsMsg}\n`
+ commands.map(cmd => `/${cmd.command}: ${cmd.description}`).join("\n")
);
});
bot.command("new", async (ctx: TgContext) => {
const msgs = await getTgMessages(c, ctx);
const userId = ctx?.message?.from?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
return await ctx.reply(msgs.TgUnableGetUserInfoMsg);
}
try {
// @ts-ignore
const address = ctx?.message?.text.slice("/new".length).trim();
const res = await tgUserNewAddress(c, userId.toString(), address);
return await ctx.reply(`创建地址成功:\n`
+ `地址: ${res.address}\n`
+ (res.password ? `密码: \`${res.password}\`\n` : '')
+ `凭证: \`${res.jwt}\`\n`,
const res = await tgUserNewAddress(c, userId.toString(), address, msgs);
return await ctx.reply(`${msgs.TgCreateSuccessMsg}\n`
+ `${msgs.TgAddressMsg} ${res.address}\n`
+ (res.password ? `${msgs.TgPasswordMsg} \`${res.password}\`\n` : '')
+ `${msgs.TgCredentialMsg} \`${res.jwt}\`\n`,
{
parse_mode: "Markdown"
}
);
} catch (e) {
return await ctx.reply(`创建地址失败: ${(e as Error).message}`);
return await ctx.reply(`${msgs.TgCreateFailedMsg} ${(e as Error).message}`);
}
});
bot.command("bind", async (ctx: TgContext) => {
const msgs = await getTgMessages(c, ctx);
const userId = ctx?.message?.from?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
return await ctx.reply(msgs.TgUnableGetUserInfoMsg);
}
try {
// @ts-ignore
const jwt = ctx?.message?.text.slice("/bind".length).trim();
if (!jwt) {
return await ctx.reply("请输入凭证");
return await ctx.reply(msgs.TgPleaseInputCredentialMsg);
}
const address = await bindTelegramAddress(c, userId.toString(), jwt);
return await ctx.reply(`绑定成功:\n`
+ `地址: ${address}`
const address = await bindTelegramAddress(c, userId.toString(), jwt, msgs);
return await ctx.reply(`${msgs.TgBindSuccessMsg}\n`
+ `${msgs.TgAddressMsg} ${address}`
);
}
catch (e) {
return await ctx.reply(`绑定失败: ${(e as Error).message}`);
return await ctx.reply(`${msgs.TgBindFailedMsg} ${(e as Error).message}`);
}
});
bot.command("unbind", async (ctx: TgContext) => {
const msgs = await getTgMessages(c, ctx);
const userId = ctx?.message?.from?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
return await ctx.reply(msgs.TgUnableGetUserInfoMsg);
}
try {
// @ts-ignore
const address = ctx?.message?.text.slice("/unbind".length).trim();
if (!address) {
return await ctx.reply("请输入地址");
return await ctx.reply(msgs.TgPleaseInputAddressMsg);
}
await unbindTelegramAddress(c, userId.toString(), address);
return await ctx.reply(`解绑成功:\n地址: ${address}`
return await ctx.reply(`${msgs.TgUnbindSuccessMsg}\n${msgs.TgAddressMsg} ${address}`
);
}
catch (e) {
return await ctx.reply(`解绑失败: ${(e as Error).message}`);
return await ctx.reply(`${msgs.TgUnbindFailedMsg} ${(e as Error).message}`);
}
})
bot.command("delete", async (ctx: TgContext) => {
const msgs = await getTgMessages(c, ctx);
const userId = ctx?.message?.from?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
return await ctx.reply(msgs.TgUnableGetUserInfoMsg);
}
try {
// @ts-ignore
const address = ctx?.message?.text.slice("/delete".length).trim();
if (!address) {
return await ctx.reply("请输入地址");
return await ctx.reply(msgs.TgPleaseInputAddressMsg);
}
await deleteTelegramAddress(c, userId.toString(), address);
return await ctx.reply(`删除成功: ${address}`);
await deleteTelegramAddress(c, userId.toString(), address, msgs);
return await ctx.reply(`${msgs.TgDeleteSuccessMsg} ${address}`);
} catch (e) {
return await ctx.reply(`删除失败: ${(e as Error).message}`);
return await ctx.reply(`${msgs.TgDeleteFailedMsg} ${(e as Error).message}`);
}
});
bot.command("address", async (ctx) => {
const msgs = await getTgMessages(c, ctx);
const userId = ctx?.message?.from?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
return await ctx.reply(msgs.TgUnableGetUserInfoMsg);
}
try {
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const { addressList } = await jwtListToAddressData(c, jwtList);
return await ctx.reply(`地址列表:\n\n`
+ addressList.map(a => `地址: ${a}`).join("\n")
const { addressList } = await jwtListToAddressData(c, jwtList, msgs);
return await ctx.reply(`${msgs.TgAddressListMsg}\n\n`
+ addressList.map(a => `${msgs.TgAddressMsg} ${a}`).join("\n")
);
} catch (e) {
return await ctx.reply(`获取地址列表失败: ${(e as Error).message}`);
return await ctx.reply(`${msgs.TgGetAddressFailedMsg} ${(e as Error).message}`);
}
});
bot.command("cleaninvalidaddress", async (ctx: TgContext) => {
const msgs = await getTgMessages(c, ctx);
const userId = ctx?.message?.from?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
return await ctx.reply(msgs.TgUnableGetUserInfoMsg);
}
try {
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const { invalidJwtList } = await jwtListToAddressData(c, jwtList);
const { invalidJwtList } = await jwtListToAddressData(c, jwtList, msgs);
const newJwtList = jwtList.filter(jwt => !invalidJwtList.includes(jwt));
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, JSON.stringify(newJwtList));
const { addressList } = await jwtListToAddressData(c, newJwtList);
return await ctx.reply(`清理无效地址成功:\n\n`
+ `当前地址列表:\n\n`
+ addressList.map(a => `地址: ${a}`).join("\n")
const { addressList } = await jwtListToAddressData(c, newJwtList, msgs);
return await ctx.reply(`${msgs.TgCleanSuccessMsg}\n\n`
+ `${msgs.TgCurrentAddressListMsg}\n\n`
+ addressList.map(a => `${msgs.TgAddressMsg} ${a}`).join("\n")
);
} catch (e) {
return await ctx.reply(`清理无效地址失败: ${(e as Error).message}`);
return await ctx.reply(`${msgs.TgCleanFailedMsg} ${(e as Error).message}`);
}
});
bot.command("lang", async (ctx: TgContext) => {
const userId = ctx?.message?.from?.id;
if (!userId) {
const msgs = await getTgMessages(c, ctx);
return await ctx.reply(msgs.TgUnableGetUserInfoMsg);
}
const msgs = await getTgMessages(c, ctx);
// Check if user language config is enabled
if (!getBooleanValue(c.env.TG_ALLOW_USER_LANG)) {
return await ctx.reply(msgs.TgLangFeatureDisabledMsg);
}
// @ts-ignore
const lang = ctx?.message?.text.slice("/lang".length).trim().toLowerCase();
if (lang === 'zh' || lang === 'en') {
await c.env.KV.put(`${CONSTANTS.TG_KV_PREFIX}:lang:${userId}`, lang);
return await ctx.reply(`${msgs.TgLangSetSuccessMsg} ${lang === 'zh' ? '中文' : 'English'}`);
}
const currentLang = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:lang:${userId}`);
return await ctx.reply(
`${msgs.TgCurrentLangMsg} ${currentLang || 'auto'}\n`
+ `${msgs.TgSelectLangMsg}\n`
+ `/lang zh - 中文\n`
+ `/lang en - English`
);
});
const queryMail = async (ctx: TgContext, queryAddress: string, mailIndex: number, edit: boolean) => {
const msgs = await getTgMessages(c, ctx);
const userId = ctx?.message?.from?.id || ctx.callbackQuery?.message?.chat?.id;
if (!userId) {
return await ctx.reply("无法获取用户信息");
return await ctx.reply(msgs.TgUnableGetUserInfoMsg);
}
const jwtList = await c.env.KV.get<string[]>(`${CONSTANTS.TG_KV_PREFIX}:${userId}`, 'json') || [];
const { addressList, addressIdMap } = await jwtListToAddressData(c, jwtList);
const { addressList, addressIdMap } = await jwtListToAddressData(c, jwtList, msgs);
if (!queryAddress && addressList.length > 0) {
queryAddress = addressList[0];
}
if (!(queryAddress in addressIdMap)) {
return await ctx.reply(`未绑定此地址 ${queryAddress}`);
return await ctx.reply(`${msgs.TgNotBoundAddressMsg} ${queryAddress}`);
}
const address_id = addressIdMap[queryAddress];
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ? `
).bind(address_id).first("id");
if (!db_address_id) {
return await ctx.reply("无效地址");
return await ctx.reply(msgs.TgInvalidAddressMsg);
}
const { raw, id: mailId, created_at } = await c.env.DB.prepare(
`SELECT * FROM raw_mails where address = ? `
@@ -233,47 +305,49 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
).bind(
queryAddress, mailIndex
).first<{ raw: string, id: string, created_at: string }>() || {};
const { mail } = raw ? await parseMail({ rawEmail: raw }, queryAddress, created_at) : { mail: "已经没有邮件了" };
const { mail } = raw ? await parseMail(msgs, { rawEmail: raw }, queryAddress, created_at) : { mail: msgs.TgNoMoreMailsMsg };
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
const miniAppButtons = []
if (settings?.miniAppUrl && settings?.miniAppUrl?.length > 0 && mailId) {
const url = new URL(settings.miniAppUrl);
url.pathname = "/telegram_mail"
url.searchParams.set("mail_id", mailId);
miniAppButtons.push(Markup.button.webApp("查看邮件", url.toString()));
miniAppButtons.push(Markup.button.webApp(msgs.TgViewMailBtnMsg, url.toString()));
}
if (edit) {
return await ctx.editMessageText(mail || "无邮件",
return await ctx.editMessageText(mail || msgs.TgNoMailMsg,
{
...Markup.inlineKeyboard([
Markup.button.callback("上一条", `mail_${queryAddress}_${mailIndex - 1}`, mailIndex <= 0),
Markup.button.callback(msgs.TgPrevBtnMsg, `mail_${queryAddress}_${mailIndex - 1}`, mailIndex <= 0),
...miniAppButtons,
Markup.button.callback("下一条", `mail_${queryAddress}_${mailIndex + 1}`, !raw),
Markup.button.callback(msgs.TgNextBtnMsg, `mail_${queryAddress}_${mailIndex + 1}`, !raw),
])
},
);
}
return await ctx.reply(mail || "无邮件",
return await ctx.reply(mail || msgs.TgNoMailMsg,
{
...Markup.inlineKeyboard([
Markup.button.callback("上一条", `mail_${queryAddress}_${mailIndex - 1}`, mailIndex <= 0),
Markup.button.callback(msgs.TgPrevBtnMsg, `mail_${queryAddress}_${mailIndex - 1}`, mailIndex <= 0),
...miniAppButtons,
Markup.button.callback("下一条", `mail_${queryAddress}_${mailIndex + 1}`, !raw),
Markup.button.callback(msgs.TgNextBtnMsg, `mail_${queryAddress}_${mailIndex + 1}`, !raw),
])
},
);
}
bot.command("mails", async ctx => {
const msgs = await getTgMessages(c, ctx);
try {
const queryAddress = ctx?.message?.text.slice("/mails".length).trim();
return await queryMail(ctx, queryAddress, 0, false);
} catch (e) {
return await ctx.reply(`获取邮件失败: ${(e as Error).message}`);
return await ctx.reply(`${msgs.TgGetMailFailedMsg} ${(e as Error).message}`);
}
});
bot.on(callbackQuery("data"), async ctx => {
const msgs = await getTgMessages(c, ctx);
// Use ctx.callbackQuery.data
try {
const data = ctx.callbackQuery.data;
@@ -283,8 +357,8 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
}
}
catch (e) {
console.log(`获取邮件失败: ${(e as Error).message}`, e);
return await ctx.answerCbQuery(`获取邮件失败: ${(e as Error).message}`);
console.log(`${msgs.TgGetMailFailedMsg} ${(e as Error).message}`, e);
return await ctx.answerCbQuery(`${msgs.TgGetMailFailedMsg} ${(e as Error).message}`);
}
await ctx.answerCbQuery();
});
@@ -293,11 +367,12 @@ export function newTelegramBot(c: Context<HonoCustomType>, token: string): Teleg
}
export async function initTelegramBotCommands(bot: Telegraf) {
await bot.telegram.setMyCommands(COMMANDS);
export async function initTelegramBotCommands(c: Context<HonoCustomType>, bot: Telegraf) {
await bot.telegram.setMyCommands(getTelegramCommands(c));
}
const parseMail = async (
msgs: LocaleMessages,
parsedEmailContext: ParsedEmailContext,
address: string, created_at: string | undefined | null
) => {
@@ -308,20 +383,20 @@ const parseMail = async (
const parsedEmail = await commonParseMail(parsedEmailContext);
let parsedText = parsedEmail?.text || "";
if (parsedText.length && parsedText.length > 1000) {
parsedText = parsedEmail?.text.substring(0, 1000) + "\n\n...\n消息过长请到miniapp查看";
parsedText = parsedEmail?.text.substring(0, 1000) + `\n\n...\n${msgs.TgMsgTooLongMsg}`;
}
return {
isHtml: false,
mail: `From: ${parsedEmail?.sender || "无发件人"}\n`
mail: `From: ${parsedEmail?.sender || msgs.TgNoSenderMsg}\n`
+ `To: ${address}\n`
+ (created_at ? `Date: ${created_at}\n` : "")
+ `Subject: ${parsedEmail?.subject}\n`
+ `Content:\n${parsedText || "解析失败,请打开 mini app 查看"}`
+ `Content:\n${parsedText || msgs.TgParseFailedViewInAppMsg}`
};
} catch (e) {
return {
isHtml: false,
mail: `解析邮件失败: ${(e as Error).message}`
mail: `${msgs.TgParseMailFailedMsg} ${(e as Error).message}`
};
}
}
@@ -336,10 +411,6 @@ export async function sendMailToTelegram(
return;
}
const userId = await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`);
const { mail } = await parseMail(parsedEmailContext, address, new Date().toUTCString());
if (!mail) {
return;
}
const settings = await c.env.KV.get<TelegramSettings>(CONSTANTS.TG_KV_SETTINGS_KEY, "json");
const globalPush = settings?.enableGlobalMailPush && settings?.globalMailPushList;
if (!userId && !globalPush) {
@@ -349,28 +420,31 @@ export async function sendMailToTelegram(
`SELECT id FROM raw_mails where address = ? and message_id = ?`
).bind(address, message_id).first<string>("id");
const bot = newTelegramBot(c, c.env.TELEGRAM_BOT_TOKEN);
const miniAppButtons = []
if (settings?.miniAppUrl && settings?.miniAppUrl?.length > 0 && mailId) {
const url = new URL(settings.miniAppUrl);
url.pathname = "/telegram_mail"
url.searchParams.set("mail_id", mailId);
miniAppButtons.push(Markup.button.webApp("查看邮件", url.toString()));
}
const buildAndSend = async (targetUserId: string, msgs: LocaleMessages) => {
const { mail } = await parseMail(msgs, parsedEmailContext, address, new Date().toUTCString());
if (!mail) return;
const buttons = [];
if (settings?.miniAppUrl && mailId) {
const url = new URL(settings.miniAppUrl);
url.pathname = "/telegram_mail"
url.searchParams.set("mail_id", mailId);
buttons.push(Markup.button.webApp(msgs.TgViewMailBtnMsg, url.toString()));
}
await bot.telegram.sendMessage(targetUserId, mail, {
...Markup.inlineKeyboard([...buttons])
});
};
if (globalPush) {
const globalMsgs = i18n.getMessages(c.env.DEFAULT_LANG || 'zh');
for (const pushId of settings.globalMailPushList) {
await bot.telegram.sendMessage(pushId, mail, {
...Markup.inlineKeyboard([
...miniAppButtons,
])
});
await buildAndSend(pushId, globalMsgs);
}
}
if (!userId) {
return;
if (userId) {
const userMsgs = await getTgMessages(c, undefined, userId);
await buildAndSend(userId, userMsgs);
}
await bot.telegram.sendMessage(userId, mail, {
...Markup.inlineKeyboard([
...miniAppButtons,
])
});
}

View File

@@ -84,6 +84,7 @@ type Bindings = {
TELEGRAM_BOT_TOKEN: string
TG_MAX_ADDRESS: number | undefined
TG_BOT_INFO: string | object | undefined
TG_ALLOW_USER_LANG: string | boolean | undefined
// webhook config
FRONTEND_URL: string | undefined
@@ -144,4 +145,7 @@ type ParsedEmailContext = {
type SubdomainForwardAddressList = {
domains: string[] | undefined | null,
forward: string,
// 来源地址正则匹配 (可选,兼容原配置)
sourcePatterns?: string[] | undefined | null, // 来源地址正则表达式列表
sourceMatchMode?: 'any' | 'all' | undefined, // 匹配模式: any-任一匹配, all-全部匹配
}

View File

@@ -32,22 +32,23 @@ const UserBindAddressModule = {
c: Context<HonoCustomType>,
user_id: number | string, address_id: number | string
) => {
const msgs = i18n.getMessagesbyContext(c);
if (!address_id || !user_id) {
return c.text("No address or user token", 400)
return c.text(msgs.NoAddressOrUserTokenMsg, 400)
}
// check if address exists
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ?`
).bind(address_id).first("id");
if (!db_address_id) {
return c.text("Address not found", 400)
return c.text(msgs.AddressNotFoundMsg, 400)
}
// check if user exists
const db_user_id = await c.env.DB.prepare(
`SELECT id FROM users where id = ?`
).bind(user_id).first("id");
if (!db_user_id) {
return c.text("User not found", 400)
return c.text(msgs.UserNotFoundMsg, 400)
}
// check if binded
const db_user_address_id = await c.env.DB.prepare(
@@ -66,7 +67,7 @@ const UserBindAddressModule = {
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
).bind(user_id).first<{ count: number }>() || { count: 0 };
if (count >= maxAddressCount) {
return c.text("Max address count reached", 400)
return c.text(msgs.MaxAddressCountReachedMsg, 400)
}
}
// bind
@@ -75,36 +76,37 @@ const UserBindAddressModule = {
`INSERT INTO users_address (user_id, address_id) VALUES (?, ?)`
).bind(user_id, address_id).run();
if (!success) {
return c.text("Failed to bind", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
} catch (e) {
const error = e as Error;
if (error.message && error.message.includes("UNIQUE")) {
return c.text("Address already binded, please unbind first", 400)
return c.text(msgs.AddressAlreadyBindedMsg, 400)
}
return c.text("Failed to bind", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({ success: true })
},
unbind: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { user_id } = c.get("userPayload");
const { address_id } = await c.req.json();
if (!address_id || !user_id) {
return c.text("Invalid address or user token", 400)
return c.text(msgs.InvalidAddressOrUserTokenMsg, 400)
}
// check if address exists
const db_address_id = await c.env.DB.prepare(
`SELECT id FROM address where id = ?`
).bind(address_id).first("id");
if (!db_address_id) {
return c.text("Address not found", 400)
return c.text(msgs.AddressNotFoundMsg, 400)
}
// check if user exists
const db_user_id = await c.env.DB.prepare(
`SELECT id FROM users where id = ?`
).bind(user_id).first("id");
if (!db_user_id) {
return c.text("User not found", 400)
return c.text(msgs.UserNotFoundMsg, 400)
}
// unbind
try {
@@ -112,10 +114,10 @@ const UserBindAddressModule = {
`DELETE FROM users_address where user_id = ? and address_id = ?`
).bind(user_id, address_id).run();
if (!success) {
return c.text("Failed to unbind", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
} catch (e) {
return c.text("Failed to unbind", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({ success: true })
},
@@ -167,18 +169,19 @@ const UserBindAddressModule = {
return results || [];
},
getBindedAddressJwt: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { address_id } = c.req.param();
// check binded
const { user_id } = c.get("userPayload");
if (!address_id || !user_id) {
return c.text("Invalid address or user token", 400)
return c.text(msgs.InvalidAddressOrUserTokenMsg, 400)
}
// check users_address if address binded
const db_user_id = await c.env.DB.prepare(
`SELECT user_id FROM users_address WHERE address_id = ? and user_id = ?`
).bind(address_id, user_id).first("user_id");
if (!db_user_id) {
return c.text("Address not binded", 400)
return c.text(msgs.AddressNotBindedMsg, 400)
}
// generate jwt
const name = await c.env.DB.prepare(
@@ -193,6 +196,7 @@ const UserBindAddressModule = {
})
},
transferAddress: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { user_id } = c.get("userPayload");
const { address_id, target_user_email } = await c.req.json();
// check if address exists
@@ -200,21 +204,21 @@ const UserBindAddressModule = {
`SELECT name FROM address where id = ?`
).bind(address_id).first<string>("name");
if (!address) {
return c.text("Address not found", 400)
return c.text(msgs.AddressNotFoundMsg, 400)
}
// check if user exists
const db_user_id = await c.env.DB.prepare(
`SELECT id FROM users where id = ?`
).bind(user_id).first("id");
if (!db_user_id) {
return c.text("User not found", 400)
return c.text(msgs.UserNotFoundMsg, 400)
}
// check if target user exists
const target_user_id = await c.env.DB.prepare(
`SELECT id FROM users where user_email = ?`
).bind(target_user_email).first<number>("id");
if (!target_user_id) {
return c.text("Target user not found", 400)
return c.text(msgs.TargetUserNotFoundMsg, 400)
}
// check target user binded address count
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
@@ -228,14 +232,14 @@ const UserBindAddressModule = {
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
).bind(target_user_id).first<{ count: number }>() || { count: 0 };
if (count >= maxAddressCount) {
return c.text("Target User Max address count reached", 400)
return c.text(msgs.MaxAddressCountReachedMsg, 400)
}
}
// check if binded
const db_user_address_id = await c.env.DB.prepare(
`SELECT user_id FROM users_address where user_id = ? and address_id = ?`
).bind(user_id, address_id).first("user_id");
if (!db_user_address_id) return c.text("Address not binded", 400)
if (!db_user_address_id) return c.text(msgs.AddressNotBindedMsg, 400)
// unbind telegram address
await unbindTelegramByAddress(c, address);
// unbind user address
@@ -244,10 +248,10 @@ const UserBindAddressModule = {
`DELETE FROM users_address where user_id = ? and address_id = ?`
).bind(user_id, address_id).run();
if (!success) {
return c.text("Failed to unbind", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
} catch (e) {
return c.text("Failed to unbind user", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
// delete address
await c.env.DB.prepare(
@@ -258,7 +262,7 @@ const UserBindAddressModule = {
`INSERT INTO address(name) VALUES(?)`
).bind(address).run();
if (!newAddressSuccess) {
throw new Error("Failed to create address")
throw new Error(msgs.FailedCreateAddressMsg)
}
await updateAddressUpdatedAt(c, address);
// find new address id
@@ -266,7 +270,7 @@ const UserBindAddressModule = {
`SELECT id FROM address WHERE name = ?`
).bind(address).first<number | null | undefined>("id");
if (!new_address_id) {
throw new Error("Failed to find new address id")
throw new Error(msgs.OperationFailedMsg)
}
// bind
try {
@@ -274,14 +278,14 @@ const UserBindAddressModule = {
`INSERT INTO users_address (user_id, address_id) VALUES (?, ?)`
).bind(target_user_id, new_address_id).run();
if (!success) {
return c.text("Failed to bind", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
} catch (e) {
const error = e as Error;
if (error.message && error.message.includes("UNIQUE")) {
return c.text("Address already binded, please unbind first", 400)
return c.text(msgs.AddressAlreadyBindedMsg, 400)
}
return c.text("Failed to bind", 500)
return c.text(msgs.OperationFailedMsg, 500)
}
return c.json({ success: true })
}

View File

@@ -11,8 +11,7 @@ export default {
getOauth2LoginUrl: async (c: Context<HonoCustomType>) => {
const settings = await getJsonSetting<UserOauth2Settings[]>(c, CONSTANTS.OAUTH2_SETTINGS_KEY);
const { clientID, state } = c.req.query();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
const setting = settings?.find(s => s.clientID === clientID);
if (!setting) {
return c.text(msgs.Oauth2ClientIDNotFoundMsg, 400);
@@ -22,8 +21,7 @@ export default {
},
oauth2Login: async (c: Context<HonoCustomType>) => {
const { clientID, code } = await c.req.json<{ clientID?: string, code?: string }>();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (!clientID || !code) {
return c.text(msgs.Oauth2CliendIDOrCodeMissingMsg, 400);
}

View File

@@ -10,6 +10,7 @@ import {
import { Passkey } from '../models';
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
import i18n from '../i18n';
export default {
getPassKeys: async (c: Context<HonoCustomType>) => {
@@ -20,10 +21,11 @@ export default {
return c.json(results);
},
renamePassKey: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const user = c.get("userPayload");
const { passkey_id, passkey_name } = await c.req.json();
if (!passkey_name || passkey_name.length > 255) {
return c.text("Invalid passkey name", 400);
return c.text(msgs.InvalidPasskeyNameMsg, 400);
}
const { success } = await c.env.DB.prepare(
`UPDATE user_passkeys SET passkey_name = ? WHERE user_id = ? AND passkey_id = ?`
@@ -71,6 +73,7 @@ export default {
return c.json(options);
},
registerResponse: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const user = c.get("userPayload");
const { credential, origin, passkey_name } = await c.req.json();
// Verify the registration response
@@ -90,7 +93,7 @@ export default {
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return c.text("Registration failed", 400);
return c.text(msgs.RegistrationFailedMsg, 400);
}
const {
@@ -131,10 +134,11 @@ export default {
return c.json(options);
},
authenticateResponse: async (c: Context<HonoCustomType>) => {
const msgs = i18n.getMessagesbyContext(c);
const { domain, credential, origin } = await c.req.json();
const passkey_id = credential?.id;
if (!passkey_id) {
return c.text("Invalid request", 400);
return c.text(msgs.InvalidInputMsg, 400);
}
const { user_id, counter, passkey } = await c.env.DB.prepare(
`SELECT user_id, counter, passkey FROM user_passkeys WHERE passkey_id = ?`
@@ -142,7 +146,7 @@ export default {
counter: number; passkey: string; user_id: number;
}>() || {};
if (!passkey) {
return c.text("Passkey not found", 404);
return c.text(msgs.PasskeyNotFoundMsg, 404);
}
const passkeyData = JSON.parse(passkey) as Passkey;
// Verify the registration response
@@ -166,7 +170,7 @@ export default {
});
const { verified, authenticationInfo } = verification;
if (!verified) {
return c.text("Authentication failed", 400);
return c.text(msgs.AuthenticationFailedMsg, 400);
}
if (authenticationInfo) {
@@ -186,7 +190,7 @@ export default {
`SELECT user_email FROM users WHERE id = ?`
).bind(user_id).first<{ user_email: string }>() || {};
if (!user_email) {
return c.text("User not found", 404);
return c.text(msgs.UserNotFoundMsg, 404);
}
// create jwt
const jwt = await Jwt.sign({

View File

@@ -31,8 +31,7 @@ export default {
},
settings: async (c: Context<HonoCustomType>) => {
const user = c.get("userPayload");
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
// check if user exists
const db_user_id = await c.env.DB.prepare(
`SELECT id FROM users where id = ?`

View File

@@ -10,8 +10,7 @@ import { sendMail } from "../mails_api/send_mail_api";
export default {
verifyCode: async (c: Context<HonoCustomType>) => {
const { email, cf_token } = await c.req.json();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
// check cf turnstile
try {
await checkCfTurnstile(c, cf_token);
@@ -61,8 +60,7 @@ export default {
register: async (c: Context<HonoCustomType>) => {
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value)
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
// check enable
if (!settings.enable) {
return c.text(msgs.UserRegistrationDisabledMsg, 403);
@@ -154,8 +152,7 @@ export default {
},
login: async (c: Context<HonoCustomType>) => {
const { email, password } = await c.req.json();
const lang = c.get("lang") || c.env.DEFAULT_LANG;
const msgs = i18n.getMessages(lang);
const msgs = i18n.getMessagesbyContext(c);
if (!email || !password) return c.text(msgs.InvalidEmailOrPasswordMsg, 400);
const { id: user_id, password: dbPassword } = await c.env.DB.prepare(
`SELECT id, password FROM users where user_email = ?`

View File

@@ -5,22 +5,20 @@ import UserBindAddressModule from "./bind_address";
export default {
getMails: async (c: Context<HonoCustomType>) => {
const { user_id } = c.get("userPayload");
const { address, limit, offset, keyword } = c.req.query();
const { address, limit, offset } = c.req.query();
const bindedAddressList = await UserBindAddressModule.getBindedAddressListById(c, user_id);
const addressList = address ? bindedAddressList.filter((item) => item == address) : bindedAddressList;
const addressQuery = `address IN (${addressList.map(() => "?").join(",")})`;
const addressParams = addressList;
const keywordQuery = keyword ? `raw like ?` : "";
const keywordParams = keyword ? [`%${keyword}%`] : [];
// user must have at least one binded address to query mails
if (addressList.length <= 0) {
return c.json({ results: [], count: 0 });
}
const filterQuerys = [addressQuery, keywordQuery].filter((item) => item).join(" and ");
const filterQuerys = [addressQuery].filter((item) => item).join(" and ");
const finalQuery = filterQuerys.length > 0 ? `where ${filterQuerys}` : "";
const filterParams = [...addressParams, ...keywordParams]
const filterParams = [...addressParams]
return await handleListQuery(c,
`SELECT * FROM raw_mails ${finalQuery}`,
`SELECT count(*) as count FROM raw_mails ${finalQuery}`,

View File

@@ -52,7 +52,7 @@ app.use('/*', async (c, next) => {
// check header x-custom-auth
const passwords = getPasswords(c);
if (!c.req.path.startsWith("/open_api") && passwords && passwords.length > 0) {
if (!c.req.path.startsWith("/open_api") && !c.req.path.startsWith("/telegram/") && passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
if (!auth || !passwords.includes(auth)) {
return c.text(msgs.CustomAuthPasswordMsg, 401)

View File

@@ -2,6 +2,7 @@ name = "cloudflare_temp_email"
main = "src/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
# if you want use custom_domain, you need to add routes
# routes = [
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
@@ -86,6 +87,8 @@ ENABLE_AUTO_REPLY = false
# 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
# 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