Compare commits

...

180 Commits

Author SHA1 Message Date
Dream Hunter
898324777e docs: update CHANGELOG for v1.3.0 recent PRs (#828)
- Add OAuth2 SVG icon support (#825)
- Add async address activity update (#826)
- Add hide send mail UI when not configured (#827)

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

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

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

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

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

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

* chore: update lock files and version

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

* fix: restore accessTokenFormat as optional field

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

---------

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

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

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

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

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

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

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

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

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

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

* docs: update changelog for address UI

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

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

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

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

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

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

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

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

* fix: improve Telegram bot language preference feature

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: update dependencies

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

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

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

* docs: update CHANGELOG for empty address cleanup feature

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: upgrade dependencies

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

* Update MailBox.vue

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

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

* feat: remove service worker
2024-11-15 01:10:25 +08:00
Dream Hunter
c102004f4d feat: |UI| show local datetime string and add useUTCDate option (#483) 2024-11-15 00:04:17 +08:00
Dream Hunter
3c81e05a2f feat: |UI| random fake name support MAX_ADDRESS_LEN (#482) 2024-11-14 23:58:42 +08:00
Dream Hunter
5ff2ceb5e8 feat: pages support Cloudflare Zero Trust (#477) 2024-11-11 23:55:49 +08:00
Dream Hunter
6c82efb738 feat: docs: ui_install worker update (#476) 2024-11-08 13:09:28 +08:00
235 changed files with 18957 additions and 7712 deletions

3
.flake8 Normal file
View File

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

View File

@@ -0,0 +1,44 @@
diff --git a/worker/src/common.ts b/worker/src/common.ts
index bd9bcc9..e7e2748 100644
--- a/worker/src/common.ts
+++ b/worker/src/common.ts
@@ -273,23 +273,23 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
}
const raw_mail = parsedEmailContext.rawEmail;
// TODO: WASM parse email
- // try {
- // const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
+ try {
+ const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
- // const parsedEmail = parse_message_wrapper(raw_mail);
- // parsedEmailContext.parsedEmail = {
- // sender: parsedEmail.sender || "",
- // subject: parsedEmail.subject || "",
- // text: parsedEmail.text || "",
- // headers: parsedEmail.headers?.map(
- // (header) => ({ key: header.key, value: header.value })
- // ) || [],
- // html: parsedEmail.body_html || "",
- // };
- // return parsedEmailContext.parsedEmail;
- // } catch (e) {
- // console.error("Failed use mail-parser-wasm-worker to parse email", e);
- // }
+ const parsedEmail = parse_message_wrapper(raw_mail);
+ parsedEmailContext.parsedEmail = {
+ sender: parsedEmail.sender || "",
+ subject: parsedEmail.subject || "",
+ text: parsedEmail.text || "",
+ headers: parsedEmail.headers?.map(
+ (header) => ({ key: header.key, value: header.value })
+ ) || [],
+ html: parsedEmail.body_html || "",
+ };
+ return parsedEmailContext.parsedEmail;
+ } catch (e) {
+ console.error("Failed use mail-parser-wasm-worker to parse email", e);
+ }
try {
const { default: PostalMime } = await import('postal-mime');
const parsedEmail = await PostalMime.parse(raw_mail);

View File

@@ -1,6 +1,9 @@
name: Deploy Backend Production
name: Deploy Backend
on:
workflow_run:
workflows: [Upstream Sync]
types: [completed]
push:
tags:
- "*"
@@ -18,7 +21,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v3
name: Install pnpm
@@ -29,14 +32,43 @@ jobs:
- name: Deploy Backend for ${{ github.ref_name }}
run: |
export use_worker_assets=${{ secrets.USE_WORKER_ASSETS }}
export use_worker_assets_with_telegram=${{ secrets.USE_WORKER_ASSETS_WITH_TELEGRAM }}
if [ -n "$use_worker_assets" ]; then
cd frontend/
pnpm install --no-frozen-lockfile
if [ -n "$use_worker_assets_with_telegram" ]; then
echo "Building with telegram pages"
pnpm build:telegram:pages
else
echo "Building with normal pages"
pnpm build:pages
fi
cd ..
fi
export debug_mode=${{ secrets.DEBUG_MODE }}
export use_mail_wasm_parser=${{ secrets.BACKEND_USE_MAIL_WASM_PARSER }}
cd worker/
echo '${{ secrets.BACKEND_TOML }}' > wrangler.toml
pnpm install --no-frozen-lockfile
output=$(pnpm run deploy 2>&1)
if [ $? -ne 0 ]; then
code=$?
echo "Command failed with exit code $code"
exit $code
if [ -n "$use_mail_wasm_parser" ]; then
echo "Using mail-parser-wasm-worker"
pnpm add mail-parser-wasm-worker
git apply ../.github/config/mail-parser-wasm-worker.patch
echo "Applied mail-parser-wasm-worker patch"
fi
if [ "$debug_mode" = "true" ]; then
pnpm run deploy
else
output=$(pnpm run deploy 2>&1)
if [ $? -ne 0 ]; then
code=$?
echo "Command failed with exit code $code"
exit $code
fi
fi
echo "Deployed for tag ${{ github.ref_name }}"
env:

View File

@@ -22,7 +22,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v3
name: Install pnpm

View File

@@ -1,9 +1,10 @@
name: Deploy Frontend
on:
workflow_run:
workflows: [Upstream Sync]
types: [completed]
push:
paths:
- "frontend/**"
tags:
- "*"
workflow_dispatch:
@@ -20,7 +21,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v3
name: Install pnpm
@@ -38,12 +39,12 @@ jobs:
export frontend_branch=${{ secrets.FRONTEND_BRANCH }}
if [ -n "$frontend_branch" ]; then
echo "Deploying branch $frontend_branch"
pnpm run deploy:actions --project-name=$project_name
pnpm run deploy:actions --project-name=$project_name --branch $frontend_branch
else
echo "Deploying branch prodcution"
echo "Deploying branch production"
pnpm run deploy --project-name=$project_name
fi
echo "Deploying prodcution for ${{ github.ref_name }}"
echo "Deploying production for ${{ github.ref_name }}"
echo "Deployed for tag ${{ github.ref_name }}"
export tg_mini_app_project_name=${{ secrets.TG_FRONTEND_NAME }}
@@ -51,9 +52,9 @@ jobs:
echo "Deploying telegram mini app $tg_mini_app_project_name"
if [ -n "$frontend_branch" ]; then
echo "Deploying telegram mini app branch $frontend_branch"
pnpm run deploy:actions:telegram --project-name=$tg_mini_app_project_name
pnpm run deploy:actions:telegram --project-name=$tg_mini_app_project_name --branch $frontend_branch
else
echo "Deploying telegram mini app branch prodcution"
echo "Deploying telegram mini app branch production"
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
fi
echo "Deployed telegram mini app for ${{ github.ref_name }}"

View File

@@ -15,7 +15,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v3
name: Install pnpm

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: Codium-ai/pr-agent@main
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

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

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

View File

@@ -17,7 +17,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v3
name: Install pnpm
@@ -44,10 +44,24 @@ jobs:
- name: Build Backend
run: cd worker && pnpm install --no-frozen-lockfile && pnpm build
- name: Move worker.js
run: cd worker/dist && mv worker.js ../
- name: Build Worker with wasm mail parser
run: |
cd worker
echo "Using mail-parser-wasm-worker"
pnpm add mail-parser-wasm-worker
git apply ../.github/config/mail-parser-wasm-worker.patch
echo "Applied mail-parser-wasm-worker patch"
pnpm build
zip -r worker-with-wasm-mail-parser.zip dist/worker.js dist/*.wasm
- name: Upload to Release
uses: softprops/action-gh-release@v2
with:
files: |
frontend/frontend.zip
frontend/telegram-frontend.zip
worker/dist/worker.js
worker/worker.js
worker/worker-with-wasm-mail-parser.zip

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.DS_Store
dist/
test/
.vscode/

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,8 +1,207 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
<!-- 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.3.0(main)
### Features
- feat: |OAuth2| 新增 OAuth2 邮箱格式转换功能,支持通过正则表达式转换第三方登录返回的邮箱格式(如将 `user@domain` 转换为 `user@custom.domain`
- feat: |OAuth2| 新增 OAuth2 提供商 SVG 图标支持,管理员可为登录按钮配置自定义图标,预置 GitHub、Linux Do、Authentik 模板图标
- feat: |发送邮件| 未配置发送邮件功能时自动隐藏发送邮件 tab、发件箱 tab 和回复按钮
### Bug Fixes
- fix: |用户地址| 修复禁止匿名创建时,已登录用户地址数量限制检查失效的问题,新增公共函数 `isAddressCountLimitReached` 统一处理地址数量限制逻辑
### Improvements
- refactor: |代码重构| 提取地址数量限制检查为公共函数,优化代码复用性
- perf: |性能优化| GET 请求中的地址活动时间更新改为异步执行,使用 `waitUntil` 不阻塞响应
## v1.2.1
### Bug Fixes
- fix: |定时任务| 修复定时任务清理报错 `e.get is not a function`,使用可选链安全访问 Context 方法
### Improvements
- style: |AI 提取| 暗色模式下 AI 提取信息使用更柔和的蓝色 (#A8C7FA),减少视觉疲劳
## v1.2.0
### Breaking Changes
- |数据库| 新增 `source_meta` 字段,需执行 `db/2025-12-27-source-meta.sql` 更新数据库或到 admin 维护页面点击数据库更新按钮
### Features
- feat: |Admin| 新增管理员账号页面,显示当前登录方式并支持退出登录(仅限密码登录方式)
- fix: |GitHub Actions| 修复容器镜像名需要全部小写的问题
- feat: |邮件转发| 新增来源地址正则转发功能,支持按发件人地址过滤转发,完全向后兼容
- feat: |地址来源| 新增地址来源追踪功能记录地址创建来源Web 记录 IPTelegram 记录用户 IDAdmin 后台标记)
- feat: |邮件过滤| 移除后端 keyword 参数,改为前端过滤当前页邮件,优化查询性能
- feat: |前端| 地址切换统一为下拉组件,极简模式支持切换,主页提供地址管理入口
- feat: |数据库| 为 `message_id` 字段添加索引,优化邮件更新操作性能,需执行 `db/2025-12-15-message-id-index.sql` 更新数据库
- feat: |Admin| 维护页面增加自定义 SQL 清理功能,支持定时任务执行自定义清理语句
- feat: |国际化| 后端 API 错误消息全面支持中英文国际化
- feat: |Telegram| 机器人支持中英文切换,新增 `/lang` 命令设置语言偏好
## v1.1.0
- feat: |AI 提取| 增加 AI 邮件识别功能,使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
- 支持优先级提取:验证码 > 认证链接 > 服务链接 > 订阅链接 > 其他链接
- 管理员可配置地址白名单(支持通配符,如 `*@example.com`
- 前端列表和详情页展示提取结果
- 需要配置 `ENABLE_AI_EMAIL_EXTRACT` 环境变量和 AI 绑定
- 需要执行 `db/2025-12-06-metadata.sql` 文件中的 SQL 更新 `D1` 数据库 或者到 admin维护页面点击数据库更新按钮
- feat: |Admin| 维护页面增加清理 n 天前空邮件的邮箱地址功能
- fix: 修复自定义认证密码功能异常的问题 (前端属性名错误 & /open_api 接口被拦截)
## v1.0.7
- feat: |Admin| 新增 IP 黑名单功能,用于限制访问频率较高的 API
- feat: |Admin| 新增 ASN 组织黑名单功能,支持基于 ASN 组织名称过滤请求(支持文本匹配和正则表达式)
- feat: |Admin| 新增浏览器指纹黑名单功能,支持基于浏览器指纹过滤请求(支持精确匹配和正则表达式)
## v1.0.6
- feat: |DB| update db schema add index
- feat: |地址密码| 增加地址密码登录功能, 通过 `ENABLE_ADDRESS_PASSWORD` 配置启用, 需要执行 `db/2025-09-23-patch.sql` 文件中的 SQL 更新 `D1` 数据库
- fix: |GitHub Actions| 修复 debug 模式配置,仅当 DEBUG_MODE 为 'true' 时才启用调试模式
- feat: |Admin| 账户管理页面新增多选批量操作功能(批量删除、批量清空收件箱、批量清空发件箱)
- feat: |Admin| 维护页面增加清理未绑定用户地址的功能
- feat: 支持针对角色配置不同的绑定地址数量上限, 可在 admin 页面配置
## v1.0.5
- feat: 新增 `DISABLE_CUSTOM_ADDRESS_NAME` 配置: 禁用自定义邮箱地址名称功能
- feat: 新增 `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` 配置: 创建地址时优先使用第一个域名
- feat: |UI| 主页增加进入极简模式按钮
- feat: |Webhook| 增加白名单开关功能,支持灵活控制访问权限
## v1.0.4
- feat: |UI| 优化极简模式主页, 增加全部邮件页面功能(删除/下载/附件/...), 可在 `外观` 中切换
- feat: admin 账号设置页面增加 `邮件转发规则` 配置
- feat: admin 账号设置页面增加 `禁止接收未知地址邮件` 配置
- feat: 邮件页面增加 上一封/下一封 按钮
## v1.0.3
- fix: 修复 github actions 部署问题
- feat: telegram /new 不指定域名时, 使用随机地址
## v1.0.2
- fix: 修复 oauth2 登录失败的问题
## v1.0.1
- feat: |UI| 增加极简模式主页, 可在 `外观` 中切换
- fix: 修复 oauth2 登录时default role 不生效的问题
## v1.0.0
- fix: |UI| 修复 User 查看收件箱,不选择地址时,关键词查询不生效
- fix: 修复自动清理任务,时间为 0 时不生效的问题
- feat: 清理功能增加 创建 n 天前地址清理n 天前未活跃地址清理
- fix: |IMAP Proxy| 修复 IMAP Proxy 服务器,无法查看新邮件的问题
## v0.10.0
- feat: 支持 User 查看收件箱,`/user_api/mails` 接口, 支持 `address``keyword` 过滤
- fix: 修复 Oauth2 登录获取 Token 时,一些 Oauth2 需要 `redirect_uri` 参数的问题
- feat: 用户访问网页时,如果 `user token` 在 7 天内过期,自动刷新
- feat: admin portal 中增加初始化 db 的功能
- feat: 增加 `ALWAYS_SHOW_ANNOUNCEMENT` 变量,用于配置是否总是显示公告
## v0.9.1
- feat: |UI| support google ads
- feat: |UI| 使用 shadow DOM 防止样式污染
- feat: |UI| 支持 URL jwt 参数自动登录邮箱jwt 参数会覆盖浏览器中的 jwt
- fix: |CleanUP| 修复清理邮件时,清理时间超过 30 天报错的 bug
- feat: admin 用户管理页面: 增加 用户地址查看功能
- feat: | S3 附件| 增加 S3 附件删除功能
- feat: | Admin API| 增加 admin 绑定用户和地址的 api
- feat: | Oauth2 | Oatuh2 获取用户信息时,支持 `JSONPATH` 表达式
## v0.9.0
- feat: | Worker | 支持多语言
- feat: | Worker | `NO_LIMIT_SEND_ROLE` 配置支持多角色, 逗号分割
- feat: | Actions | build 里增加 `worker-with-wasm-mail-parser.zip` 支持 UI 部署带 `wasm` 的 worker
## v0.8.7
- fix: |UI| 修复移动设备日期显示问题
- feat: |Worker| 支持通过 `SMTP` 发送邮件, 使用 [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
## v0.8.6
- feat: |UI| 公告支持 html 格式
- feat: |UI| `COPYRIGHT` 支持 html 格式
- feat: |Doc| 优化部署文档,补充了 `Github Actions 部署文档`,增加了 `Worker 变量说明`
## v0.8.5
- feat: |mail-parser-wasm-worker| 修复 `initSync` 函数调用时的 `deprecated` 参数警告
- feat: rpc headers covert & typo (#559)
- fix: telegram mail page use iframe show email (#561)
- feat: |Worker| 增加 `REMOVE_ALL_ATTACHMENT``REMOVE_EXCEED_SIZE_ATTACHMENT` 用于移除邮件附件,由于是解析邮件的一些信息会丢失,比如图片等.
## v0.8.4
- fix: |UI| 修复 admin portal 无收件人邮箱删除调用api 错误
- feat: |Telegram Bot| 增加 telegram bot 清理无效地址凭证命令
- feat: 增加 worker 配置 `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` 禁用匿名用户创建邮箱地址,只允许登录用户创建邮箱地址
- feat: 增加 worker 配置 `ENABLE_ANOTHER_WORKER``ANOTHER_WORKER_LIST` ,用于调用其他 worker 的 rpc 接口 (#547)
- feat: |UI| 自动刷新配置保存到浏览器,可配置刷新间隔
- feat: 垃圾邮件检测增加存在时才检查的列表 `JUNK_MAIL_CHECK_LIST` 配置
- feat: | Worker | 增加 `ParsedEmailContext` 类用于缓存解析后的邮件内容,减少解析次数
- feat: |Github Action| Worker 部署增加 `DEBUG_MODE` 输出日志, `BACKEND_USE_MAIL_WASM_PARSER` 配置是否使用 wasm 解析邮件
## v0.8.3
- feat: |Github Action| 增加自动更新并部署功能
- feat: |UI| admin 用户设置,支持 oauth2 配置的删除
- feat: 增加垃圾邮件检测必须通过的列表 `JUNK_MAIL_FORCE_PASS_LIST` 配置
## v0.8.2
- fix: |Doc| 修复文档中的一些错误
- fix: |Github Action| 修复 frontend 部署分支错误的问题
- feat: admin 发送邮件功能
- feat: admin 后台,账号配置页面添加无限发送邮件的地址列表
## v0.8.1
- feat: |Doc| 更新 UI 安装的文档
- feat: |UI| 对用户隐藏邮箱账号的 ID
- feat: |UI| 增加邮件详情页的 `转发` 按钮
## v0.8.0
- feat: |UI| 随机生成地址时不超过最大长度
- feat: |UI| 邮件时间显示浏览器时区,可在设置中切换显示为 UTC 时间
- feat: 支持转移邮件到其他用户
## v0.7.6
### Breaking Changes
UI 部署 worker 需要点击 Settings -> Runtime, 修改 Compatibility flags, 增加 `nodejs_compat`
![worker-runtime](vitepress-docs/docs/public/ui_install/worker-runtime.png)
### Changes
- feat: 支持提前设置 bot info, 降低 telegram 回调延迟 (#441)
- feat: 增加 telegram mini app 的 build 压缩包
- feat: 增加是否启用垃圾邮件检查 `ENABLE_CHECK_JUNK_MAIL` 配置

605
CHANGELOG_EN.md Normal file
View File

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

214
README.md
View File

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

196
README_EN.md Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -4,20 +4,33 @@ CREATE TABLE IF NOT EXISTS raw_mails (
source TEXT,
address TEXT,
raw TEXT,
metadata TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY,
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
password TEXT,
source_meta TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);
CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY,
source_prefix TEXT,
@@ -50,6 +63,8 @@ CREATE TABLE IF NOT EXISTS sendbox (
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
CREATE INDEX IF NOT EXISTS idx_sendbox_created_at ON sendbox(created_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "0.7.6",
"version": "1.3.0",
"private": true,
"type": "module",
"scripts": {
@@ -8,7 +8,9 @@
"build": "vite build -m prod --emptyOutDir",
"build:release": "vite build -m example --emptyOutDir",
"build:pages": "vite build -m pages --emptyOutDir",
"build:pages:nopwa": "VITE_PWA_DISABLED=true vite build -m pages --emptyOutDir",
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
"build:telegram:pages": "VITE_IS_TELEGRAM=true vite build -m pages --emptyOutDir",
"build:telegram:release": "VITE_IS_TELEGRAM=true vite build -m example --emptyOutDir",
"preview": "vite preview",
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
@@ -18,33 +20,36 @@
"deploy:actions": "npm run build && wrangler pages deploy ./dist"
},
"dependencies": {
"@simplewebauthn/browser": "^10.0.0",
"@unhead/vue": "^1.11.11",
"@vicons/material": "^0.12.0",
"@vueuse/core": "^10.11.1",
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@simplewebauthn/browser": "10.0.0",
"@unhead/vue": "^2.1.2",
"@vueuse/core": "^14.2.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.7.7",
"axios": "^1.13.4",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.1.8",
"naive-ui": "^2.40.1",
"postal-mime": "^2.3.2",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.43.2",
"postal-mime": "^2.7.3",
"vooks": "^0.2.12",
"vue": "^3.5.12",
"vue": "^3.5.27",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^9.14.1",
"vue-router": "^4.4.5"
"vue-i18n": "^11.2.8",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vicons/fa": "^0.12.0",
"@vitejs/plugin-vue": "^5.1.4",
"unplugin-auto-import": "^0.18.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.10",
"vite-plugin-pwa": "^0.19.8",
"vite-plugin-top-level-await": "^1.4.4",
"vite-plugin-wasm": "^3.3.0",
"workbox-window": "^7.3.0",
"wrangler": "^3.84.1"
}
"@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0",
"@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.61.1"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

5902
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,10 @@
import { useGlobalState } from '../store'
import { h } from 'vue'
import axios from 'axios'
import i18n from '../i18n'
import { getFingerprint } from '../utils/fingerprint'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const {
loading, auth, jwt, settings, openSettings,
@@ -17,28 +21,31 @@ const instance = axios.create({
const apiFetch = async (path, options = {}) => {
loading.value = true;
try {
// Get browser fingerprint for request tracking
const fingerprint = await getFingerprint();
const response = await instance.request(path, {
method: options.method || 'GET',
data: options.body || null,
headers: {
'x-user-token': userJwt.value,
'x-lang': i18n.global.locale.value,
'x-user-token': options.userJwt || userJwt.value,
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'x-fingerprint': fingerprint,
'Authorization': `Bearer ${jwt.value}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true;
throw new Error("Unauthorized, your admin password is wrong")
}
if (response.status === 401 && openSettings.value.auth) {
if (response.status === 401 && openSettings.value.needAuth) {
showAuth.value = true;
throw new Error("Unauthorized, you access password is wrong")
}
if (response.status >= 300) {
throw new Error(`${response.status} ${response.data}` || "error");
throw new Error(`[${response.status}]: ${response.data}` || "error");
}
const data = response.data;
return data;
@@ -52,7 +59,7 @@ const apiFetch = async (path, options = {}) => {
}
}
const getOpenSettings = async (message) => {
const getOpenSettings = async (message, notification) => {
try {
const res = await api.fetch("/open_api/settings");
const domainLabels = res["domainLabels"] || [];
@@ -75,6 +82,8 @@ const getOpenSettings = async (message) => {
}),
adminContact: res["adminContact"] || "",
enableUserCreateEmail: res["enableUserCreateEmail"] || false,
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
disableCustomAddressName: res["disableCustomAddressName"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false,
@@ -82,16 +91,23 @@ const getOpenSettings = async (message) => {
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
enableWebhook: res["enableWebhook"] || false,
isS3Enabled: res["isS3Enabled"] || false,
enableAddressPassword: res["enableAddressPassword"] || false,
});
if (openSettings.value.needAuth) {
showAuth.value = true;
}
if (openSettings.value.announcement && openSettings.value.announcement != announcement.value) {
if (openSettings.value.announcement
&& !openSettings.value.fetched
&& (openSettings.value.announcement != announcement.value
|| openSettings.value.alwaysShowAnnouncement)
) {
announcement.value = openSettings.value.announcement;
message.info(announcement.value, {
showIcon: false,
duration: 0,
closable: true
notification.info({
content: () => {
return h("div", {
innerHTML: announcement.value
});
}
});
}
} catch (error) {
@@ -134,6 +150,19 @@ const getUserSettings = async (message) => {
if (!userJwt.value) return;
const res = await api.fetch("/user_api/settings")
Object.assign(userSettings.value, res)
// auto refresh user jwt
if (userSettings.value.new_user_token) {
try {
await api.fetch("/user_api/settings", {
userJwt: userSettings.value.new_user_token,
})
userJwt.value = userSettings.value.new_user_token;
console.log("User JWT updated successfully");
}
catch (error) {
console.error("Failed to update user JWT", error);
}
}
} catch (error) {
message?.error(error.message || "error");
} finally {

View File

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

View File

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

View File

@@ -1,11 +1,14 @@
<script setup>
import { watch, onMounted, ref, onBeforeUnmount } from "vue";
import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { CloudDownloadRound, ReplyFilled } from '@vicons/material'
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
import { processItem } from '../utils/email-parser'
import { utcToLocalDate } from '../utils';
import MailContentRenderer from "./MailContentRenderer.vue";
import AiExtractInfo from "./AiExtractInfo.vue";
const message = useMessage()
const isMobile = useIsMobile()
@@ -46,25 +49,87 @@ 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,
useIframeShowMail, sendMailModel, preferShowTextMail
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
autoRefresh, configAutoRefreshInterval, sendMailModel
} = useGlobalState()
const autoRefresh = ref(false)
const autoRefreshInterval = ref(30)
const data = ref([])
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
const rawData = ref([])
const timer = ref(null)
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
// Computed property for filtered data (only filter current page)
const data = computed(() => {
if (!localFilterKeyword.value || localFilterKeyword.value.trim() === '') {
return rawData.value;
}
const keyword = localFilterKeyword.value.toLowerCase();
return rawData.value.filter(mail => {
// Search in subject, text, message fields
const searchFields = [
mail.subject || '',
mail.text || '',
mail.message || ''
].map(field => field.toLowerCase());
return searchFields.some(field => field.includes(keyword));
});
})
const canGoPrevMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
return currentIndex > 0 || page.value > 1
})
const canGoNextMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
return currentIndex < data.value.length - 1 || count.value > page.value * pageSize.value
})
const prevMail = async () => {
if (!canGoPrevMail.value) return
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex > 0) {
curMail.value = data.value[currentIndex - 1]
} else if (page.value > 1) {
page.value--
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[data.value.length - 1]
}
}
}
const nextMail = async () => {
if (!canGoNextMail.value) return
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex < data.value.length - 1) {
curMail.value = data.value[currentIndex + 1]
} else if (count.value > page.value * pageSize.value) {
page.value++
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[0]
}
}
}
const curMail = ref(null);
const showTextMail = ref(preferShowTextMail.value)
const multiActionMode = ref(false)
const showMultiActionDownload = ref(false)
@@ -85,6 +150,7 @@ const { t } = useI18n({
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete mail?',
reply: 'Reply',
forwardMail: 'Forward',
showTextMail: 'Show Text Mail',
showHtmlMail: 'Show Html Mail',
saveToS3: 'Save to S3',
@@ -92,6 +158,10 @@ const { t } = useI18n({
cancelMultiAction: 'Cancel Multi Action',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
prevMail: 'Previous',
nextMail: 'Next',
keywordQueryTip: 'Filter current page',
query: 'Query',
},
zh: {
success: '成功',
@@ -104,6 +174,7 @@ const { t } = useI18n({
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
reply: '回复',
forwardMail: '转发',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件',
saveToS3: '保存到S3',
@@ -111,19 +182,25 @@ const { t } = useI18n({
cancelMultiAction: '取消多选',
selectAll: '全选本页',
unselectAll: '取消全选',
prevMail: '上一封',
nextMail: '下一封',
keywordQueryTip: '过滤当前页',
query: '查询',
}
}
});
const setupAutoRefresh = async (autoRefresh) => {
// auto refresh every 30 seconds
autoRefreshInterval.value = 30;
// auto refresh every configAutoRefreshInterval seconds
autoRefreshInterval.value = configAutoRefreshInterval.value;
if (autoRefresh) {
clearInterval(timer.value);
timer.value = setInterval(async () => {
if (loading.value) return;
autoRefreshInterval.value--;
if (autoRefreshInterval.value <= 0) {
autoRefreshInterval.value = 30;
await refresh();
autoRefreshInterval.value = configAutoRefreshInterval.value;
await backFirstPageAndRefresh();
}
}, 1000)
} else {
@@ -134,7 +211,7 @@ const setupAutoRefresh = async (autoRefresh) => {
watch(autoRefresh, async (autoRefresh, old) => {
setupAutoRefresh(autoRefresh)
})
}, { immediate: true })
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
@@ -148,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);
}));
@@ -167,6 +244,11 @@ const refresh = async () => {
}
};
const backFirstPageAndRefresh = async () => {
page.value = 1;
await refresh();
}
const clickRow = async (row) => {
if (multiActionMode.value) {
row.checked = !row.checked;
@@ -175,10 +257,6 @@ const clickRow = async (row) => {
curMail.value = row;
};
const getAttachments = (attachments) => {
curAttachments.value = attachments;
showAttachments.value = true;
};
const mailItemClass = (row) => {
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
@@ -214,18 +292,21 @@ const replyMail = async () => {
indexTab.value = 'sendmail';
};
const forwardMail = async () => {
Object.assign(sendMailModel.value, {
subject: `${t('forwardMail')}: ${curMail.value.subject}`,
contentType: curMail.value.message ? 'html' : 'text',
content: curMail.value.message || curMail.value.text,
});
indexTab.value = 'sendmail';
};
const onSpiltSizeChange = (size) => {
mailboxSplitSize.value = size;
}
const attachmentLoding = ref(false)
const saveToS3Proxy = async (filename, blob) => {
attachmentLoding.value = true
try {
await props.saveToS3(curMail.value.id, filename, blob);
} finally {
attachmentLoding.value = false
}
await props.saveToS3(curMail.value.id, filename, blob);
}
const multiActionModeClick = (enableMulti) => {
@@ -317,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>
@@ -340,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>
@@ -354,15 +435,18 @@ onBeforeUnmount(() => {
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" type="primary" tertiary>
<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"
:on-update:size="onSpiltSizeChange">
<template #1>
<div style="overflow: auto; height: 80vh;">
<div style="overflow: auto; min-height: 50vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
@@ -375,14 +459,19 @@ onBeforeUnmount(() => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ `${row.created_at} UTC` }}
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
<n-ellipsis style="max-width: 240px;">
{{ showEMailTo ? "FROM: " + row.source : row.source }}
</n-ellipsis>
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
<n-ellipsis style="max-width: 240px;">
TO: {{ row.address }}
</n-ellipsis>
</n-tag>
<AiExtractInfo :metadata="row.metadata" compact />
</template>
</n-thing>
</n-list-item>
@@ -390,53 +479,31 @@ onBeforeUnmount(() => {
</div>
</template>
<template #2>
<div v-if="curMail" style="margin: 8px;">
<n-flex justify="space-between">
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
<template #icon>
<n-icon>
<ArrowBackIosNewFilled />
</n-icon>
</template>
{{ t('prevMail') }}
</n-button>
<n-button @click="nextMail" :disabled="!canGoNextMail" text size="small" icon-placement="right">
<template #icon>
<n-icon>
<ArrowForwardIosFilled />
</n-icon>
</template>
{{ t('nextMail') }}
</n-button>
</n-flex>
</div>
<n-card :bordered="false" embedded v-if="curMail" class="mail-item" :title="curMail.subject"
style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ `${curMail.created_at} UTC` }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail.raw)">
<template #icon>
<n-icon :component="CloudDownloadRound" />
</template>
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
</n-button>
</n-space>
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
style="margin-top: 10px;width: 100%; height: 100%;">
</iframe>
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
@@ -446,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 }) }}
@@ -458,10 +523,14 @@ onBeforeUnmount(() => {
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" tertiary size="small" type="primary">
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
{{ t('refresh') }}
</n-button>
</n-space>
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
<n-input v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" size="small" clearable />
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
@@ -471,14 +540,19 @@ onBeforeUnmount(() => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ `${row.created_at} UTC` }}
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
<n-ellipsis style="max-width: 240px;">
{{ showEMailTo ? "FROM: " + row.source : row.source }}
</n-ellipsis>
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
<n-ellipsis style="max-width: 240px;">
TO: {{ row.address }}
</n-ellipsis>
</n-tag>
<AiExtractInfo :metadata="row.metadata" compact />
</template>
</n-thing>
</n-list-item>
@@ -488,83 +562,14 @@ onBeforeUnmount(() => {
style="height: 80vh;">
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
<n-card :bordered="false" embedded style="overflow: auto;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ `${curMail.created_at} UTC` }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail)">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
</n-button>
</n-space>
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
style="margin-top: 10px;width: 100%; height: 100%;">
</iframe>
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
:onSaveToS3="saveToS3Proxy" />
</n-card>
</n-drawer-content>
</n-drawer>
</div>
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("attachments") }}</div>
</template>
<n-spin v-model:show="attachmentLoding">
<n-list hoverable clickable>
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
<n-thing class="center" :title="row.filename">
<template #description>
<n-space>
<n-tag type="info">
Size: {{ row.size }}
</n-tag>
<n-button v-if="showSaveS3" @click="saveToS3Proxy(row.filename, row.blob)" ghost type="info"
size="small">
{{ t('saveToS3') }}
</n-button>
</n-space>
</template>
</n-thing>
<template #suffix>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
:href="row.url">
<n-icon :component="CloudDownloadRound" />
</n-button>
</template>
</n-list-item>
</n-list>
</n-spin>
</n-modal>
<n-modal v-model:show="showMultiActionDownload" preset="dialog" :title="t('downloadMail')">
<n-tag type="info">
{{ multiActionDownloadZip.filename }}

View File

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

View File

@@ -4,6 +4,7 @@ import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { useIsMobile } from '../utils/composables'
import { utcToLocalDate } from '../utils';
const message = useMessage()
const isMobile = useIsMobile()
@@ -30,7 +31,7 @@ const props = defineProps({
},
})
const { isDark, mailboxSplitSize, loading } = useGlobalState()
const { isDark, mailboxSplitSize, loading, useUTCDate } = useGlobalState()
const data = ref([])
const count = ref(0)
@@ -251,7 +252,7 @@ onMounted(async () => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ `${row.created_at} UTC` }}
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
@@ -273,7 +274,7 @@ onMounted(async () => {
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ `${curMail.created_at} UTC` }}
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.address }}
@@ -320,7 +321,7 @@ onMounted(async () => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ `${row.created_at} UTC` }}
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
@@ -342,7 +343,7 @@ onMounted(async () => {
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ `${curMail.created_at} UTC` }}
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.address }}

View File

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

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

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

View File

@@ -1,31 +1,9 @@
import { createApp } from 'vue'
import { createHead } from '@unhead/vue/client'
import App from './App.vue'
import { createI18n } from 'vue-i18n'
import router from './router'
import { registerSW } from 'virtual:pwa-register'
import { createHead } from '@unhead/vue'
registerSW({ immediate: true })
const i18n = createI18n({
legacy: false, // you must set `false`, to use Composition API
locale: 'zh', // set locale
fallbackLocale: 'en', // set fallback locale
'en': {
messages: {}
},
'zh': {
messages: {}
}
})
router.beforeEach((to, from) => {
if (to.params.lang && ['en', 'zh'].includes(to.params.lang)) {
i18n.global.locale.value = to.params.lang
} else {
i18n.global.locale.value = 'zh'
}
});
import i18n from './i18n'
const head = createHead()
const app = createApp(App)

View File

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

View File

@@ -2,6 +2,10 @@ import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue'
import User from '../views/User.vue'
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
import i18n from '../i18n'
import { useGlobalState } from '../store'
const { jwt } = useGlobalState()
const router = createRouter({
history: createWebHistory(),
@@ -37,6 +41,20 @@ const router = createRouter({
redirect: '/'
}
]
})
});
router.beforeEach((to, from, next) => {
if (to.params.lang && ['en', 'zh'].includes(to.params.lang)) {
i18n.global.locale.value = to.params.lang
} else {
i18n.global.locale.value = 'zh'
}
// check if query parameter has jwt, set it to store
if (to.query.jwt) {
jwt.value = to.query.jwt;
}
next()
});
export default router

View File

@@ -10,15 +10,19 @@ export const useGlobalState = createGlobalState(
const toggleDark = useToggle(isDark)
const loading = ref(false);
const announcement = useLocalStorage('announcement', '');
const useSimpleIndex = useLocalStorage('useSimpleIndex', false);
const openSettings = ref({
fetched: false,
title: '',
announcement: '',
alwaysShowAnnouncement: false,
prefix: '',
addressRegex: '',
needAuth: false,
adminContact: '',
enableUserCreateEmail: false,
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableAutoReply: false,
enableIndexAbout: false,
@@ -30,8 +34,10 @@ export const useGlobalState = createGlobalState(
cfTurnstileSiteKey: '',
enableWebhook: false,
isS3Enabled: false,
enableSendMail: false,
showGithub: true,
disableAdminPasswordCheck: false,
enableAddressPassword: false,
})
const settings = ref({
fetched: false,
@@ -59,6 +65,7 @@ export const useGlobalState = createGlobalState(
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', '');
const addressPassword = useSessionStorage('addressPassword', '');
const adminTab = useSessionStorage('adminTab', "account");
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
@@ -66,15 +73,18 @@ export const useGlobalState = createGlobalState(
const useIframeShowMail = useStorage('useIframeShowMail', false);
const preferShowTextMail = useStorage('preferShowTextMail', false);
const userJwt = useStorage('userJwt', '');
const userTab = useSessionStorage('userTab', 'user_settings');
const userTab = useSessionStorage('userTab', 'address_management');
const indexTab = useSessionStorage('indexTab', 'mailbox');
const globalTabplacement = useStorage('globalTabplacement', 'top');
const useSideMargin = useStorage('useSideMargin', true);
const useUTCDate = useStorage('useUTCDate', false);
const autoRefresh = useStorage('autoRefresh', false);
const configAutoRefreshInterval = useStorage("configAutoRefreshInterval", 60);
const userOpenSettings = ref({
fetched: false,
enable: false,
enableMailVerify: false,
/** @type {{ clientID: string, name: string }[]} */
/** @type {{ clientID: string, name: string, icon?: string }[]} */
oauth2ClientIDs: [],
});
const userSettings = ref({
@@ -88,6 +98,8 @@ export const useGlobalState = createGlobalState(
is_admin: false,
/** @type {string | null} */
access_token: null,
/** @type {string | null} */
new_user_token: null,
/** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
user_role: null,
});
@@ -100,6 +112,7 @@ export const useGlobalState = createGlobalState(
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
const browserFingerprint = ref('');
return {
isDark,
toggleDark,
@@ -127,11 +140,17 @@ export const useGlobalState = createGlobalState(
userSettings,
globalTabplacement,
useSideMargin,
useUTCDate,
autoRefresh,
configAutoRefreshInterval,
telegramApp,
isTelegram,
showAdminPage,
userOauth2SessionState,
userOauth2SessionClientID,
useSimpleIndex,
addressPassword,
browserFingerprint,
}
},
)

View File

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

View File

@@ -11,3 +11,20 @@ export const getRouterPathWithLang = (path: string, lang: string) => {
}
return `/${lang}${path}`;
}
export const utcToLocalDate = (utcDate: string, useUTCDate: boolean) => {
const utcDateString = `${utcDate} UTC`;
if (useUTCDate) {
return utcDateString;
}
try {
const date = new Date(utcDateString);
// if invalid date string
if (isNaN(date.getTime())) return utcDateString;
return date.toLocaleString();
} catch (e) {
console.error(e);
}
return utcDateString;
}

View File

@@ -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"
@@ -14,21 +16,33 @@ import AccountSettings from './admin/AccountSettings.vue';
import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue';
import UserOauth2Settings from './admin/UserOauth2Settings.vue';
import RoleAddressConfig from './admin/RoleAddressConfig.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import About from './common/About.vue';
import Maintenance from './admin/Maintenance.vue';
import DatabaseManager from './admin/DatabaseManager.vue';
import Appearance from './common/Appearance.vue';
import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
import MailWebhook from './admin/MailWebhook.vue';
import WorkerConfig from './admin/WorkerConfig.vue';
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
import AiExtractSettings from './admin/AiExtractSettings.vue';
const {
adminAuth, showAdminAuth, adminTab, loading,
globalTabplacement, showAdminPage, userSettings
globalTabplacement, showAdminPage, userSettings,
openSettings
} = useGlobalState()
const message = useMessage()
const router = useRouter()
const SendMail = defineAsyncComponent(() => {
loading.value = true;
return import('./admin/SendMail.vue')
.finally(() => loading.value = false);
});
const authFunc = async () => {
try {
@@ -39,12 +53,26 @@ 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',
accessTip: 'Please enter the admin password',
mails: 'Emails',
sendMail: 'Send Mail',
qucickSetup: 'Quick Setup',
account: 'Account',
account_create: 'Create Account',
@@ -53,6 +81,7 @@ const { t } = useI18n({
user_management: 'User Management',
user_settings: 'User Settings',
userOauth2Settings: 'Oauth2 Settings',
roleAddressConfig: 'Role Address Config',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
@@ -60,16 +89,30 @@ const { t } = useI18n({
webhookSettings: 'Webhook Settings',
statistics: 'Statistics',
maintenance: 'Maintenance',
database: 'Database',
workerconfig: 'Worker Config',
ipBlacklistSettings: 'IP Blacklist',
aiExtractSettings: 'AI Extract Settings',
appearance: 'Appearance',
about: 'About',
ok: 'OK',
mailWebhook: 'Mail Webhook',
adminAccount: 'Admin',
loginMethod: 'Login Method',
loginViaPassword: 'Admin Password Login',
loginViaUserAdmin: 'User Admin Permission',
loginViaDisabledCheck: 'Disabled Password Check',
logout: 'Logout',
logoutConfirmTitle: 'Confirm Logout',
logoutConfirmContent: 'Are you sure you want to logout from admin panel?',
confirm: 'Confirm',
logoutSuccess: 'Logout successful',
},
zh: {
accessHeader: 'Admin 密码',
accessTip: '请输入 Admin 密码',
mails: '邮件',
sendMail: '发送邮件',
qucickSetup: '快速设置',
account: '账号',
account_create: '创建账号',
@@ -78,6 +121,7 @@ const { t } = useI18n({
user_management: '用户管理',
user_settings: '用户设置',
userOauth2Settings: 'Oauth2 设置',
roleAddressConfig: '角色地址配置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
@@ -85,17 +129,44 @@ const { t } = useI18n({
webhookSettings: 'Webhook 设置',
statistics: '统计',
maintenance: '维护',
database: '数据库',
workerconfig: 'Worker 配置',
ipBlacklistSettings: 'IP 黑名单',
aiExtractSettings: 'AI 提取设置',
appearance: '外观',
about: '关于',
ok: '确定',
mailWebhook: '邮件 Webhook',
adminAccount: '管理员',
loginMethod: '登录方式',
loginViaPassword: 'Admin 密码登录',
loginViaUserAdmin: '用户管理员权限',
loginViaDisabledCheck: '已禁用密码检查',
logout: '退出登录',
logoutConfirmTitle: '确认退出',
logoutConfirmContent: '确定要退出管理员面板吗?',
confirm: '确认',
logoutSuccess: '退出成功',
}
}
});
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
@@ -104,7 +175,7 @@ onMounted(async () => {
</script>
<template>
<div>
<div v-if="userSettings.fetched">
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
preset="dialog" :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
@@ -118,6 +189,9 @@ onMounted(async () => {
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings />
</n-tab-pane>
@@ -143,6 +217,12 @@ onMounted(async () => {
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="ipBlacklistSettings" :tab="t('ipBlacklistSettings')">
<IpBlacklistSettings />
</n-tab-pane>
<n-tab-pane name="aiExtractSettings" :tab="t('aiExtractSettings')">
<AiExtractSettings />
</n-tab-pane>
<n-tab-pane name="webhook" :tab="t('webhookSettings')">
<Webhook />
</n-tab-pane>
@@ -159,6 +239,9 @@ onMounted(async () => {
<n-tab-pane name="userOauth2Settings" :tab="t('userOauth2Settings')">
<UserOauth2Settings />
</n-tab-pane>
<n-tab-pane name="roleAddressConfig" :tab="t('roleAddressConfig')">
<RoleAddressConfig />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
@@ -172,6 +255,9 @@ onMounted(async () => {
<n-tab-pane name="sendBox" :tab="t('sendBox')">
<SendBox />
</n-tab-pane>
<n-tab-pane name="sendMail" :tab="t('sendMail')">
<SendMail />
</n-tab-pane>
<n-tab-pane name="mailWebhook" :tab="t('mailWebhook')">
<MailWebhook />
</n-tab-pane>
@@ -185,6 +271,9 @@ onMounted(async () => {
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>
<n-tab-pane name="workerconfig" :tab="t('workerconfig')">
<WorkerConfig />
</n-tab-pane>
@@ -196,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

@@ -21,9 +21,14 @@ const { t } = useI18n({
<div>
<n-divider class="footer-divider" />
<div style="text-align: center; padding: 20px">
<n-text depth="3">
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }} {{ openSettings.copyright }}
</n-text>
<n-space justify="center">
<n-text depth="3">
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }}
</n-text>
<n-text depth="3">
<div v-html="openSettings.copyright"></div>
</n-text>
</n-space>
</div>
</div>
</template>

View File

@@ -15,6 +15,7 @@ import { api } from '../api'
import { getRouterPathWithLang } from '../utils'
const message = useMessage()
const notification = useNotification()
const {
toggleDark, isDark, isTelegram, showAdminPage,
@@ -125,7 +126,9 @@ const menuOptions = computed(() => [
type: menuValue.value == "admin" ? "primary" : "default",
style: "width: 100%",
onClick: async () => {
loading.value = true;
await router.push(getRouterPathWithLang('/admin', locale.value));
loading.value = false;
showMobileMenu.value = false;
}
},
@@ -213,7 +216,9 @@ const logoClick = async () => {
if (logoClickCount.value >= 5) {
logoClickCount.value = 0;
message.info("Change to admin Page");
loading.value = true;
await router.push(getRouterPathWithLang('/admin', locale.value));
loading.value = false;
} else {
logoClickCount.value++;
}
@@ -223,7 +228,7 @@ const logoClick = async () => {
}
onMounted(async () => {
await api.getOpenSettings(message);
await api.getOpenSettings(message, notification);
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
});

View File

@@ -5,20 +5,30 @@ import { useRoute } from 'vue-router'
import { useGlobalState } from '../store'
import { api } from '../api'
import { useIsMobile } from '../utils/composables'
import { FullscreenExitOutlined } from '@vicons/material'
import AddressBar from './index/AddressBar.vue';
import MailBox from '../components/MailBox.vue';
import SendBox from '../components/SendBox.vue';
import AutoReply from './index/AutoReply.vue';
import AccountSettings from './index/AccountSettings.vue';
import Appearance from './common/Appearance.vue';
import Webhook from './index/Webhook.vue';
import Attachment from './index/Attachment.vue';
import About from './common/About.vue';
import SimpleIndex from './index/SimpleIndex.vue';
const SendMail = defineAsyncComponent(() => import('./index/SendMail.vue'));
const { settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
const { loading, settings, openSettings, indexTab, globalTabplacement, useSimpleIndex } = useGlobalState()
const message = useMessage()
const route = useRoute()
const isMobile = useIsMobile()
const SendMail = defineAsyncComponent(() => {
loading.value = true;
return import('./index/SendMail.vue')
.finally(() => loading.value = false);
});
const { t } = useI18n({
messages: {
@@ -28,23 +38,27 @@ const { t } = useI18n({
sendmail: 'Send Mail',
auto_reply: 'Auto Reply',
accountSettings: 'Account Settings',
appearance: 'Appearance',
about: 'About',
s3Attachment: 'S3 Attachment',
saveToS3Success: 'save to s3 success',
webhookSettings: 'Webhook Settings',
query: 'Query',
enterSimpleMode: 'Simple Mode',
},
zh: {
mailbox: '收件箱',
sendbox: '发件箱',
sendmail: '发送邮件',
auto_reply: '自动回复',
accountSettings: '账户设置',
accountSettings: '账户',
appearance: '外观',
about: '关于',
s3Attachment: 'S3附件',
saveToS3Success: '保存到s3成功',
webhookSettings: 'Webhook 设置',
query: '查询',
enterSimpleMode: '极简模式',
}
}
});
@@ -117,43 +131,61 @@ onMounted(() => {
<template>
<div>
<AddressBar />
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
<n-tab-pane name="mailbox" :tab="t('mailbox')">
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
<n-input-group>
<n-input v-model:value="mailIdQuery" />
<n-button @click="queryMail" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
</div>
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled"
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
</n-tab-pane>
<n-tab-pane name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:deleteMail="deleteSenboxMail" />
</n-tab-pane>
<n-tab-pane name="sendmail" :tab="t('sendmail')">
<SendMail />
</n-tab-pane>
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
<AutoReply />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhookSettings')">
<Webhook />
</n-tab-pane>
<n-tab-pane v-if="openSettings.isS3Enabled" name="s3_attachment" :tab="t('s3Attachment')">
<Attachment />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableIndexAbout" name="about" :tab="t('about')">
<About />
</n-tab-pane>
</n-tabs>
<div v-if="useSimpleIndex">
<SimpleIndex />
</div>
<div v-else>
<AddressBar />
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
<template #prefix v-if="!isMobile">
<n-button @click="useSimpleIndex = true" tertiary size="small">
<template #icon>
<n-icon>
<FullscreenExitOutlined />
</n-icon>
</template>
{{ t('enterSimpleMode') }}
</n-button>
</template>
<n-tab-pane name="mailbox" :tab="t('mailbox')">
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
<n-input-group>
<n-input v-model:value="mailIdQuery" />
<n-button @click="queryMail" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
</div>
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:deleteMail="deleteSenboxMail" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
<SendMail />
</n-tab-pane>
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="appearance" :tab="t('appearance')">
<Appearance :showUseSimpleIndex="true" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
<AutoReply />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhookSettings')">
<Webhook />
</n-tab-pane>
<n-tab-pane v-if="openSettings.isS3Enabled" name="s3_attachment" :tab="t('s3Attachment')">
<Attachment />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableIndexAbout" name="about" :tab="t('about')">
<About />
</n-tab-pane>
</n-tabs>
</div>
</div>
</template>

View File

@@ -7,6 +7,7 @@ import AddressMangement from './user/AddressManagement.vue';
import UserSettingsPage from './user/UserSettings.vue';
import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue';
import UserMailBox from './user/UserMailBox.vue';
const {
userTab, globalTabplacement, userSettings
@@ -16,11 +17,13 @@ const { t } = useI18n({
messages: {
en: {
address_management: 'Address Management',
user_mail_box_tab: 'Mail Box',
user_settings: 'User Settings',
bind_address: 'Bind Mail Address',
},
zh: {
address_management: '地址管理',
user_mail_box_tab: '收件箱',
user_settings: '用户设置',
bind_address: '绑定邮箱地址',
}
@@ -36,6 +39,9 @@ const { t } = useI18n({
<n-tab-pane name="address_management" :tab="t('address_management')">
<AddressMangement />
</n-tab-pane>
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
<UserMailBox />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettingsPage />
</n-tab-pane>

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { NBadge } from 'naive-ui'
import { ref, h, onMounted, watch, computed } from 'vue';
import { NBadge, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
@@ -9,7 +9,7 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
loading, adminTab,
loading, adminTab, openSettings,
adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
@@ -22,18 +22,37 @@ 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.',
delete: 'Delete',
deleteTip: 'Are you sure to delete this email?',
delteAccount: 'Delete Account',
deleteAccount: 'Delete Account',
viewMails: 'View Mails',
viewSendBox: 'View SendBox',
itemCount: 'itemCount',
query: 'Query',
addressQueryTip: 'Leave blank to query all addresses',
actions: 'Actions'
clearInbox: 'Clear Inbox',
clearSentItems: 'Clear Sent Items',
clearInboxTip: 'Are you sure to clear inbox for this email?',
clearSentItemsTip: 'Are you sure to clear sent items for this email?',
actions: 'Actions',
success: 'Success',
resetPassword: 'Reset Password',
newPassword: 'New Password',
passwordResetSuccess: 'Password reset successfully',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
pleaseSelectAddress: 'Please select address',
selectedItems: 'Selected',
multiDelete: 'Multi Delete',
multiDeleteTip: 'Are you sure to delete selected addresses?',
multiClearInbox: 'Multi Clear Inbox',
multiClearInboxTip: 'Are you sure to clear inbox for selected addresses?',
multiClearSentItems: 'Multi Clear Sent Items',
multiClearSentItemsTip: 'Are you sure to clear sent items for selected addresses?',
},
zh: {
name: '名称',
@@ -41,18 +60,37 @@ const { t } = useI18n({
updated_at: '更新时间',
mail_count: '邮件数量',
send_count: '发送数量',
source_meta: '来源',
showCredential: '查看邮箱地址凭证',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
delteAccount: '删除邮箱',
deleteAccount: '删除邮箱',
viewMails: '查看邮件',
viewSendBox: '查看发件箱',
itemCount: '总数',
query: '查询',
addressQueryTip: '留空查询所有地址',
clearInbox: '清空收件箱',
clearSentItems: '清空发件箱',
clearInboxTip: '确定要清空这个邮箱的收件箱吗?',
clearSentItemsTip: '确定要清空这个邮箱的发件箱吗?',
actions: '操作',
success: '成功',
resetPassword: '重置密码',
newPassword: '新密码',
passwordResetSuccess: '密码重置成功',
selectAll: '全选本页',
unselectAll: '取消全选',
pleaseSelectAddress: '请选择地址',
selectedItems: '已选择',
multiDelete: '批量删除',
multiDeleteTip: '确定要删除选中的邮箱吗?',
multiClearInbox: '批量清空收件箱',
multiClearInboxTip: '确定要清空选中邮箱的收件箱吗?',
multiClearSentItems: '批量清空发件箱',
multiClearSentItemsTip: '确定要清空选中邮箱的发件箱吗?',
}
}
});
@@ -60,6 +98,20 @@ const { t } = useI18n({
const showEmailCredential = ref(false)
const curEmailCredential = ref("")
const curDeleteAddressId = ref(0);
const curClearInboxAddressId = ref(0);
const curClearSentItemsAddressId = ref(0);
const showResetPassword = ref(false);
const curResetPasswordAddressId = ref(0);
const newPassword = ref('');
// Multi-action mode state
const checkedRowKeys = ref([]);
const showMultiActionModal = ref(false);
const multiActionProgress = ref({ percentage: 0, tip: '0/0' });
const multiActionTitle = ref('');
const selectedCount = computed(() => checkedRowKeys.value.length);
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
const addressQuery = ref("")
@@ -68,6 +120,8 @@ const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showDeleteAccount = ref(false)
const showClearInbox = ref(false)
const showClearSentItems = ref(false)
const showCredential = async (id) => {
try {
@@ -83,7 +137,7 @@ const showCredential = async (id) => {
const deleteEmail = async () => {
try {
await api.adminDeleteAddress(curDeleteAddressId.value)
message.success("success");
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
@@ -92,6 +146,142 @@ const deleteEmail = async () => {
}
}
const clearInbox = async () => {
try {
await api.fetch(`/admin/clear_inbox/${curClearInboxAddressId.value}`, {
method: 'DELETE'
});
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
} finally {
showClearInbox.value = false
}
}
const clearSentItems = async () => {
try {
await api.fetch(`/admin/clear_sent_items/${curClearSentItemsAddressId.value}`, {
method: 'DELETE'
});
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
} finally {
showClearSentItems.value = false
}
}
const resetPassword = async () => {
try {
await api.fetch(`/admin/address/${curResetPasswordAddressId.value}/reset_password`, {
method: 'POST',
body: JSON.stringify({
password: newPassword.value
})
});
message.success(t("passwordResetSuccess"));
newPassword.value = '';
showResetPassword.value = false;
} catch (error) {
message.error(error.message || "error");
}
}
// Multi-action mode functions
const multiActionSelectAll = () => {
checkedRowKeys.value = data.value.map(item => item.id);
}
const multiActionUnselectAll = () => {
checkedRowKeys.value = [];
}
// 通用批量操作函数
const executeBatchOperation = async ({
shouldSkip = () => false,
apiCall,
title,
operationName = 'operation'
}) => {
try {
loading.value = true;
const selectedAddresses = data.value.filter((item) =>
checkedRowKeys.value.includes(item.id)
);
if (selectedAddresses.length === 0) {
message.error(t('pleaseSelectAddress'));
return;
}
const failedIds = [];
const totalCount = selectedAddresses.length;
multiActionProgress.value = {
percentage: 0,
tip: `0/${totalCount}`
};
multiActionTitle.value = title;
showMultiActionModal.value = true;
for (const [index, address] of selectedAddresses.entries()) {
try {
if (!shouldSkip(address)) {
await apiCall(address.id);
}
} catch (error) {
console.error(`${operationName} failed for address ${address.id}:`, error);
failedIds.push(address.id);
}
multiActionProgress.value = {
percentage: Math.floor((index + 1) / totalCount * 100),
tip: `${index + 1}/${totalCount}`
};
}
await fetchData();
checkedRowKeys.value = failedIds;
message.success(t("success"));
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
}
}
const multiActionDeleteAccounts = async () => {
await executeBatchOperation({
apiCall: (id) => api.adminDeleteAddress(id),
title: t('multiDelete') + ' ' + t('success'),
operationName: 'Delete'
});
}
const multiActionClearInbox = async () => {
await executeBatchOperation({
shouldSkip: (address) => address.mail_count <= 0,
apiCall: (id) => api.fetch(`/admin/clear_inbox/${id}`, {
method: 'DELETE'
}),
title: t('multiClearInbox') + ' ' + t('success'),
operationName: 'ClearInbox'
});
}
const multiActionClearSentItems = async () => {
await executeBatchOperation({
shouldSkip: (address) => address.send_count <= 0,
apiCall: (id) => api.fetch(`/admin/clear_sent_items/${id}`, {
method: 'DELETE'
}),
title: t('multiClearSentItems') + ' ' + t('success'),
operationName: 'ClearSentItems'
});
}
const fetchData = async () => {
try {
addressQuery.value = addressQuery.value.trim()
@@ -106,12 +296,15 @@ const fetchData = async () => {
count.value = addressCount;
}
} catch (error) {
console.log(error)
console.error(error);
message.error(error.message || "error");
}
}
const columns = [
{
type: 'selection'
},
{
title: "ID",
key: "id"
@@ -128,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",
@@ -212,7 +409,8 @@ const columns = [
}
},
{ default: () => t('viewMails') }
)
),
show: row.mail_count > 0
},
{
label: () => h(NButton,
@@ -224,7 +422,47 @@ const columns = [
}
},
{ default: () => t('viewSendBox') }
)
),
show: row.send_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curClearInboxAddressId.value = row.id;
showClearInbox.value = true;
}
},
{ default: () => t('clearInbox') }
),
show: row.mail_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curClearSentItemsAddressId.value = row.id;
showClearSentItems.value = true;
}
},
{ default: () => t('clearSentItems') }
),
show: row.send_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curResetPasswordAddressId.value = row.id;
showResetPassword.value = true;
}
},
{ default: () => t('resetPassword') }
),
show: openSettings.value?.enableAddressPassword
},
{
label: () => h(NButton,
@@ -271,21 +509,78 @@ onMounted(async () => {
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('delteAccount')">
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
{{ t('delteAccount') }}
{{ t('deleteAccount') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-modal v-model:show="showClearInbox" preset="dialog" :title="t('clearInbox')">
<p>{{ t('clearInboxTip') }}</p>
<template #action>
<n-button :loading="loading" @click="clearInbox" size="small" tertiary type="error">
{{ t('clearInbox') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showClearSentItems" preset="dialog" :title="t('clearSentItems')">
<p>{{ t('clearSentItemsTip') }}</p>
<template #action>
<n-button :loading="loading" @click="clearSentItems" size="small" tertiary type="error">
{{ t('clearSentItems') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item :label="t('newPassword')">
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
</n-form-item>
<template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="info">
{{ t('resetPassword') }}
</n-button>
</template>
</n-modal>
<n-input-group style="margin-bottom: 10px;">
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
@keydown.enter="fetchData" />
<n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
<n-space v-if="showMultiActionBar" style="margin-bottom: 10px;">
<n-button @click="multiActionSelectAll" tertiary>
{{ t('selectAll') }}
</n-button>
<n-button @click="multiActionUnselectAll" tertiary>
{{ t('unselectAll') }}
</n-button>
<n-popconfirm @positive-click="multiActionDeleteAccounts">
<template #trigger>
<n-button tertiary type="error">{{ t('multiDelete') }}</n-button>
</template>
{{ t('multiDeleteTip') }}
</n-popconfirm>
<n-popconfirm @positive-click="multiActionClearInbox">
<template #trigger>
<n-button tertiary type="warning">{{ t('multiClearInbox') }}</n-button>
</template>
{{ t('multiClearInboxTip') }}
</n-popconfirm>
<n-popconfirm @positive-click="multiActionClearSentItems">
<template #trigger>
<n-button tertiary type="warning">{{ t('multiClearSentItems') }}</n-button>
</template>
{{ t('multiClearSentItemsTip') }}
</n-popconfirm>
<n-tag type="info">
{{ t('selectedItems') }}: {{ selectedCount }}
</n-tag>
</n-space>
<div style="overflow: auto;">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
@@ -295,8 +590,21 @@ onMounted(async () => {
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
:row-key="row => row.id" embedded />
</div>
<!-- Multi-action progress modal -->
<n-modal v-model:show="showMultiActionModal" preset="dialog" :title="multiActionTitle" negative-text="OK">
<n-space justify="center">
<n-progress type="circle" status="info" :percentage="multiActionProgress.percentage">
<span style="text-align: center">
{{ multiActionProgress.tip }}
</span>
</n-progress>
</n-space>
</n-modal>
</div>
</template>

View File

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

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
// @ts-ignore
import { api } from '../../api'
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
title: 'AI Email Extraction Settings',
successTip: 'Success',
save: 'Save',
enableAllowList: 'Enable Address Allowlist',
enableAllowListTip: 'When enabled, AI extraction will only process emails sent to addresses in the allowlist',
allowList: 'Address Allowlist (Enter address and press Enter, wildcards supported)',
allowListTip: "Wildcard * matches any characters, e.g. *{'@'}example.com matches all addresses under example.com domain",
manualInputPrompt: 'Type and press Enter to add',
disabledTip: 'When disabled, AI extraction will process all email addresses',
},
zh: {
title: 'AI 邮件提取设置',
successTip: '成功',
save: '保存',
enableAllowList: '启用地址白名单',
enableAllowListTip: '启用后AI 提取功能仅对白名单中的邮箱地址生效',
allowList: '地址白名单 (请输入地址并回车,支持通配符)',
allowListTip: "通配符 * 可匹配任意字符,如 *{'@'}example.com 可匹配 example.com 域名下的所有地址",
manualInputPrompt: '输入后按回车键添加',
disabledTip: '未启用时,所有邮箱地址都可使用 AI 提取功能',
}
}
});
type AiExtractSettings = {
enableAllowList: boolean
allowList: string[]
}
const settings = ref<AiExtractSettings>({
enableAllowList: false,
allowList: []
})
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/ai_extract/settings`) as AiExtractSettings
Object.assign(settings.value, res)
} catch (error) {
message.error((error as Error).message || "error");
}
}
const saveSettings = async () => {
try {
await api.fetch(`/admin/ai_extract/settings`, {
method: 'POST',
body: JSON.stringify(settings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card :title="t('title')" :bordered="false" embedded style="max-width: 800px; overflow: auto;">
<n-flex justify="end">
<n-button @click="saveSettings" type="primary">
{{ t('save') }}
</n-button>
</n-flex>
<n-form-item-row :label="t('enableAllowList')">
<n-switch v-model:value="settings.enableAllowList" :round="false" />
</n-form-item-row>
<n-alert v-if="!settings.enableAllowList" type="info" style="margin-bottom: 16px;">
{{ t('disabledTip') }}
</n-alert>
<div v-if="settings.enableAllowList">
<n-alert type="warning" style="margin-bottom: 16px;">
{{ t('enableAllowListTip') }}
</n-alert>
<n-form-item-row :label="t('allowList')">
<n-select v-model:value="settings.allowList" filterable multiple tag
:placeholder="t('allowListTip')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-text depth="3" style="font-size: 12px;">
{{ t('allowListTip') }}
</n-text>
</div>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -15,10 +15,13 @@ const { t } = useI18n({
en: {
address: 'Address',
enablePrefix: 'If enable Prefix',
creatNewEmail: 'Get New Email',
creatNewEmail: 'Create New Email',
fillInAllFields: 'Please fill in all fields',
successTip: 'Success Created',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
addressPassword: 'Address Password',
linkWithAddressCredential: 'Open to auto login email link',
},
zh: {
address: '地址',
@@ -27,6 +30,9 @@ const { t } = useI18n({
fillInAllFields: '请填写完整信息',
successTip: '创建成功',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
addressPassword: '地址密码',
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
}
}
});
@@ -36,6 +42,8 @@ const emailName = ref("")
const emailDomain = ref("")
const showReultModal = ref(false)
const result = ref("")
const addressPassword = ref("")
const createdAddress = ref("")
const newEmail = async () => {
if (!emailName.value || !emailDomain.value) {
@@ -52,6 +60,8 @@ const newEmail = async () => {
})
})
result.value = res["jwt"];
addressPassword.value = res["password"] || '';
createdAddress.value = res["address"] || '';
message.success(t('successTip'))
showReultModal.value = true
} catch (error) {
@@ -59,6 +69,10 @@ const newEmail = async () => {
}
}
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${result.value}`
}
onMounted(async () => {
if (openSettings.prefix) {
enablePrefix.value = true
@@ -70,14 +84,29 @@ onMounted(async () => {
<template>
<div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
<p>{{ t('addressCredential') }}</p>
<n-card :bordered="false" embedded>
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<b>{{ result }}</b>
</n-card>
<n-card embedded v-if="addressPassword">
<p><b>{{ createdAddress }}</b></p>
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
</n-card>
<n-card embedded>
<n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'>
<n-card embedded>
<b>{{ getUrlWithJwt() }}</b>
</n-card>
</n-collapse-item>
</n-collapse>
</n-card>
</n-modal>
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
<n-checkbox v-model:checked="enablePrefix" />
<n-switch v-model:value="enablePrefix" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('address')">
<n-input-group>

View File

@@ -0,0 +1,126 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { CleaningServicesFilled } from '@vicons/material'
import { api } from '../../api'
import { init } from 'vooks/lib/on-fonts-ready';
const message = useMessage()
const dbVersionData = ref({
need_initialization: false,
need_migration: false,
current_db_version: '',
code_db_version: ''
})
const { t } = useI18n({
messages: {
en: {
need_initialization_tip: 'Database initialization is required. Please initialize the database.',
need_migration_tip: 'Database migration is required. Please migrate the database.',
current_db_version: 'Current DB Version',
code_db_version: 'Code Needed DB Version',
init: 'Initialize Database',
migration: 'Migrate Database',
initializationSuccess: 'Database initialized successfully',
migrationSuccess: 'Database migrated successfully',
},
zh: {
need_initialization_tip: '需要初始化数据库,请初始化数据库',
need_migration_tip: '需要迁移数据库,请迁移数据库',
current_db_version: '当前数据库版本',
code_db_version: '需要的数据库版本',
init: '初始化数据库',
migration: '升级数据库 Schema',
initializationSuccess: '数据库初始化成功',
migrationSuccess: '数据库升级成功',
}
}
});
const fetchData = async () => {
try {
const res = await api.fetch('/admin/db_version');
if (res) Object.assign(dbVersionData.value, res);
} catch (error) {
message.error(error.message || "error");
}
}
const initialization = async () => {
try {
await api.fetch('/admin/db_initialize', {
method: 'POST'
});
await fetchData();
message.success(t('initializationSuccess'));
} catch (error) {
message.error(error.message || "error");
}
}
const migration = async () => {
try {
await api.fetch('/admin/db_migration', {
method: 'POST'
});
await fetchData();
message.success(t('migrationSuccess'));
} catch (error) {
message.error(error.message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded>
<n-alert v-if="dbVersionData.need_initialization" type="warning" :show-icon="false" :bordered="false">
<span>{{ t('need_initialization_tip') }}</span>
<n-button @click="initialization" type="primary" secondary block :loading="loading">
{{ t('init') }}
</n-button>
</n-alert>
<n-alert v-if="dbVersionData.need_migration" type="warning" :show-icon="false" :bordered="false">
<span>{{ t('need_migration_tip') }}</span>
<n-button @click="migration" type="primary" secondary block :loading="loading">
{{ t('migration') }}
</n-button>
</n-alert>
<n-alert type="info" :show-icon="false" :bordered="false">
<span>
{{ t('current_db_version') }}: {{ dbVersionData.current_db_version || "unknown" }},
{{ t('code_db_version') }}: {{ dbVersionData.code_db_version }}
</span>
</n-alert>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.n-alert {
margin-bottom: 10px;
}
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,220 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
title: 'IP Blacklist Settings',
manualInputPrompt: 'Type pattern and press Enter to add',
save: 'Save',
successTip: 'Save Success',
enable_ip_blacklist: 'Enable IP Blacklist',
enable_tip: 'Block IPs matching blacklist patterns from accessing rate-limited APIs',
ip_blacklist: 'IP Blacklist Patterns',
ip_blacklist_placeholder: 'Enter pattern (e.g., 192.168.1 or ^10\\.0\\.0\\.5$)',
asn_blacklist: 'ASN Organization Blacklist',
asn_blacklist_placeholder: 'Enter ASN organization (e.g., Google, Amazon)',
fingerprint_blacklist: 'Browser Fingerprint Blacklist',
fingerprint_blacklist_placeholder: 'Enter fingerprint ID (e.g., a1b2c3d4e5f6g7h8)',
tip_ip: 'IP Blacklist: Supports text matching (e.g., "192.168.1") or regex (e.g., "^10\\.0\\.0\\.5$").',
tip_asn: 'ASN Organization: Block by ISP/provider. Case-insensitive text matching or regex.',
tip_fingerprint: 'Browser Fingerprint: Block by browser fingerprint. Supports exact matching or regex patterns.',
tip_daily_limit: 'Daily Limit: Restrict the maximum number of requests per IP address per day (1-1000000).',
tip_scope: 'Applies to: Create Address, Send Mail, External Send Mail API, User Registration, Verify Code',
enable_daily_limit: 'Enable Daily Request Limit',
enable_daily_limit_tip: 'Limit the number of API requests per IP address per day',
daily_request_limit: 'Daily Request Limit',
daily_request_limit_placeholder: 'Enter limit (e.g., 1000)',
},
zh: {
title: 'IP 黑名单设置',
manualInputPrompt: '输入匹配模式后按回车键添加',
save: '保存',
successTip: '保存成功',
enable_ip_blacklist: '启用 IP 黑名单',
enable_tip: '阻止匹配黑名单的 IP 访问限流 API',
ip_blacklist: 'IP 黑名单匹配模式',
ip_blacklist_placeholder: '输入匹配模式例如192.168.1 或 ^10\\.0\\.0\\.5$',
asn_blacklist: 'ASN 组织(运营商)黑名单',
asn_blacklist_placeholder: '输入 ASN 组织名称例如Google, Amazon',
fingerprint_blacklist: '浏览器指纹黑名单',
fingerprint_blacklist_placeholder: '输入指纹 ID例如a1b2c3d4e5f6g7h8',
tip_ip: 'IP 黑名单:支持文本匹配(如 "192.168.1")或正则表达式(如 "^10\\.0\\.0\\.5$")。',
tip_asn: 'ASN 组织:根据运营商/ISP 拉黑。支持不区分大小写的文本匹配或正则表达式。',
tip_fingerprint: '浏览器指纹:根据浏览器指纹拉黑。支持完全匹配或正则表达式。',
tip_daily_limit: '每日限流:限制单个 IP 地址每天最多请求次数1-1000000。',
tip_scope: '作用范围:创建邮箱地址、发送邮件、外部发送邮件 API、用户注册、验证码验证',
enable_daily_limit: '启用每日请求限流',
enable_daily_limit_tip: '限制每个 IP 地址每天的 API 请求次数',
daily_request_limit: '每日请求次数上限',
daily_request_limit_placeholder: '输入限制次数例如1000',
}
}
});
const enabled = ref(false)
const ipBlacklist = ref([])
const asnBlacklist = ref([])
const fingerprintBlacklist = ref([])
const enableDailyLimit = ref(false)
const dailyRequestLimit = ref(1000)
const fetchData = async () => {
try {
loading.value = true
const res = await api.fetch(`/admin/ip_blacklist/settings`)
enabled.value = res.enabled || false
ipBlacklist.value = res.blacklist || []
asnBlacklist.value = res.asnBlacklist || []
fingerprintBlacklist.value = res.fingerprintBlacklist || []
enableDailyLimit.value = res.enableDailyLimit || false
dailyRequestLimit.value = res.dailyRequestLimit || 1000
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false
}
}
const save = async () => {
try {
loading.value = true
await api.fetch(`/admin/ip_blacklist/settings`, {
method: 'POST',
body: JSON.stringify({
enabled: enabled.value,
blacklist: ipBlacklist.value || [],
asnBlacklist: asnBlacklist.value || [],
fingerprintBlacklist: fingerprintBlacklist.value || [],
enableDailyLimit: enableDailyLimit.value,
dailyRequestLimit: dailyRequestLimit.value
})
})
message.success(t('successTip'))
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card :title="t('title')" :bordered="false" embedded style="max-width: 800px;">
<template #header-extra>
<n-button @click="save" type="primary" :loading="loading">
{{ t('save') }}
</n-button>
</template>
<n-space vertical :size="20">
<n-alert :show-icon="false" :bordered="false" type="info">
<div style="line-height: 1.8;">
<div><strong>{{ t("tip_scope") }}</strong></div>
<div> {{ t("tip_ip") }}</div>
<div> {{ t("tip_asn") }}</div>
<div> {{ t("tip_fingerprint") }}</div>
<div> {{ t("tip_daily_limit") }}</div>
</div>
</n-alert>
<n-form-item-row :label="t('enable_ip_blacklist')">
<n-switch v-model:value="enabled" :round="false" />
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
{{ t('enable_tip') }}
</n-text>
</n-form-item-row>
<n-form-item-row :label="t('ip_blacklist')">
<n-select
v-model:value="ipBlacklist"
filterable
multiple
tag
:placeholder="t('ip_blacklist_placeholder')"
:disabled="!enabled">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('asn_blacklist')">
<n-select
v-model:value="asnBlacklist"
filterable
multiple
tag
:placeholder="t('asn_blacklist_placeholder')"
:disabled="!enabled">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('fingerprint_blacklist')">
<n-select
v-model:value="fingerprintBlacklist"
filterable
multiple
tag
:placeholder="t('fingerprint_blacklist_placeholder')"
:disabled="!enabled">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-divider />
<n-form-item-row :label="t('enable_daily_limit')">
<n-switch v-model:value="enableDailyLimit" :round="false" />
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
{{ t('enable_daily_limit_tip') }}
</n-text>
</n-form-item-row>
<n-form-item-row :label="t('daily_request_limit')">
<n-input-number
v-model:value="dailyRequestLimit"
:min="1"
:max="1000000"
:placeholder="t('daily_request_limit_placeholder')"
:disabled="!enableDailyLimit"
style="width: 100%;"
/>
</n-form-item-row>
</n-space>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
margin: 20px;
}
</style>

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

@@ -11,7 +11,7 @@ const fetchMailUnknowData = async (limit, offset) => {
}
const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
await api.fetch(`/admin/mails/${curMailId}`, { method: 'DELETE' });
};
</script>

View File

@@ -1,20 +1,29 @@
<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,
cleanMailsDays: 30,
enableUnknowMailsAutoCleanup: false,
cleanUnknowMailsDays: 30,
enableAddressAutoCleanup: false,
cleanAddressDays: 30,
enableSendBoxAutoCleanup: false,
cleanSendBoxDays: 30,
enableAddressAutoCleanup: false,
cleanAddressDays: 30,
enableInactiveAddressAutoCleanup: false,
cleanInactiveAddressDays: 30,
enableUnboundAddressAutoCleanup: false,
cleanUnboundAddressDays: 30,
enableEmptyAddressAutoCleanup: false,
cleanEmptyAddressDays: 30,
customSqlCleanupList: []
})
const { t } = useI18n({
@@ -24,22 +33,50 @@ const { t } = useI18n({
mailBoxLabel: 'Cleanup the inbox before n days',
mailUnknowLabel: "Cleanup the unknow mail before n days",
sendBoxLabel: "Cleanup the sendbox before n days",
addressCreateLabel: "Cleanup the address created before n days",
inactiveAddressLabel: "Cleanup the inactive address before n days",
unboundAddressLabel: "Cleanup the unbound address before n days",
emptyAddressLabel: "Cleanup the empty address before n days",
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",
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: '请输入天数',
mailBoxLabel: '清理 n 天前的收件箱',
mailUnknowLabel: "清理 n 天前的无收件人邮件",
sendBoxLabel: "清理 n 天前的发件箱",
addressCreateLabel: "清理 n 天前创建的地址",
inactiveAddressLabel: "清理 n 天前的未活跃地址",
unboundAddressLabel: "清理 n 天前的未绑定用户地址",
emptyAddressLabel: "清理 n 天前空邮件的邮箱地址",
autoCleanup: "自动清理",
cleanupSuccess: "清理成功",
saveSuccess: "保存成功",
cleanupNow: "立即清理",
save: "保存",
cronTip: "启用定时清理, 需在 worker 配置 [crons] 参数, 请参考文档",
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: "删除",
}
}
});
@@ -56,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");
}
@@ -71,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");
}
@@ -86,50 +142,140 @@ onMounted(async () => {
<template>
<div class="center">
<n-card :bordered="false" embedded>
<n-alert :show-icon="false" :bordered="false">
<n-alert :show-icon="false" :bordered="false" type="warning">
<span>{{ t('cronTip') }}</span>
</n-alert>
<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-button @click="save" type="primary" block :loading="loading">
<n-flex justify="end">
<n-button @click="save" type="primary" :loading="loading">
{{ t('save') }}
</n-button>
</n-form>
</n-flex>
<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>
@@ -150,8 +296,7 @@ onMounted(async () => {
margin-bottom: 20px;
}
.item {
display: flex;
margin: 10px;
.sql-input {
text-align: left;
}
</style>

View File

@@ -0,0 +1,153 @@
<script setup>
import { ref, onMounted, h } from 'vue';
import { useI18n } from 'vue-i18n'
import { NInputNumber, NTag, NSpace, NButton } from 'naive-ui';
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { loading } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
role: 'Role',
maxAddressCount: 'Max Address Count',
save: 'Save',
successTip: 'Success',
noRolesAvailable: 'No roles available in system config',
roleConfigDesc: 'Configure maximum address count for each user role. Role-based limits take priority over global settings.',
notConfigured: 'Not Configured (Use Global Settings)',
},
zh: {
role: '角色',
maxAddressCount: '最大地址数量',
save: '保存',
successTip: '成功',
noRolesAvailable: '系统配置中没有可用的角色',
roleConfigDesc: '为每个用户角色配置最大地址数量。角色配置优先于全局设置。',
notConfigured: '未配置(使用全局设置)',
}
}
});
const systemRoles = ref([])
const tableData = ref([])
const fetchUserRoles = async () => {
try {
const results = await api.fetch(`/admin/user_roles`);
systemRoles.value = results;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const fetchRoleConfigs = async () => {
try {
const { configs } = await api.fetch(`/admin/role_address_config`);
tableData.value = systemRoles.value.map(roleObj => ({
role: roleObj.role,
max_address_count: configs[roleObj.role]?.maxAddressCount ?? null,
}));
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const saveConfig = async () => {
try {
// convert tableData to object with nested structure
const configs = {};
tableData.value.forEach(row => {
if (row.max_address_count !== null && row.max_address_count !== undefined) {
configs[row.role] = { maxAddressCount: row.max_address_count };
}
});
await api.fetch(`/admin/role_address_config`, {
method: 'POST',
body: JSON.stringify({ configs })
});
message.success(t('successTip'));
await fetchRoleConfigs();
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: t('role'),
key: 'role',
width: 200,
render(row) {
return h(NTag, {
type: 'info',
bordered: false
}, {
default: () => row.role
})
}
},
{
title: t('maxAddressCount'),
key: 'max_address_count',
render(row) {
return h(NInputNumber, {
value: row.max_address_count,
min: 0,
max: 999,
clearable: true,
placeholder: t('notConfigured'),
style: 'width: 200px;',
onUpdateValue: (value) => {
row.max_address_count = value;
}
})
}
}
]
onMounted(async () => {
await fetchUserRoles();
await fetchRoleConfigs();
})
</script>
<template>
<div style="margin-top: 10px;">
<n-alert type="info" :bordered="false" style="margin-bottom: 20px;">
{{ t('roleConfigDesc') }}
</n-alert>
<n-alert v-if="systemRoles.length === 0" type="warning" :bordered="false">
{{ t('noRolesAvailable') }}
</n-alert>
<div v-else>
<n-space justify="end" style="margin-bottom: 12px;">
<n-button :loading="loading" @click="saveConfig" type="primary">
{{ t('save') }}
</n-button>
</n-space>
<n-data-table
:columns="columns"
:data="tableData"
:bordered="false"
embedded
/>
</div>
</div>
</template>
<style scoped>
.n-data-table {
min-width: 600px;
}
</style>

View File

@@ -0,0 +1,199 @@
<script setup>
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useI18n } from 'vue-i18n'
import { onBeforeUnmount, ref, shallowRef } from 'vue'
import { useSessionStorage } from '@vueuse/core'
import { api } from '../../api'
const message = useMessage()
const isPreview = ref(false)
const editorRef = shallowRef()
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
fromName: "",
fromMail: "",
toName: "",
toMail: "",
subject: "",
contentType: 'text',
content: "",
});
const { t } = useI18n({
locale: 'zh',
messages: {
en: {
successSend: 'Please check your sendbox. If failed, please try again later.',
fromName: 'Your Name and Address, leave Name blank to use email address',
toName: 'Recipient Name and Address, leave Name blank to use email address',
subject: 'Subject',
options: 'Options',
edit: 'Edit',
preview: 'Preview',
content: 'Content',
send: 'Send',
text: 'Text',
html: 'HTML',
'rich text': 'Rich Text',
tooLarge: 'Too large file, please upload file less than 1MB.',
},
zh: {
successSend: '请查看您的发件箱, 如果失败, 请检查稍后重试。',
fromName: '你的名称和地址,名称不填写则使用邮箱地址',
toName: '收件人名称和地址,名称不填写则使用邮箱地址',
subject: '主题',
options: '选项',
edit: '编辑',
preview: '预览',
content: '内容',
send: '发送',
text: '文本',
html: 'HTML',
'rich text': '富文本',
tooLarge: '文件过大, 请上传小于1MB的文件。',
}
}
});
const contentTypes = [
{ label: t('text'), value: 'text' },
{ label: t('html'), value: 'html' },
{ label: t('rich text'), value: 'rich' },
]
const send = async () => {
try {
await api.fetch(`/admin/send_mail`,
{
method: 'POST',
body:
JSON.stringify({
from_name: sendMailModel.value.fromName,
from_mail: sendMailModel.value.fromMail,
to_name: sendMailModel.value.toName,
to_mail: sendMailModel.value.toMail,
subject: sendMailModel.value.subject,
is_html: sendMailModel.value.contentType != 'text',
content: sendMailModel.value.content,
})
})
sendMailModel.value = {
fromName: "",
fromMail: "",
toName: "",
toMail: "",
subject: "",
contentType: 'text',
content: "",
}
} catch (error) {
message.error(error.message || "error");
} finally {
message.success(t("successSend"));
}
}
const toolbarConfig = {
excludeKeys: ["uploadVideo"]
}
const editorConfig = {
MENU_CONF: {
'uploadImage': {
async customUpload() {
message.error(t('tooLarge'))
},
maxFileSize: 1 * 1024 * 1024,
base64LimitSize: 1 * 1024 * 1024,
}
}
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor;
}
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded>
<n-flex justify="end">
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
</n-flex>
<div class="left">
<n-form :model="sendMailModel">
<n-form-item :label="t('fromName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.fromName" />
<n-input v-model:value="sendMailModel.fromMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('toName')" label-placement="top">
<n-input-group>
<n-input v-model:value="sendMailModel.toName" />
<n-input v-model:value="sendMailModel.toMail" />
</n-input-group>
</n-form-item>
<n-form-item :label="t('subject')" label-placement="top">
<n-input v-model:value="sendMailModel.subject" />
</n-form-item>
<n-form-item :label="t('options')" label-placement="top">
<n-radio-group v-model:value="sendMailModel.contentType">
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
:label="option.label" />
</n-radio-group>
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
style="margin-left: 10px;">
{{ isPreview ? t('edit') : t('preview') }}
</n-button>
</n-form-item>
<n-form-item :label="t('content')" label-placement="top">
<n-card :bordered="false" embedded v-if="isPreview">
<div v-html="sendMailModel.content" />
</n-card>
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
:editor="editorRef" mode="default" />
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
</div>
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
minRows: 3
}" />
</n-form-item>
</n-form>
</div>
</n-card>
</div>
</template>
<style scoped>
.n-card {
max-width: 800px;
}
.n-button {
text-align: left;
margin-right: 10px;
}
.center {
display: flex;
text-align: center;
place-items: center;
justify-content: center;
}
.left {
text-align: left;
place-items: left;
justify-content: left;
}
</style>

View File

@@ -15,25 +15,29 @@ const { t } = useI18n({
init: 'Init',
successTip: 'Success',
status: 'Check Status',
enableTelegramAllowList: 'Enable Telegram Allow List(Manually input user ID)',
enableTelegramAllowList: 'Enable Telegram Allow List(Manually input Chat ID)',
enable: 'Enable',
telegramAllowList: 'Telegram Allow List',
telegramAllowList: 'Telegram Allow List(Manually input telegram Chat ID)',
manualInputPrompt: 'Type and press Enter to add',
save: 'Save',
miniAppUrl: 'Telegram Mini App URL',
enableGlobalMailPush: 'Enable Global Mail Push(Manually input telegram user ID)',
globalMailPushList: 'Global Mail Push List',
enableGlobalMailPush: 'Enable Global Mail Push(Manually input telegram Chat ID)',
globalMailPushList: 'Global Mail Push Chat ID List',
globalMailPushListTip: 'Support chat_id of private chat/group/channel. You can send a message to your bot, then visit this link to see chat_id, https://api.telegram.org/bot<Replace with your BOT TOKEN>/getUpdates',
},
zh: {
init: '初始化',
successTip: '成功',
status: '查看状态',
enableTelegramAllowList: '启用 Telegram 白名单(手动输入用户 ID)',
enableTelegramAllowList: '启用 Telegram 白名单(手动输入 Chat ID, 回车增加)',
enable: '启用',
telegramAllowList: 'Telegram 白名单',
telegramAllowList: 'Telegram 白名单(手动输入 Chat ID, 回车增加)',
manualInputPrompt: '输入后按回车键添加',
save: '保存',
miniAppUrl: '电报小程序 URL(请输入你部署的电报小程序网页地址)',
enableGlobalMailPush: '启用全局邮件推送(手动输入邮箱管理员的 telegram 用户 ID)',
globalMailPushList: '全局邮件推送用户列表',
enableGlobalMailPush: '启用全局邮件推送(手动输入邮箱管理员的 telegram Chat ID, 回车增加)',
globalMailPushList: '全局邮件推送 Chat ID 列表',
globalMailPushListTip: '支持对话/群组/频道的 Chat ID, 您可以发送一条消息给您的机器人,然后访问此链接来查看 chat_id, https://api.telegram.org/bot<这里替换成您的 BOT TOKEN>/getUpdates',
}
}
});
@@ -113,6 +117,17 @@ onMounted(async () => {
<template>
<div class="center">
<n-card :bordered="false" embedded style="max-width: 800px; overflow: auto;">
<n-flex justify="end">
<n-button @click="fetchStatus" secondary>
{{ t('status') }}
</n-button>
<n-button @click="init" type="primary">
{{ t('init') }}
</n-button>
<n-button @click="saveSettings" type="primary">
{{ t('save') }}
</n-button>
</n-flex>
<n-card :bordered="false" embedded>
<n-form-item-row :label="t('enableTelegramAllowList')">
<n-input-group>
@@ -120,31 +135,41 @@ onMounted(async () => {
{{ t('enable') }}
</n-checkbox>
<n-select v-model:value="settings.allowList" filterable multiple tag style="width: 80%;"
:placeholder="t('telegramAllowList')" />
:placeholder="t('telegramAllowList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-input-group>
</n-form-item-row>
<br />
<n-form-item-row :label="t('enableGlobalMailPush')">
<n-input-group>
<n-checkbox v-model:checked="settings.enableGlobalMailPush" style="width: 20%;">
{{ t('enable') }}
</n-checkbox>
<n-select v-model:value="settings.globalMailPushList" filterable multiple tag
style="width: 80%;" :placeholder="t('globalMailPushList')" />
style="width: 80%;" :placeholder="t('globalMailPushList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-input-group>
<template #feedback>
<n-text depth="3">
{{ t('globalMailPushListTip') }}
</n-text>
</template>
</n-form-item-row>
<br />
<n-form-item-row :label="t('miniAppUrl')">
<n-input v-model:value="settings.miniAppUrl"></n-input>
</n-form-item-row>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</n-card>
<n-button @click="init" type="primary" block>
{{ t('init') }}
</n-button>
<n-button @click="fetchStatus" secondary block>
{{ t('status') }}
</n-button>
<pre v-if="status.fetched">{{ JSON.stringify(status, null, 2) }}</pre>
</n-card>
</div>
@@ -157,8 +182,4 @@ onMounted(async () => {
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,94 @@
<script setup>
import { ref, h, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { NBadge } from 'naive-ui'
import { api } from '../../api'
const props = defineProps({
user_id: {
type: Number,
required: true
}
});
const message = useMessage()
const { locale, t } = useI18n({
messages: {
en: {
success: 'success',
name: 'Name',
mail_count: 'Mail Count',
send_count: 'Send Count',
},
zh: {
success: '成功',
name: '名称',
mail_count: '邮件数量',
send_count: '发送数量',
}
}
});
const data = ref([])
const fetchData = async () => {
try {
const { results } = await api.fetch(
`/admin/users/bind_address/${props.user_id}`,
);
data.value = results;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: t('name'),
key: "name"
},
{
title: t('mail_count'),
key: "mail_count",
render(row) {
return h(NBadge, {
value: row.mail_count,
'show-zero': true,
max: 99,
type: "success"
})
}
},
{
title: t('send_count'),
key: "send_count",
render(row) {
return h(NBadge, {
value: row.send_count,
'show-zero': true,
max: 99,
type: "success"
})
}
}
]
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div style="overflow: auto;">
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
</template>
<style scoped>
.n-data-table {
min-width: 700px;
}
</style>

View File

@@ -8,6 +8,8 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
import { hashPassword } from '../../utils';
import UserAddressManagement from './UserAddressManagement.vue'
const { loading, openSettings } = useGlobalState()
const message = useMessage()
@@ -34,6 +36,7 @@ const { t } = useI18n({
prefix: 'Prefix',
domains: 'Domains',
roleDonotExist: 'Current Role does not exist',
userAddressManagement: 'Address Management',
},
zh: {
success: '成功',
@@ -56,6 +59,7 @@ const { t } = useI18n({
prefix: '前缀',
domains: '域名',
roleDonotExist: '当前角色不存在',
userAddressManagement: '地址管理',
}
}
});
@@ -75,6 +79,7 @@ const user = ref({
password: ""
})
const showChangeRole = ref(false)
const showUserAddressManagement = ref(false)
const userRoles = ref([])
const curUserRole = ref('')
const userRolesOptions = computed(() => {
@@ -214,12 +219,25 @@ const columns = [
title: t('address_count'),
key: "address_count",
render(row) {
return h(NBadge, {
value: row.address_count,
'show-zero': true,
max: 99,
type: "success"
})
return h(NButton,
{
text: true,
onClick: () => {
if (row.address_count <= 0) return;
curUserId.value = row.id;
showUserAddressManagement.value = true;
}
},
{
icon: () => h(NBadge, {
value: row.address_count,
'show-zero': true,
max: 99,
type: "success"
}),
default: () => row.address_count > 0 ? t('userAddressManagement') : ""
}
)
}
},
{
@@ -239,6 +257,19 @@ const columns = [
icon: () => h(MenuFilled),
key: "action",
children: [
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curUserId.value = row.id;
showUserAddressManagement.value = true;
}
},
{ default: () => t('userAddressManagement') }
),
show: row.address_count > 0
},
{
label: () => h(NButton,
{
@@ -362,6 +393,9 @@ onMounted(async () => {
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showUserAddressManagement" preset="card" :title="t('userAddressManagement')">
<UserAddressManagement :user_id="curUserId" />
</n-modal>
<n-input-group>
<n-input v-model:value="userQuery" @keydown.enter="fetchData" />
<n-button @click="fetchData" type="primary" tertiary>

View File

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

View File

@@ -18,6 +18,7 @@ const { t } = useI18n({
enableMailVerify: 'Enable Mail Verify (Send address must be an address in the system with a balance and can send mail normally)',
verifyMailSender: 'Verify Mail Sender',
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
manualInputPrompt: 'Type and press Enter to add',
mailAllowList: 'Mail Address Allow List',
maxAddressCount: 'Maximum number of email addresses that can be binded',
},
@@ -28,7 +29,8 @@ const { t } = useI18n({
enableUserRegister: "允许用户注册",
enableMailVerify: '启用邮件验证(发送地址必须是系统中能有余额且能正常发送邮件的地址)',
verifyMailSender: '验证邮件发送地址',
enableMailAllowList: '启用邮件地址白名单(可手动输入)',
enableMailAllowList: '启用邮件地址白名单(可手动输入, 回车增加)',
manualInputPrompt: '输入后按回车键添加',
mailAllowList: '邮件地址白名单',
maxAddressCount: '可绑定最大邮箱地址数量',
}
@@ -83,9 +85,14 @@ onMounted(async () => {
<template>
<div class="center">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-flex justify="end">
<n-button @click="save" type="primary" :loading="loading">
{{ t('save') }}
</n-button>
</n-flex>
<n-form :model="userSettings">
<n-form-item-row :label="t('enableUserRegister')">
<n-checkbox v-model:checked="userSettings.enable" />
<n-switch v-model:value="userSettings.enable" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('enableMailVerify')">
<n-input-group>
@@ -103,7 +110,13 @@ onMounted(async () => {
</n-checkbox>
<n-select v-model:value="userSettings.mailAllowList" v-if="userSettings.enableMailAllowList"
filterable multiple tag style="width: 80%;" :options="mailAllowOptions"
:placeholder="t('mailAllowList')" />
:placeholder="t('mailAllowList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-input-group>
</n-form-item-row>
<n-form-item-row :label="t('maxAddressCount')">
@@ -112,9 +125,6 @@ onMounted(async () => {
:placeholder="t('maxAddressCount')" />
</n-input-group>
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>
</n-form>
</n-card>
</div>

View File

@@ -13,13 +13,17 @@ const { t } = useI18n({
messages: {
en: {
successTip: 'Success',
webhookAllowList: 'Webhook Allow List(Enter the address that is allowed to use webhook)',
enableAllowList: 'Enable Allow List (Restrict webhook access to specific users)',
webhookAllowList: 'Webhook Allow List(Enter the mail address that is allowed to use webhook and enter)',
manualInputPrompt: 'Type and press Enter to add',
save: 'Save',
notEnabled: 'Webhook is not enabled',
},
zh: {
successTip: '成功',
webhookAllowList: 'Webhook 白名单(请输入允许使用webhook 的地址)',
enableAllowList: '启用白名单 (限制 webhook 访问权限,只有白名单中的用户可以使用)',
webhookAllowList: 'Webhook 白名单(请输入允许使用webhook 的邮箱地址, 回车增加)',
manualInputPrompt: '输入后按回车键添加',
save: '保存',
notEnabled: 'Webhook 未开启',
}
@@ -27,14 +31,16 @@ const { t } = useI18n({
});
class WebhookSettings {
enableAllowList: boolean;
allowList: string[];
constructor(allowList: string[]) {
constructor(enableAllowList: boolean, allowList: string[]) {
this.enableAllowList = enableAllowList;
this.allowList = allowList;
}
}
const webhookSettings = ref(new WebhookSettings([]))
const webhookSettings = ref(new WebhookSettings(false, []))
const webhookEnabled = ref(false)
const errorInfo = ref('')
@@ -68,13 +74,24 @@ onMounted(async () => {
<template>
<div class="center">
<n-card v-if="webhookEnabled" :bordered="false" embedded style="max-width: 800px; overflow: auto;">
<n-flex justify="end">
<n-button @click="saveSettings" type="primary">
{{ t('save') }}
</n-button>
</n-flex>
<n-form-item-row :label="t('enableAllowList')">
<n-switch v-model:value="webhookSettings.enableAllowList" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('webhookAllowList')">
<n-select v-model:value="webhookSettings.allowList" filterable multiple tag
:placeholder="t('webhookAllowList')" />
:placeholder="t('webhookAllowList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</n-card>
<n-result v-else status="404" :title="t('notEnabled')" :description="errorInfo" />
</div>

View File

@@ -26,7 +26,7 @@ onMounted(async () => {
<template>
<div class="center">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-card :bordered="false" embedded style="max-width: 800px; overflow: auto;">
<pre>{{ JSON.stringify(settings, null, 2) }}</pre>
</n-card>
</div>

View File

@@ -1,10 +1,13 @@
<script setup>
import { GithubAlt, Discord, Telegram } from '@vicons/fa'
import { useGlobalState } from '../../store'
const { announcement } = useGlobalState()
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded>
<div v-html="announcement"></div>
<n-button tag="a" target="_blank" href="https://github.com/dreamhunter2333/cloudflare_temp_email">
<template #icon>
<n-icon :component="GithubAlt" />

View File

@@ -3,16 +3,23 @@ import { useI18n } from 'vue-i18n'
import { useIsMobile } from '../../utils/composables'
import { useGlobalState } from '../../store'
const props = defineProps({
showUseSimpleIndex: {
type: Boolean,
default: false
}
})
const {
mailboxSplitSize, useIframeShowMail, preferShowTextMail,
globalTabplacement, useSideMargin
mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex
} = useGlobalState()
const isMobile = useIsMobile()
const { t } = useI18n({
messages: {
en: {
useSimpleIndex: 'Use Simple Index',
mailboxSplitSize: 'Mailbox Split Size',
useIframeShowMail: 'Use iframe Show HTML Mail',
preferShowTextMail: 'Display text Mail by default',
@@ -22,8 +29,11 @@ const { t } = useI18n({
top: 'top',
right: 'right',
bottom: 'bottom',
useUTCDate: 'Use UTC Date',
autoRefreshInterval: 'Auto Refresh Interval(Sec)',
},
zh: {
useSimpleIndex: '使用极简主页',
mailboxSplitSize: '邮箱界面分栏大小',
preferShowTextMail: '默认以文本显示邮件',
useIframeShowMail: '使用iframe显示HTML邮件',
@@ -33,6 +43,8 @@ const { t } = useI18n({
top: '顶部',
right: '右侧',
bottom: '底部',
useUTCDate: '使用 UTC 时间',
autoRefreshInterval: '自动刷新间隔(秒)',
}
}
});
@@ -48,12 +60,23 @@ const { t } = useI18n({
0.75: '0.75'
}" />
</n-form-item-row>
<n-form-item-row :label="t('autoRefreshInterval')">
<n-slider v-model:value="configAutoRefreshInterval" :min="30" :max="300" :step="1" :marks="{
60: '60', 120: '120', 180: '180', 240: '240'
}" />
</n-form-item-row>
<n-form-item-row v-if="props.showUseSimpleIndex" :label="t('useSimpleIndex')">
<n-switch v-model:value="useSimpleIndex" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('preferShowTextMail')">
<n-switch v-model:value="preferShowTextMail" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('useIframeShowMail')">
<n-switch v-model:value="useIframeShowMail" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('useUTCDate')">
<n-switch v-model:value="useUTCDate" :round="false" />
</n-form-item-row>
<n-form-item-row v-if="!isMobile" :label="t('useSideMargin')">
<n-switch v-model:value="useSideMargin" :round="false" />
</n-form-item-row>

View File

@@ -9,7 +9,7 @@ import Turnstile from '../../components/Turnstile.vue'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
import { getRouterPathWithLang, hashPassword } from '../../utils'
const props = defineProps({
bindUserAddress: {
@@ -34,11 +34,12 @@ const props = defineProps({
})
const message = useMessage()
const notification = useNotification()
const router = useRouter()
const {
jwt, loading, openSettings,
showAddressCredential, userSettings
showAddressCredential, userSettings, addressPassword
} = useGlobalState()
const tabValue = ref('signin')
@@ -46,8 +47,47 @@ const credential = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const loginMethod = ref('credential') // 'credential' or 'password'
const loginAddress = ref('')
const loginPassword = ref('')
// 根据 openSettings 初始化登录方式
const initLoginMethod = () => {
if (openSettings.value?.enableAddressPassword) {
loginMethod.value = 'password';
} else {
loginMethod.value = 'credential';
}
}
const login = async () => {
if (loginMethod.value === 'password') {
// Password login
if (!loginAddress.value || !loginPassword.value) {
message.error(t('emailPasswordRequired'));
return;
}
try {
const res = await api.fetch('/api/address_login', {
method: 'POST',
body: JSON.stringify({
email: loginAddress.value,
password: await hashPassword(loginPassword.value)
})
});
jwt.value = res.jwt;
await api.getSettings();
try {
await props.bindUserAddress();
} catch (error) {
message.error(`${t('bindUserAddressError')}: ${error.message}`);
}
await router.push(getRouterPathWithLang("/", locale.value));
} catch (error) {
message.error(error.message || "error");
}
return;
}
if (!credential.value) {
message.error(t('credentialInput'));
return;
@@ -70,6 +110,7 @@ const { locale, t } = useI18n({
messages: {
en: {
login: 'Login',
loginAndBind: 'Login and Bind',
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
getNewEmail: 'Create New Email',
getNewEmailTip1: 'Please input the email you want to use. only allow: ',
@@ -82,9 +123,16 @@ const { locale, t } = useI18n({
credentialInput: 'Please input the Mail Address Credential',
bindUserInfo: 'Logged in user, login without binding email or create new email address will bind to current user',
bindUserAddressError: 'Error when bind email address to user',
autoGeneratedName: 'Auto-generated name',
passwordLogin: 'Password Login',
credentialLogin: 'Credential Login',
email: 'Email',
password: 'Password',
emailPasswordRequired: 'Email and password are required',
},
zh: {
login: '登录',
loginAndBind: '登录并绑定',
pleaseGetNewEmail: '请"登录"或点击 "注册新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '创建新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许: ',
@@ -97,10 +145,23 @@ const { locale, t } = useI18n({
credentialInput: '请输入邮箱地址凭据',
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
bindUserAddressError: '绑定邮箱地址到用户时错误',
autoGeneratedName: '自动生成名称',
passwordLogin: '密码登录',
credentialLogin: '凭据登录',
email: '邮箱',
password: '密码',
emailPasswordRequired: '邮箱和密码不能为空',
}
}
});
const loginAndBindTag = computed(() => {
if (userSettings.value.user_email) {
return t('loginAndBind')
}
return t('login')
})
const addressRegex = computed(() => {
try {
if (openSettings.value.addressRegex) {
@@ -124,6 +185,10 @@ const generateName = async () => {
.replace(/\.{2,}/g, '.')
.replace(addressRegex.value, '')
.toLowerCase();
// support maxAddressLen
if (emailName.value.length > openSettings.value.maxAddressLen) {
emailName.value = emailName.value.slice(0, openSettings.value.maxAddressLen);
}
} catch (error) {
message.error(error.message || "error");
} finally {
@@ -133,12 +198,15 @@ const generateName = async () => {
const newEmail = async () => {
try {
// If custom names are disabled, send empty name to trigger backend auto-generation
const nameToSend = openSettings.value.disableCustomAddressName ? "" : emailName.value;
const res = await props.newAddressPath(
emailName.value,
nameToSend,
emailDomain.value,
cfToken.value
);
jwt.value = res["jwt"];
addressPassword.value = res["password"] || '';
await api.getSettings();
await router.push(getRouterPathWithLang("/", locale.value));
showAddressCredential.value = true;
@@ -180,11 +248,21 @@ const domainsOptions = computed(() => {
});
});
const showNewAddressTab = computed(() => {
if (openSettings.value.disableAnonymousUserCreateEmail
&& !userSettings.value.user_email
) {
return false;
}
return openSettings.value.enableUserCreateEmail;
});
onMounted(async () => {
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
await api.getOpenSettings();
await api.getOpenSettings(message, notification);
}
emailDomain.value = domainsOptions.value ? domainsOptions.value[0]?.value : "";
initLoginMethod();
});
</script>
@@ -194,19 +272,38 @@ onMounted(async () => {
<span>{{ t('bindUserInfo') }}</span>
</n-alert>
<n-tabs v-if="openSettings.fetched" v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tab-pane name="signin" :tab="t('login')">
<n-tab-pane name="signin" :tab="loginAndBindTag">
<n-form>
<n-form-item-row :label="t('credential')" required>
<n-input v-model:value="credential" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<div v-if="loginMethod === 'password'">
<n-form-item-row :label="t('email')" required>
<n-input v-model:value="loginAddress" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="loginPassword" type="password" show-password-on="click" />
</n-form-item-row>
</div>
<div v-else>
<n-form-item-row :label="t('credential')" required>
<n-input v-model:value="credential" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
</div>
<div class="switch-login-button">
<n-button v-if="openSettings?.enableAddressPassword"
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
type="info" quaternary size="tiny">
{{ loginMethod === 'password' ? t('credentialLogin') : t('passwordLogin') }}
</n-button>
</div>
<n-button @click="login" :loading="loading" type="primary" block secondary strong>
<template #icon>
<n-icon :component="EmailOutlined" />
</template>
{{ t('login') }}
{{ loginAndBindTag }}
</n-button>
<n-button v-if="openSettings.enableUserCreateEmail" @click="tabValue = 'register'" block secondary
strong>
<n-button v-if="showNewAddressTab" @click="tabValue = 'register'" block secondary strong>
<template #icon>
<n-icon :component="NewLabelOutlined" />
</template>
@@ -214,23 +311,26 @@ onMounted(async () => {
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableUserCreateEmail" name="register" :tab="t('getNewEmail')">
<n-tab-pane v-if="showNewAddressTab" name="register" :tab="t('getNewEmail')">
<n-spin :show="generateNameLoading">
<n-form>
<span>
<p>{{ t("getNewEmailTip1") + addressRegex.source }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p v-if="!openSettings.disableCustomAddressName">{{ t("getNewEmailTip1") +
addressRegex.source }}</p>
<p v-if="!openSettings.disableCustomAddressName">{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>
<n-button @click="generateName" style="margin-bottom: 10px;">
<n-button v-if="!openSettings.disableCustomAddressName" @click="generateName"
style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="addressPrefix">
{{ addressPrefix }}
</n-input-group-label>
<n-input v-model:value="emailName" show-count :minlength="openSettings.minAddressLen"
:maxlength="openSettings.maxAddressLen" />
<n-input v-if="!openSettings.disableCustomAddressName" v-model:value="emailName" show-count
:minlength="openSettings.minAddressLen" :maxlength="openSettings.maxAddressLen" />
<n-input v-else :value="t('autoGeneratedName')" disabled />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="domainsOptions" />
@@ -267,6 +367,12 @@ onMounted(async () => {
margin-top: 10px;
}
.switch-login-button {
display: flex;
justify-content: center;
margin: 10px 0;
}
.n-form {
text-align: left;
}

View File

@@ -5,34 +5,59 @@ import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import Appearance from '../common/Appearance.vue'
import { hashPassword } from '../../utils'
import { getRouterPathWithLang } from '../../utils'
const {
jwt, settings, showAddressCredential, loading
jwt, settings, showAddressCredential, loading, openSettings
} = useGlobalState()
const router = useRouter()
const message = useMessage()
const showLogout = ref(false)
const showDelteAccount = ref(false)
const showDeleteAccount = ref(false)
const showClearInbox = ref(false)
const showClearSentItems = ref(false)
const showChangePassword = ref(false)
const newPassword = ref('')
const confirmPassword = ref('')
const { locale, t } = useI18n({
messages: {
en: {
logout: "Logout",
delteAccount: "Delete Account",
deleteAccount: "Delete Account",
showAddressCredential: 'Show Address Credential',
logoutConfirm: 'Are you sure to logout?',
delteAccount: "Delete Account",
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
deleteAccount: "Delete Account",
deleteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
clearInbox: "Clear Inbox",
clearSentItems: "Clear Sent Items",
clearInboxConfirm: "Are you sure to clear all emails in your inbox?",
clearSentItemsConfirm: "Are you sure to clear all emails in your sent items?",
success: "Success",
changePassword: "Change Password",
newPassword: "New Password",
confirmPassword: "Confirm Password",
passwordMismatch: "Passwords do not match",
passwordChanged: "Password changed successfully",
},
zh: {
logout: '退出登录',
delteAccount: "删除账户",
deleteAccount: "删除账户",
showAddressCredential: '查看邮箱地址凭证',
logoutConfirm: '确定要退出登录吗?',
delteAccount: "删除账户",
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
deleteAccount: "删除账户",
deleteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
clearInbox: "清空收件箱",
clearSentItems: "清空发件箱",
clearInboxConfirm: "确定要清空你收件箱中的所有邮件吗?",
clearSentItemsConfirm: "确定要清空你发件箱中的所有邮件吗?",
success: "成功",
changePassword: "修改密码",
newPassword: "新密码",
confirmPassword: "确认密码",
passwordMismatch: "密码不匹配",
passwordChanged: "密码修改成功",
}
}
});
@@ -55,35 +80,125 @@ const deleteAccount = async () => {
message.error(error.message || "error");
}
};
const clearInbox = async () => {
try {
await api.fetch(`/api/clear_inbox`, {
method: 'DELETE'
});
message.success(t("success"));
} catch (error) {
message.error(error.message || "error");
} finally {
showClearInbox.value = false;
}
};
const clearSentItems = async () => {
try {
await api.fetch(`/api/clear_sent_items`, {
method: 'DELETE'
});
message.success(t("success"));
} catch (error) {
message.error(error.message || "error");
} finally {
showClearSentItems.value = false;
}
};
const changePassword = async () => {
if (newPassword.value !== confirmPassword.value) {
message.error(t("passwordMismatch"));
return;
}
try {
await api.fetch(`/api/address_change_password`, {
method: 'POST',
body: JSON.stringify({
new_password: await hashPassword(newPassword.value)
})
});
message.success(t("passwordChanged"));
newPassword.value = '';
confirmPassword.value = '';
showChangePassword.value = false;
} catch (error) {
message.error(error.message || "error");
}
};
</script>
<template>
<div class="center" v-if="settings.address">
<n-card :bordered="false" embedded>
<Appearance />
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }}
</n-button>
<n-button v-if="openSettings?.enableAddressPassword" @click="showChangePassword = true" type="info" secondary block strong>
{{ t('changePassword') }}
</n-button>
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showClearInbox = true" type="warning" secondary
block strong>
{{ t('clearInbox') }}
</n-button>
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showClearSentItems = true" type="warning"
secondary block strong>
{{ t('clearSentItems') }}
</n-button>
<n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }}
</n-button>
<n-button @click="showDelteAccount = true" type="error" secondary block strong>
{{ t('delteAccount') }}
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary
block strong>
{{ t('deleteAccount') }}
</n-button>
</n-card>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="logout" size="small" tertiary type="primary">
<n-button :loading="loading" @click="logout" size="small" tertiary type="warning">
{{ t('logout') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
<p>{{ t('delteAccountConfirm') }}</p>
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<p>{{ t('deleteAccountConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
{{ t('delteAccount') }}
{{ t('deleteAccount') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showClearInbox" preset="dialog" :title="t('clearInbox')">
<p>{{ t('clearInboxConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="clearInbox" size="small" tertiary type="warning">
{{ t('clearInbox') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showClearSentItems" preset="dialog" :title="t('clearSentItems')">
<p>{{ t('clearSentItemsConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="clearSentItems" size="small" tertiary type="warning">
{{ t('clearSentItems') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showChangePassword" preset="dialog" :title="t('changePassword')">
<n-form :model="{ newPassword, confirmPassword }">
<n-form-item :label="t('newPassword')">
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
</n-form-item>
<n-form-item :label="t('confirmPassword')">
<n-input v-model:value="confirmPassword" type="password" placeholder="" show-password-on="click" />
</n-form-item>
</n-form>
<template #action>
<n-button :loading="loading" @click="changePassword" size="small" tertiary type="info">
{{ t('changePassword') }}
</n-button>
</template>
</n-modal>

View File

@@ -1,76 +1,54 @@
<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
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 showAddressManage = ref(false)
const copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
}
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${jwt.value}`
}
const onUserLogin = async () => {
@@ -89,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>
@@ -127,22 +101,31 @@ 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>
</span>
<n-card :bordered="false" embedded>
<n-card embedded>
<b>{{ jwt }}</b>
</n-card>
<n-card embedded v-if="addressPassword">
<p><b>{{ settings.address }}</b></p>
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
</n-card>
<n-card embedded>
<n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'>
<n-card embedded>
<b>{{ getUrlWithJwt() }}</b>
</n-card>
</n-collapse-item>
</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>
@@ -165,4 +148,9 @@ onMounted(async () => {
justify-content: center;
margin: 20px;
}
.address-manage {
flex: 0 0 auto;
white-space: nowrap;
}
</style>

View File

@@ -3,6 +3,7 @@ import { ref, h, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { api } from '../../api'
import { NPopconfirm } from 'naive-ui';
const message = useMessage()
@@ -11,10 +12,16 @@ const { t } = useI18n({
en: {
download: 'Download',
action: 'Action',
delete: 'Delete',
deleteConfirm: 'Are you sure to delete this attachment?',
deleteSuccess: 'Deleted successfully',
},
zh: {
download: '下载',
action: '操作',
delete: '删除',
deleteConfirm: '确定要删除此附件吗?',
deleteSuccess: '删除成功',
}
}
});
@@ -66,6 +73,34 @@ const columns = [
}
},
{ default: () => t('download') }
),
h(NPopconfirm,
{
onPositiveClick: async () => {
try {
await api.fetch(`/api/attachment/delete`, {
method: 'POST',
body: JSON.stringify({ key: row.key })
});
message.success(t('deleteSuccess'));
await fetchData();
}
catch (error) {
console.error(error);
message.error(error.message || "error");
}
},
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "error",
},
{ default: () => t('delete') }
),
default: () => t('deleteConfirm')
}
)
])
}

View File

@@ -22,7 +22,7 @@ const { t } = useI18n({
actions: 'Actions',
changeMailAddress: 'Change Mail Address',
unbindMailAddress: 'Unbind Mail Address credential',
bind: 'Bind',
create_or_bind: 'Create or Bind',
bindAddressSuccess: 'Bind Address Success',
},
zh: {
@@ -32,7 +32,7 @@ const { t } = useI18n({
actions: '操作',
changeMailAddress: '切换邮箱地址',
unbindMailAddress: '解绑邮箱地址',
bind: '绑定',
create_or_bind: '创建或绑定',
bindAddressSuccess: '绑定地址成功',
}
}
@@ -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, "/")
@@ -151,7 +151,7 @@ const columns = [
<n-tab-pane name="address" :tab="t('address')">
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</n-tab-pane>
<n-tab-pane name="bind" :tab="t('bind')">
<n-tab-pane name="create_or_bind" :tab="t('create_or_bind')">
<Login :bindUserAddress="bindAddress" />
</n-tab-pane>
</n-tabs>

View File

@@ -0,0 +1,284 @@
<script setup>
import { ref, onMounted, computed, watch, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import {
ExitToAppFilled,
ContentCopyFilled,
RefreshFilled,
ArrowBackIosNewFilled,
ArrowForwardIosFilled,
SettingsFilled
} from '@vicons/material'
import { useGlobalState } from '../../store'
import { api } from '../../api'
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()
// 邮件数据
const currentPage = ref(1)
const totalCount = ref(0)
const currentMail = ref(null)
const showAccountSettingsCard = ref(false)
const currentAutoRefreshInterval = ref(60)
const timer = ref(null)
const { t } = useI18n({
messages: {
en: {
exitSimpleIndex: 'Exit Simple',
copyAddress: 'Copy',
addressCopied: 'Address copied successfully',
refreshMails: 'Refresh',
noMails: 'No mails found',
prevPage: 'Previous',
nextPage: 'Next',
refreshSuccess: 'Mails refreshed successfully',
mailCount: '{current} / {total} emails',
accountSettings: "Account Settings",
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login',
deleteSuccess: 'Mail deleted successfully',
refreshAfter: 'Refresh After {msg} Seconds',
},
zh: {
exitSimpleIndex: '退出极简',
copyAddress: '复制',
addressCopied: '地址复制成功',
refreshMails: '刷新',
noMails: '暂无邮件',
prevPage: '上一页',
nextPage: '下一页',
refreshSuccess: '邮件刷新成功',
mailCount: '{current} / {total} 封邮件',
accountSettings: "账户设置",
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
deleteSuccess: '邮件删除成功',
refreshAfter: '{msg}秒后刷新',
}
}
})
// 复制地址
const copyAddress = async () => {
try {
await navigator.clipboard.writeText(settings.value.address)
message.success(t('addressCopied'))
} catch (error) {
message.error('复制失败')
}
}
// 获取邮件数据
const fetchMails = async () => {
if (!settings.value.address) return
try {
const { results, count } = await api.fetch(`/api/mails?limit=1&offset=${currentPage.value - 1}`)
totalCount.value = count > 0 ? count : totalCount.value;
const rawMail = results && results.length > 0 ? results[0] : null
currentMail.value = rawMail ? await processItem(rawMail) : null
} catch (error) {
console.error('Failed to fetch mails:', error)
message.error('获取邮件失败')
}
}
// 删除邮件
const deleteMail = async () => {
if (!currentMail.value) return;
try {
await api.fetch(`/api/mails/${currentMail.value.id}`, { method: 'DELETE' });
message.success(t('deleteSuccess'));
currentMail.value = null;
await refreshMails();
} catch (error) {
console.error('Failed to delete mail:', error);
message.error('删除邮件失败');
}
}
// 刷新邮件
const refreshMails = async () => {
if (loading.value) return
currentPage.value = 1
showAccountSettingsCard.value = false
currentAutoRefreshInterval.value = 60
await fetchMails()
message.success(t('refreshSuccess'))
}
// 分页控制
const currentPageDisplay = computed(() => currentPage.value)
const totalPages = computed(() => Math.max(1, totalCount.value))
const canGoPrev = computed(() => currentPage.value > 1)
const canGoNext = computed(() => currentPage.value < totalPages.value)
const isFirstPage = computed(() => currentPage.value === 1)
const prevPage = async () => {
if (canGoPrev.value) {
currentPage.value--
}
}
const nextPage = async () => {
if (canGoNext.value) {
currentPage.value++
}
}
// 监听页面变化
watch(currentPage, () => {
fetchMails()
})
onMounted(async () => {
await api.getSettings()
await fetchMails()
// 启动自动刷新
timer.value = setInterval(async () => {
if (!isFirstPage.value) {
currentAutoRefreshInterval.value = 60
return
}
if (--currentAutoRefreshInterval.value <= 0) {
await refreshMails()
}
}, 1000)
})
onBeforeUnmount(() => {
clearInterval(timer.value)
})
</script>
<template>
<div class="center">
<div v-if="!settings.address">
<n-card :bordered="false" embedded>
<Login />
</n-card>
</div>
<div v-else>
<n-card :bordered="false" embedded>
<div style="text-align: center; margin-bottom: 16px; font-size: 18px;">
<AddressSelect :showCopy="false" size="small" />
</div>
<n-flex justify="center">
<n-button @click="refreshMails" :loading="loading" type="primary" tertiary size="small">
<template #icon>
<n-icon>
<RefreshFilled />
</n-icon>
</template>
{{ t('refreshMails') }}
</n-button>
<n-button @click="copyAddress" tertiary size="small">
<template #icon>
<n-icon>
<ContentCopyFilled />
</n-icon>
</template>
{{ t('copyAddress') }}
</n-button>
<n-button @click="useSimpleIndex = false" tertiary size="small">
<template #icon>
<n-icon>
<ExitToAppFilled />
</n-icon>
</template>
{{ t('exitSimpleIndex') }}
</n-button>
<n-button @click="showAccountSettingsCard = true" tertiary size="small">
<template #icon>
<n-icon>
<SettingsFilled />
</n-icon>
</template>
{{ t('accountSettings') }}
</n-button>
</n-flex>
<div v-if="isFirstPage" style="text-align: center; margin-top: 12px;">
<n-text depth="3" size="12">
{{ t('refreshAfter', { msg: Math.max(0, currentAutoRefreshInterval) }) }}
</n-text>
</div>
</n-card>
<!-- 账户设置卡片 -->
<n-card v-if="showAccountSettingsCard" :bordered="false" embedded closable
@close="showAccountSettingsCard = false" :title="t('accountSettings')">
<AccountSettings />
</n-card>
<n-card v-else :bordered="false" embedded style="text-align: left;">
<div v-if="totalCount > 1">
<n-flex justify="space-between">
<n-button @click="prevPage" :disabled="!canGoPrev" text size="small">
<template #icon>
<n-icon>
<ArrowBackIosNewFilled />
</n-icon>
</template>
{{ t('prevPage') }}
</n-button>
<n-text size="small">
{{ t('mailCount', { current: currentPageDisplay, total: totalCount }) }}
</n-text>
<n-button @click="nextPage" :disabled="!canGoNext" text size="small" icon-placement="right">
<template #icon>
<n-icon>
<ArrowForwardIosFilled />
</n-icon>
</template>
{{ t('nextPage') }}
</n-button>
</n-flex>
</div>
<div v-if="!currentMail" class="no-mail">
<n-empty :description="t('noMails')" />
</div>
<div v-else>
<h3 v-if="currentMail.subject">{{ currentMail.subject }}</h3>
<div style="margin-top: 16px;">
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
:onDelete="deleteMail" />
</div>
</div>
</n-card>
</div>
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<b>{{ jwt }}</b>
</n-card>
</n-modal>
</div>
</template>
<style scoped>
.center {
max-width: 800px;
margin: 0 auto;
}
.n-card {
margin-top: 20px;
width: 100%;
}
</style>

View File

@@ -5,8 +5,9 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
import { onMounted, watch } from 'vue';
import { processItem } from '../../utils/email-parser'
import { utcToLocalDate } from '../../utils';
const { telegramApp, loading } = useGlobalState()
const { telegramApp, loading, useUTCDate } = useGlobalState()
const route = useRoute()
const curMail = ref({});
@@ -45,12 +46,12 @@ onMounted(async () => {
<template>
<div class="center">
<n-card :bordered="false" embedded v-if="curMail.message" style="max-width: 800px; overflow: auto;">
<n-card :bordered="false" embedded v-if="curMail.message" style="max-width: 800px; height: 100%;">
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
Date: {{ curMail.created_at }}
Date: {{ utcToLocalDate(curMail.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
@@ -58,7 +59,8 @@ onMounted(async () => {
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<div v-html="curMail.message" style="margin-top: 10px;"></div>
<iframe :srcdoc="curMail.message" style="margin-top: 10px;width: 100%; height: 100%;">
</iframe>
</n-card>
</div>
</template>
@@ -70,5 +72,6 @@ onMounted(async () => {
text-align: left;
place-items: center;
justify-content: center;
height: 80vh;
}
</style>

View File

@@ -8,6 +8,8 @@ import { useGlobalState } from '../../store'
import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
import Login from '../common/Login.vue';
const { jwt } = useGlobalState()
const message = useMessage()
const router = useRouter()
@@ -20,9 +22,14 @@ const { locale, t } = useI18n({
mail_count: 'Mail Count',
send_count: 'Send Count',
actions: 'Actions',
changeMailAddress: 'Change Mail Address',
changeMailAddress: 'Change Address',
unbindAddress: 'Unbind Address',
unbindAddressTip: 'Before unbinding, please switch to this email address and save the email address credential.',
transferAddress: 'Transfer Address',
targetUserEmail: 'Target User Email',
transferAddressTip: 'Transfer address to another user will remove the address from your account and transfer it to another user. Are you sure to transfer the address?',
address: 'Address',
create_or_bind: 'Create or Bind',
},
zh: {
success: '成功',
@@ -30,14 +37,23 @@ const { locale, t } = useI18n({
mail_count: '邮件数量',
send_count: '发送数量',
actions: '操作',
changeMailAddress: '切换邮箱地址',
changeMailAddress: '切换地址',
unbindAddress: '解绑地址',
unbindAddressTip: '解绑前请切换到此邮箱地址并保存邮箱地址凭证。',
transferAddress: '转移地址',
targetUserEmail: '目标用户邮箱',
transferAddressTip: '转移地址到其他用户将会从你的账户中移除此地址并转移给其他用户。确定要转移地址吗?',
address: '地址',
create_or_bind: '创建或绑定',
}
}
});
const data = ref([])
const showTranferAddress = ref(false)
const currentAddress = ref("")
const currentAddressId = ref(0)
const targetUserEmail = ref('')
const changeMailAddress = async (address_id) => {
try {
@@ -70,15 +86,41 @@ const unbindAddress = async (address_id) => {
}
}
const transferAddress = async () => {
if (!targetUserEmail.value) {
message.error("targetUserEmail is required");
return;
}
if (!currentAddressId.value) {
message.error("currentAddressId is required");
return;
}
try {
const res = await api.fetch(`/user_api/transfer_address`, {
method: 'POST',
body: JSON.stringify({
address_id: currentAddressId.value,
target_user_email: targetUserEmail.value
})
});
message.success(t('transferAddress') + " " + t('success'));
await fetchData();
showTranferAddress.value = false;
currentAddressId.value = 0;
currentAddress.value = "";
targetUserEmail.value = "";
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
const { results } = await api.fetch(
`/user_api/bind_address`
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
@@ -86,10 +128,6 @@ const fetchData = async () => {
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('name'),
key: "name"
@@ -138,6 +176,18 @@ const columns = [
default: () => `${t('changeMailAddress')}?`
}
),
h(NButton,
{
tertiary: true,
type: "primary",
onClick: () => {
currentAddressId.value = row.id;
currentAddress.value = row.name;
showTranferAddress.value = true;
}
},
{ default: () => t('transferAddress') }
),
h(NPopconfirm,
{
onPositiveClick: () => unbindAddress(row.id)
@@ -164,8 +214,29 @@ onMounted(async () => {
</script>
<template>
<div style="overflow: auto;">
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
<div>
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
<span>
<p>{{ t("transferAddressTip") }}</p>
<p>{{ t('transferAddress') + ": " + currentAddress }}</p>
<n-input v-model:value="targetUserEmail" :placeholder="t('targetUserEmail')" />
</span>
<template #action>
<n-button :loading="loading" @click="transferAddress" size="small" tertiary type="error">
{{ t('transferAddress') }}
</n-button>
</template>
</n-modal>
<n-tabs type="segment">
<n-tab-pane name="address" :tab="t('address')">
<div style="overflow: auto;">
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
</n-tab-pane>
<n-tab-pane name="create_or_bind" :tab="t('create_or_bind')">
<Login />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

@@ -233,6 +233,9 @@ onMounted(async () => {
</n-button>
<n-button @click="oauth2Login(item.clientID)" v-for="item in userOpenSettings.oauth2ClientIDs"
:key="item.clientID" block secondary strong>
<template #icon v-if="item.icon">
<span class="oauth2-icon" v-html="item.icon"></span>
</template>
{{ t('loginWith', { provider: item.name }) }}
</n-button>
</n-form>
@@ -305,4 +308,17 @@ onMounted(async () => {
.n-button {
margin-top: 10px;
}
.oauth2-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
.oauth2-icon :deep(svg) {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,84 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
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',
query: 'Query',
},
zh: {
addressQueryTip: '留空查询所有地址',
query: '查询',
}
}
});
const mailBoxKey = ref("")
const addressFilter = ref();
const addressFilterOptions = ref([]);
const queryMail = () => {
addressFilter.value = addressFilter.value ? addressFilter.value.trim() : addressFilter.value;
mailBoxKey.value = Date.now();
}
const fetchMailData = async (limit, offset) => {
return await api.fetch(
`/user_api/mails`
+ `?limit=${limit}`
+ `&offset=${offset}`
+ (addressFilter.value ? `&address=${addressFilter.value}` : '')
);
}
const fetchAddresData = async () => {
try {
const { results } = await api.fetch(
`/user_api/bind_address`
);
addressFilterOptions.value = results.map((item) => {
return {
label: item.name,
value: item.name
}
});
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const deleteMail = async (curMailId) => {
await api.fetch(`/user_api/mails/${curMailId}`, { method: 'DELETE' });
};
watch(addressFilter, async (newValue) => {
queryMail();
});
onMounted(() => {
fetchAddresData();
});
</script>
<template>
<div style="margin-top: 10px;">
<n-input-group>
<n-select v-model:value="addressFilter" :options="addressFilterOptions" clearable
:placeholder="t('addressQueryTip')" />
<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" :showFilterInput="true" />
</div>
</template>

View File

@@ -232,7 +232,7 @@ const renamePasskey = async () => {
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
<n-button :loading="loading" @click="logout" size="small" tertiary type="primary">
<n-button :loading="loading" @click="logout" size="small" tertiary type="warning">
{{ t('logout') }}
</n-button>
</template>

View File

@@ -24,6 +24,7 @@ export default defineConfig({
{
'naive-ui': [
'useMessage',
'useNotification',
'NButton',
'NPopconfirm',
'NIcon',
@@ -35,13 +36,16 @@ export default defineConfig({
resolvers: [NaiveUiResolver()]
}),
VitePWA({
registerType: 'autoUpdate',
registerType: null,
devOptions: {
enabled: true
enabled: false
},
workbox: {
disableDevLogs: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
globPatterns: [],
runtimeCaching: [],
navigateFallback: null,
cleanupOutdatedCaches: true,
},
manifest: {
name: 'Temp Email',
@@ -65,5 +69,10 @@ export default defineConfig({
},
define: {
'import.meta.env.PACKAGE_VERSION': JSON.stringify(process.env.npm_package_version),
},
esbuild: {
supported: {
'top-level-await': true
},
}
})

View File

@@ -1,6 +1,6 @@
[package]
name = "mail-parser-wasm"
version = "0.1.8"
version = "0.2.1"
edition = "2021"
description = "A simple mail parser for wasm"
license = "MIT"
@@ -9,5 +9,5 @@ license = "MIT"
crate-type = ["cdylib"]
[dependencies]
mail-parser = "0.9.3"
wasm-bindgen = "0.2.92"
mail-parser = "0.9.4"
wasm-bindgen = "0.2.99"

View File

@@ -35,10 +35,31 @@ impl AttachmentResult {
}
}
#[derive(Clone)]
#[wasm_bindgen]
pub struct MessageHeader {
key: String,
value: String,
}
#[wasm_bindgen]
impl MessageHeader {
#[wasm_bindgen(getter)]
pub fn key(&self) -> String {
self.key.clone()
}
#[wasm_bindgen(getter)]
pub fn value(&self) -> String {
self.value.clone()
}
}
#[wasm_bindgen]
pub struct MessageResult {
sender: String,
subject: String,
headers: Vec<MessageHeader>,
body_html: String,
text: String,
attachments: Vec<AttachmentResult>,
@@ -56,6 +77,11 @@ impl MessageResult {
self.subject.clone()
}
#[wasm_bindgen(getter)]
pub fn headers(&self) -> Vec<MessageHeader> {
self.headers.clone()
}
#[wasm_bindgen(getter)]
pub fn body_html(&self) -> String {
self.body_html.clone()
@@ -119,6 +145,7 @@ pub fn parse_message(raw_message: &str) -> MessageResult {
return MessageResult {
sender: String::new(),
subject: String::new(),
headers: Vec::new(),
body_html: String::new(),
text: String::new(),
attachments: Vec::new(),
@@ -146,6 +173,14 @@ pub fn parse_message(raw_message: &str) -> MessageResult {
.subject()
.map(|subject| subject.to_owned())
.unwrap_or(String::new()),
headers: message
.headers()
.iter()
.map(|header| MessageHeader {
key: header.name().to_owned(),
value: header.value().as_text().unwrap_or("").to_owned(),
})
.collect(),
body_html: message
.body_html(0)
.map(|html| html.into_owned())

View File

@@ -1,12 +1,12 @@
import initAsync, { initSync, parse_message } from './mail_parser_wasm';
import MODULE from './mail_parser_wasm_bg.wasm';
initSync(MODULE);
initSync({ module: MODULE });
export { initAsync, MODULE };
export * from './mail_parser_wasm';
export const parse_message_wrapper = (raw_message) => {
initSync(MODULE);
initSync({ module: MODULE });
return parse_message(raw_message);
}

View File

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

View File

@@ -3,7 +3,8 @@ const API_PATHS = [
"/open_api/",
"/user_api/",
"/admin/",
"/telegram/"
"/telegram/",
"/external/",
];
export async function onRequest(context) {

View File

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

19
scripts/update-dependencies.sh Executable file
View File

@@ -0,0 +1,19 @@
cd frontend/
pnpm up
pnpm add -D wrangler@latest
cd ..
cd worker/
pnpm up
pnpm add -D wrangler@latest
cd ..
cd pages/
pnpm up
pnpm add -D wrangler@latest
cd ..
cd vitepress-docs/
pnpm up --latest
pnpm add -D wrangler@latest
cd ..

View File

@@ -65,6 +65,31 @@ class SimpleMailbox:
self.addListener = self.listeners.append
self.removeListener = self.listeners.remove
self.message_count = 0
self._update_message_count()
def _update_message_count(self):
"""主动获取邮件总数"""
try:
if self.name == "INBOX":
endpoint = "/api/mails"
elif self.name == "SENT":
endpoint = "/api/sendbox"
else:
return
res = httpx.get(
f"{settings.proxy_url}{endpoint}?limit=1&offset=0",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)
if res.status_code == 200:
self.message_count = res.json()["count"]
# _logger.info(f"Updated {self.name} message count: {self.message_count}")
except Exception as e:
_logger.error(f"Failed to update message count for {self.name}: {e}")
def getFlags(self):
return ["\\Seen"]
@@ -73,7 +98,9 @@ class SimpleMailbox:
return 0
def getMessageCount(self):
return self.message_count or 1000
# 每次请求时更新邮件总数
self._update_message_count()
return self.message_count
def getRecentCount(self):
return 0
@@ -91,6 +118,8 @@ class SimpleMailbox:
return "/"
def requestStatus(self, names):
# 在状态请求时也更新邮件总数
self._update_message_count()
r = {}
if "MESSAGES" in names:
r["MESSAGES"] = self.getMessageCount()
@@ -105,65 +134,99 @@ class SimpleMailbox:
return defer.succeed(r)
def fetch(self, messages, uid):
"""边查边返回邮件"""
result = []
for range_item in messages.ranges:
start, end = range_item
_logger.info(f"Fetching messages: {self.name}, range: {start}-{end}")
for email_data in self.fetchGenerator(start, end):
result.append(email_data)
# 返回列表而不是生成器,以支持 IMAP SEARCH 等需要索引访问的操作
return result
def fetchGenerator(self, start, end):
"""通用的邮件获取生成器,边查边返回"""
start = max(start, 1)
# 根据邮箱类型确定API端点
if self.name == "INBOX":
return self.fetchINBOX(messages)
if self.name == "SENT":
return self.fetchSENT(messages)
return []
endpoint = "/api/mails"
elif self.name == "SENT":
endpoint = "/api/sendbox"
else:
return
def fetchINBOX(self, messages):
start, end = messages.ranges[0]
start = max(start, 1)
limit = min(20, end - start + 1) if end and end >= start else 20
if self.message_count > 0 and start > self.message_count:
return []
res = httpx.get(
f"{settings.proxy_url}/api/mails?limit={limit}&offset={start - 1}",
# 首先获取服务端邮件总数
count_res = httpx.get(
f"{settings.proxy_url}{endpoint}?limit=1&offset=0",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)
if res.status_code != 200:
if count_res.status_code != 200:
_logger.error(
"Failed: "
f"code=[{res.status_code}] text=[{res.text}]"
f"Failed to get {self.name} email count: "
f"code=[{count_res.status_code}] text=[{count_res.text}]"
)
raise Exception("Failed to fetch emails")
if res.json()["count"] > 0:
self.message_count = res.json()["count"]
return [
(start + uid, SimpleMessage(start + uid, parse_email(item["raw"])))
for uid, item in enumerate(reversed(res.json()["results"]))
]
return
def fetchSENT(self, messages):
start, end = messages.ranges[0]
start = max(start, 1)
limit = min(20, end - start + 1) if end and end >= start else 20
if self.message_count > 0 and start > self.message_count:
return []
res = httpx.get(
f"{settings.proxy_url}/api/sendbox?limit={limit}&offset={start - 1}",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)
if res.status_code != 200:
_logger.error(
"Failed: "
f"code=[{res.status_code}] text=[{res.text}]"
total_count = count_res.json()["count"]
self.message_count = total_count
if total_count == 0 or start > total_count:
return
# 分批处理,每次获取一小批就立即返回
batch_size = 20
current_start = start
current_end = min(end or total_count, total_count)
while current_start <= current_end:
batch_end = min(current_start + batch_size - 1, current_end)
# 计算这一批的参数
limit = batch_end - current_start + 1
server_offset = total_count - batch_end
server_offset = max(0, server_offset)
_logger.info(
f"Fetching batch: start={current_start}, end={batch_end}, "
f"total_count={total_count}, limit={limit}, "
f"server_offset={server_offset}"
)
raise Exception("Failed to fetch emails")
if res.json()["count"] > 0:
self.message_count = res.json()["count"]
return [
(start + uid, SimpleMessage(start + uid, generate_email_model(item)))
for uid, item in enumerate(reversed(res.json()["results"]))
]
res = httpx.get(
f"{settings.proxy_url}{endpoint}?limit={limit}&offset={server_offset}",
headers={
"Authorization": f"Bearer {self.password}",
"x-custom-auth": f"{settings.basic_password}",
"Content-Type": "application/json"
}
)
if res.status_code != 200:
_logger.error(
f"Failed to fetch {self.name} emails: "
f"code=[{res.status_code}] text=[{res.text}]"
)
break
emails = res.json()["results"]
for i, item in enumerate(reversed(emails)):
uid = total_count - server_offset - len(emails) + i + 1
if current_start <= uid <= batch_end:
if self.name == "INBOX":
email_model = parse_email(item["raw"])
elif self.name == "SENT":
email_model = generate_email_model(item)
# 立即返回这封邮件
yield (uid, SimpleMessage(uid, email_model))
current_start = batch_end + 1
def getUID(self, message):
return message.uid

View File

@@ -48,17 +48,14 @@ def generate_email_model(item: dict) -> EmailModel:
email_json = json.loads(item["raw"])
message = MIMEMultipart()
if email_json.get("version") == "v2":
message['From'] = f"{email_json["from_name"]} <{item["address"]}>" if email_json.get(
"from_name") else item["address"]
message['To'] = f"{email_json["to_name"]} <{email_json["to_mail"]}>" if email_json.get(
"to_name") else email_json["to_mail"]
message['From'] = f'{email_json["from_name"]} <{item["address"]}>' if email_json.get("from_name") else item["address"]
message['To'] = f'{email_json["to_name"]} <{email_json["to_mail"]}>' if email_json.get("to_name") else email_json["to_mail"]
message.attach(MIMEText(
email_json["content"],
"html" if email_json.get("is_html") else "plain"
))
else:
message['From'] = f"{email_json["from"]['name']} <{
email_json["from"]['email']}>"
message['From'] = f'{email_json["from"]["name"]} <{email_json["from"]["email"]}>'
message['To'] = ", ".join(
[f"{to['name']} <{to['email']}>" for to in email_json["personalizations"][0]["to"]])
message.attach(MIMEText(

View File

@@ -1,5 +1,5 @@
aiosmtpd==1.4.6
pydantic-settings==2.2.1
requests==2.32.0
twisted==24.7.0
httpx==0.27.0
pydantic-settings==2.9.1
requests==2.32.4
Twisted==25.5.0
httpx==0.28.1

View File

@@ -27,7 +27,6 @@ export default defineConfig({
}
},
themeConfig: {
logo: { src: '/logo.png', width: 24, height: 24 },
search: { provider: 'local' },
socialLinks: [

View File

@@ -2,12 +2,16 @@ import { defineConfig, type DefaultTheme } from 'vitepress'
export const en = defineConfig({
title: "Temp Mail Doc",
lang: 'zh-Hans',
lang: 'en-US',
description: 'CloudFlare Free sending and receiving of temporary domain name mailboxes',
themeConfig: {
nav: nav(),
sidebar: {
'/en/guide/': { base: '/en/guide/', items: sidebarGuide() },
},
editLink: {
pattern: 'https://github.com/dreamhunter2333/cloudflare_temp_email/edit/main/vitepress-docs/docs/:path',
text: 'Edit this page on GitHub'
@@ -17,6 +21,31 @@ export const en = defineConfig({
message: 'Based on MIT license',
copyright: `Copyright © 2023-${new Date().getFullYear()} Dream Hunter`
},
docFooter: {
prev: 'Previous',
next: 'Next'
},
outline: {
level: 'deep',
label: 'On this page'
},
lastUpdated: {
text: 'Last updated',
formatOptions: {
dateStyle: 'short',
timeStyle: 'medium'
}
},
langMenuLabel: 'Language',
returnToTopLabel: 'Return to top',
sidebarMenuLabel: 'Menu',
darkModeSwitchLabel: 'Theme',
lightModeSwitchTitle: 'Switch to light mode',
darkModeSwitchTitle: 'Switch to dark mode'
}
})
@@ -28,15 +57,16 @@ function nav(): DefaultTheme.NavItem[] {
},
{
text: 'Guide',
link: '/en/cli',
link: '/en/guide/quick-start',
activeMatch: '/en/guide/'
},
{
text: 'Service Status',
link: '/status',
link: '/en/status',
},
{
text: 'Reference',
link: '/reference',
link: '/en/reference',
},
{
text: process.env.TAG_NAME || 'v0.2.2',
@@ -53,3 +83,88 @@ function nav(): DefaultTheme.NavItem[] {
}
]
}
function sidebarGuide(): DefaultTheme.SidebarItem[] {
return [
{
text: 'Introduction',
collapsed: false,
items: [
{ text: 'What is Temporary Email', link: 'what-is-temp-mail' },
{ text: 'Star History', link: 'star-history' },
{ text: 'Quick Start', link: 'quick-start' },
]
},
{
text: 'Deploy via Command Line',
collapsed: false,
items: [
{ text: 'CLI Prerequisites', link: 'cli/pre-requisite' },
{ text: 'D1 Database', link: 'cli/d1' },
{ text: 'Cloudflare Workers Backend', link: 'cli/worker' },
{ text: 'Configure Email Routing', link: 'email-routing.md' },
{ text: 'Cloudflare Pages Frontend', link: 'cli/pages' },
{ text: 'Configure Email Sending', link: 'config-send-mail' },
]
},
{
text: 'Deploy via User Interface',
collapsed: false,
items: [
{ text: 'D1 Database', link: 'ui/d1' },
{ text: 'Cloudflare Workers Backend', link: 'ui/worker' },
{ text: 'Configure Email Routing', link: 'email-routing.md' },
{ text: 'Cloudflare Pages Frontend', link: 'ui/pages' },
{ text: 'Configure Email Sending', link: 'config-send-mail' },
]
},
{
text: 'Deploy via Github Actions',
collapsed: true,
items: [
{ text: 'Github Actions Prerequisites', link: 'actions/pre-requisite' },
{ text: 'D1 Database', link: 'actions/d1' },
{ text: 'Github Actions Configuration', link: 'actions/github-action' },
{ text: 'Configure Email Routing', link: 'email-routing.md' },
{ text: 'Configure Email Sending', link: 'config-send-mail' },
{ text: 'Auto-Update Configuration', link: 'actions/auto-update' },
]
},
{
text: 'General',
collapsed: false,
items: [
{ text: 'Worker Variables', link: 'worker-vars' },
{ text: 'Common Issues', link: 'common-issues' },
]
},
{
text: 'Additional Features',
collapsed: false,
items: [
{ text: 'AI Email Recognition', link: 'feature/ai-extract' },
{ text: 'Configure SMTP IMAP Proxy', link: 'feature/config-smtp-proxy' },
{ text: 'Send Email API', link: 'feature/send-mail-api' },
{ text: 'View Email API', link: 'feature/mail-api' },
{ text: 'Configure Subdomain Email', link: 'feature/subdomain' },
{ text: 'Configure Telegram Bot', link: 'feature/telegram' },
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
{ text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' },
{ text: 'Configure Webhook', link: 'feature/webhook' },
{ text: 'New Address API', link: 'feature/new-address-api' },
{ text: 'OAuth2 Third-party Login', link: 'feature/user-oauth2' },
{ text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' },
{ text: 'Add Google Ads', link: 'feature/google-ads.md' },
]
},
{
text: 'Feature Overview',
collapsed: false,
items: [
{ text: 'Admin Console', link: 'feature/admin' },
{ text: 'Admin User Management', link: 'feature/admin-user-management' },
]
},
{ text: 'Reference', base: "/en/", link: 'reference' }
]
}

View File

@@ -28,6 +28,7 @@ export const zh = defineConfig({
},
outline: {
level: 'deep',
label: '页面导航'
},
@@ -119,15 +120,29 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
},
{
text: '通过 Github Actions 部署',
collapsed: true,
items: [
{ text: 'Github Actions 部署准备', link: 'actions/pre-requisite' },
{ text: 'D1 数据库', link: 'actions/d1' },
{ text: 'Github Actions 配置', link: 'actions/github-action' },
{ text: '配置邮件转发', link: 'email-routing.md' },
{ text: '配置发送邮件', link: 'config-send-mail' },
{ text: '自动更新配置', link: 'actions/auto-update' },
]
},
{
text: '通用',
collapsed: false,
items: [
{ text: '通过 Github Actions 部署', link: 'github-action' },
{ text: 'worker变量说明', link: 'worker-vars' },
{ text: '常见问题', link: 'common-issues' },
]
},
{
text: '附加功能',
collapsed: false,
items: [
{ text: 'AI 邮件识别', link: 'feature/ai-extract' },
{ text: '配置 SMTP IMAP 代理服务', link: 'feature/config-smtp-proxy' },
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
{ text: '查看邮件 API', link: 'feature/mail-api' },
@@ -138,6 +153,8 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
{ text: '配置 webhook', link: 'feature/webhook' },
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
{ text: 'Oauth2 第三方登录', link: 'feature/user-oauth2' },
{ text: '配置其他worker增强', link: 'feature/another-worker-enhanced' },
{ text: '给网页增加 Google Ads', link: 'feature/google-ads.md' },
]
},
{

View File

@@ -74,16 +74,19 @@ compatibility_flags = [ "nodejs_compat" ]
# ]
[vars]
# DEFAULT_LANG = "zh"
# TITLE = "Custom Title" # The title of the site
PREFIX = "tmp" # The mailbox name prefix to be processed
# (min, max) length of the adderss, if not set, the default is (1, 30)
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# ANNOUNCEMENT = "Custom Announcement"
# always show ANNOUNCEMENT even no changes
# ALWAYS_SHOW_ANNOUNCEMENT = true
# address check REGEX, if not set, will not check
# ADDRESS_CHECK_REGEX = "^(?!.*admin).*"
# address name replace REGEX, if not set, the default is [^a-z0-9]
# ADDRESS_REGEX = "[^a-z0-9]"
# MIN_ADDRESS_LEN = 1
# MAX_ADDRESS_LEN = 30
# If you want your site to be private, uncomment below and change your password
# PASSWORDS = ["123", "456"]
# admin console password, if not configured, access to the console is not allowed
@@ -107,6 +110,8 @@ JWT_SECRET = "xxx" # Key used to generate jwt
BLACK_LIST = "" # Blacklist, used to filter senders, comma separated
# Allow users to create email addresses
ENABLE_USER_CREATE_EMAIL = true
# Disable anonymous user create email, if set true, users can only create email addresses after logging in
# DISABLE_ANONYMOUS_USER_CREATE_EMAIL = true
# Allow users to delete messages
ENABLE_USER_DELETE_EMAIL = true
# Allow automatic replies to emails
@@ -118,7 +123,8 @@ ENABLE_AUTO_REPLY = false
# DISABLE_SHOW_GITHUB = true # Disable Show GitHub link
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# NO_LIMIT_SEND_ROLE = "vip" # the role which can send emails without limit
# the role which can send emails without limit, multiple roles can be separated by ,
# NO_LIMIT_SEND_ROLE = "vip"
# Turnstile verification configuration
# CF_TURNSTILE_SITE_KEY = ""
# CF_TURNSTILE_SECRET_KEY = ""
@@ -132,6 +138,14 @@ ENABLE_AUTO_REPLY = false
# FRONTEND_URL = "https://xxxx.xxx"
# Enable check junk mail
# ENABLE_CHECK_JUNK_MAIL = false
# junk mail check list, if status exists and status is not pass, will be marked as junk mail
# JUNK_MAIL_CHECK_LIST = = ["spf", "dkim", "dmarc"]
# junk mail force check pass list, if no status or status is not pass, will be marked as junk mail
# JUNK_MAIL_FORCE_PASS_LIST = ["spf", "dkim", "dmarc"]
# remove attachment if size exceed 2MB, mail maybe mising some information due to parsing
# REMOVE_EXCEED_SIZE_ATTACHMENT = true
# remove all attachment, mail maybe mising some information due to parsing
# REMOVE_ALL_ATTACHMENT = true
[[d1_databases]]
binding = "DB"

View File

@@ -0,0 +1,10 @@
# How to Configure Auto-Update for GitHub Actions Deployment
::: warning Notice
If you encounter any issues, please report them via `GitHub Issues`. Thank you.
Auto-update will not execute SQL files for the D1 database. When the database schema changes, you need to execute them manually.
:::
1. Open the `Actions` page of the repository, find `Upstream Sync`, and click `enable workflow` to enable the `workflow`
2. If `Upstream Sync` fails, go to the repository homepage and click `Sync` to synchronize manually
3. You can customize the update interval by modifying the `schedule` configuration in `Upstream Sync`, refer to [cron expressions](https://crontab.guru/)

View File

@@ -0,0 +1,17 @@
# Initialize/Update D1 Database
## Create Database
Open the Cloudflare console, select `Workers & Pages` -> `D1` -> `Create Database`, and click to create the database
![d1](/ui_install/d1.png)
After creation, you can see the D1 database in the Cloudflare console and obtain the database `name` and `database ID`
## Initialize Database
After deployment is complete, go to the admin page's `Quick Setup` -> `Database` section and click the `Initialize Database` button to initialize the database
## Update Database Schema
Refer to [Update D1 via Command Line](/en/guide/cli/d1) or [Update D1 via UI](/en/guide/ui/d1)

View File

@@ -0,0 +1,61 @@
# Deploy via GitHub Actions
::: warning Notice
Currently only supports Worker and Pages deployment.
If you encounter any issues, please report them via `GitHub Issues`. Thank you.
The `worker.dev` domain is inaccessible in China, please use a custom domain
:::
## Deployment Steps
### Fork Repository and Enable Actions
- Fork this repository on GitHub
- Open the `Actions` page of the repository
- Find `Deploy Backend` and click `enable workflow` to enable the `workflow`
- If you need separate frontend and backend deployment, find `Deploy Frontend` and click `enable workflow` to enable the `workflow`
### Configure Secrets
Then go to the repository page `Settings` -> `Secrets and variables` -> `Actions` -> `Repository secrets`, and add the following `secrets`:
- Common `secrets`
| Name | Description |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Account ID, [Reference Documentation](https://developers.cloudflare.com/workers/wrangler/ci-cd/#cloudflare-account-id) |
| `CLOUDFLARE_API_TOKEN` | Cloudflare API Token, [Reference Documentation](https://developers.cloudflare.com/workers/wrangler/ci-cd/#api-token) |
- Worker backend `secrets`
| Name | Description |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `BACKEND_TOML` | Backend configuration file, [see here](/en/guide/cli/worker.html#modify-wrangler-toml-configuration-file) |
| `DEBUG_MODE` | (Optional) Whether to enable debug mode, set to `true` to enable. By default, worker deployment logs are not output to GitHub Actions page, enabling this will output them |
| `BACKEND_USE_MAIL_WASM_PARSER` | (Optional) Whether to use WASM to parse emails, set to `true` to enable. For features, refer to [Configure Worker to use WASM Email Parser](/en/guide/feature/mail_parser_wasm_worker) |
| `USE_WORKER_ASSETS` | (Optional) Deploy Worker with frontend assets, set to `true` to enable |
- Pages frontend `secrets`
> [!warning] Notice
> If you choose to deploy Worker with frontend assets, these `secrets` are not required
| Name | Description |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FRONTEND_ENV` | Frontend configuration file, please copy the content from `frontend/.env.example`, [and modify according to this guide](/en/guide/cli/pages.html) |
| `FRONTEND_NAME` | The project name you created in Cloudflare Pages, can be created via [UI](https://temp-mail-docs.awsl.uk/en/guide/ui/pages.html) or [Command Line](https://temp-mail-docs.awsl.uk/en/guide/cli/pages.html) |
| `FRONTEND_BRANCH` | (Optional) Branch for pages deployment, can be left unconfigured, defaults to `production` |
| `PAGE_TOML` | (Optional) Required when using page functions to forward backend requests. Please copy the content from `pages/wrangler.toml` and modify the `service` field to your worker backend name according to actual situation |
| `TG_FRONTEND_NAME` | (Optional) The project name you created in Cloudflare Pages, same as `FRONTEND_NAME`. Fill this in if you need Telegram Mini App functionality |
### Deploy
- Open the `Actions` page of the repository
- Find `Deploy Backend` and click `Run workflow` to select a branch and deploy manually
- If you need separate frontend and backend deployment, find `Deploy Frontend` and click `Run workflow` to select a branch and deploy manually
## How to Configure Auto-Update
1. Open the `Actions` page of the repository, find `Upstream Sync`, and click `enable workflow` to enable the `workflow`
2. If `Upstream Sync` fails, go to the repository homepage and click `Sync` to synchronize manually

View File

@@ -0,0 +1,10 @@
# GitHub Actions Deployment Prerequisites
## GitHub Account
- A GitHub account is required
- A stable network connection
## Fork Repository
- Fork [this repository](https://github.com/dreamhunter2333/cloudflare_temp_email.git) on GitHub

View File

@@ -0,0 +1,30 @@
# Initialize/Update D1 Database
When executing the wrangler login command for the first time, you will be prompted to log in. Follow the prompts to complete the login process.
## Initialize Database
```bash
cd worker
cp wrangler.toml.template wrangler.toml
# Create D1 and execute schema.sql
wrangler d1 create dev
wrangler d1 execute dev --file=../db/schema.sql --remote
```
After creation, you can see the D1 database in the Cloudflare console.
![D1](/readme_assets/d1.png)
## Update Database Schema
For `schema` updates, please confirm your previously deployed version.
Check the [Changelog](https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/CHANGELOG.md)
Find the `patch` file you need to execute and run it, for example:
```bash
cd worker
wrangler d1 execute dev --file=../db/2024-01-13-patch.sql --remote
wrangler d1 execute dev --file=../db/2024-04-03-patch.sql --remote
```

View File

@@ -0,0 +1,51 @@
# Cloudflare Pages Frontend
> [!warning] Notice
> Choose one of the following methods
## Deploy Worker with Frontend Assets
Refer to [Deploy Worker](/en/guide/cli/worker#deploy-worker-with-frontend-optional)
## Separate Frontend and Backend Deployment
The first deployment will prompt you to create a project. For the `production` branch, enter `production`.
```bash
cd frontend
pnpm install
cp .env.example .env.prod
```
Modify the `.env.prod` file.
Change `VITE_API_BASE` to the `worker` `url` created in the previous step. Do not add `/` at the end.
For example: `VITE_API_BASE=https://xxx.xxx.workers.dev`
```bash
pnpm build --emptyOutDir
# The first deployment will prompt you to create a project, for production branch enter production
pnpm run deploy
```
After deployment, you can see your project in the Cloudflare console. You can configure a custom domain for `pages`.
![pages](/readme_assets/pages.png)
## Forward Backend Requests Through Page Functions
Forwarding requests from page functions to the worker backend can achieve faster response times.
The first deployment will prompt you to create a project. For the `production` branch, enter `production`.
If your worker backend name is not `cloudflare_temp_email`, please modify `pages/wrangler.toml`.
```bash
cd frontend
pnpm install
# If you want to enable Cloudflare Zero Trust, you need to use pnpm build:pages:nopwa to disable caching
pnpm build:pages
cd ../pages
pnpm run deploy
```

View File

@@ -0,0 +1,17 @@
# Prerequisites
## Installing wrangler
Install wrangler
```bash
npm install wrangler -g
```
## Clone the Project
```bash
git clone https://github.com/dreamhunter2333/cloudflare_temp_email.git
# Switch to the latest tag or the branch you want to deploy, you can also use the main branch directly
# git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
```

View File

@@ -0,0 +1,147 @@
# Cloudflare Worker Backend
> [!warning] Notice
> The `worker.dev` domain is not accessible in China, please use a custom domain
## Initialize Project
```bash
cd worker
pnpm install
cp wrangler.toml.template wrangler.toml
```
## Create KV Cache
> [!NOTE]
> If you want to enable user registration and need to send email verification, you need to create a `KV` cache. You can skip this step if not needed.
> If you need Telegram Bot, you need to create a `KV` cache. You can skip this step if not needed.
Create KV cache through command line, or create it in the Cloudflare console, then copy the corresponding configuration to the `wrangler.toml` file.
```bash
wrangler kv:namespace create DEV
```
## Modify `wrangler.toml` Configuration File
> [!NOTE] Note
> For more variable configurations, please check [Worker Variables Documentation](/en/guide/worker-vars)
```toml
name = "cloudflare_temp_email"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
compatibility_flags = [ "nodejs_compat" ]
# If you want to use a custom domain, you need to add routes configuration
# routes = [
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
# ]
# If you want to deploy a worker with frontend assets, you need to add assets configuration
# [assets]
# directory = "../frontend/dist/"
# binding = "ASSETS"
# run_worker_first = true
# If you want to use scheduled tasks to clean up emails, uncomment the following and modify the cron expression
# [triggers]
# crons = [ "0 0 * * *" ]
# Send emails through Cloudflare
# send_email = [
# { name = "SEND_MAIL" },
# ]
[vars]
# Email name prefix, can be configured as an empty string or not configured if no prefix is needed
PREFIX = "tmp"
# All domains used for temporary email, supports multiple domains
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"]
# Secret key for generating JWT, JWT is used for user login and authentication
JWT_SECRET = "xxx"
# Admin console password, if not configured, console access is not allowed
# ADMIN_PASSWORDS = ["123", "456"]
# Whether to allow users to create emails, not allowed if not configured
ENABLE_USER_CREATE_EMAIL = true
# Allow users to delete emails, not allowed if not configured
ENABLE_USER_DELETE_EMAIL = true
# D1 database name and ID can be viewed in the Cloudflare console
[[d1_databases]]
binding = "DB"
database_name = "xxx" # D1 database name
database_id = "xxx" # D1 database ID
# KV config for user registration email verification, can be skipped if user registration is not enabled or registration verification is not enabled
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
# Rate limit configuration for new address /api/new_address
# [[unsafe.bindings]]
# name = "RATE_LIMITER"
# type = "ratelimit"
# namespace_id = "1001"
# # 10 requests per minute
# simple = { limit = 10, period = 60 }
# Bind other workers to process emails, for example, using auth-inbox AI capabilities to parse verification codes or activation links
# [[services]]
# binding = "AUTH_INBOX"
# service = "auth-inbox"
```
## Deploy Worker with Frontend (Optional)
> [!NOTE]
> If you don't need a [worker with frontend], you can skip this step.
> Refer to the frontend deployment documentation later for separate frontend and backend deployment.
Ensure the frontend assets are built to the `frontend/dist` directory.
```bash
cd frontend
pnpm install --no-frozen-lockfile
pnpm build:pages
```
Add the following configuration to the `wrangler.toml` file in the `worker` directory.
```toml
[assets]
directory = "../frontend/dist/"
binding = "ASSETS"
run_worker_first = true
```
## Telegram Bot Configuration
> [!NOTE]
> If you don't need Telegram Bot, you can skip this step.
Please create a Telegram Bot first, then get the `token`, and execute the following command to add the `token` to secrets.
```bash
pnpm wrangler secret put TELEGRAM_BOT_TOKEN
```
## Deploy
The first deployment will prompt you to create a project. For the `production` branch, enter `production`.
```bash
pnpm run deploy
```
After successful deployment, you can see the `worker` `url` in the routes, and the console will also output the `worker` `url`.
![worker](/readme_assets/worker.png)
> [!NOTE]
> Open the `worker` `url`, if it displays `OK`, the deployment is successful.
>
> Open `/health_check`, if it displays `OK`, the deployment is successful.

View File

@@ -0,0 +1,41 @@
# Common Issues
> [!NOTE] Note
> If you don't find a solution here, please search or ask in `Github Issues`, or ask in the Telegram group.
## General
| Issue | Solution |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| Sending emails to authenticated forwarding addresses using Cloudflare Workers | Use CF's API for sending, only supports recipient addresses bound to CF, i.e., CF EMAIL forwarding destination addresses |
| Binding multiple domains | Each domain needs to configure email forwarding to worker |
## Worker Related
| Issue | Solution |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| `Uncaught Error: No such module "path". imported from "worker.js"` | [Reference](/en/guide/ui/worker) |
| `No such module "node:stream". imported from "worker.js"` | [Reference](/en/guide/ui/worker) |
| `Subdomain cannot send emails` | [Reference](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/515) |
| `Failed to send verify code: No balance` | Set unlimited emails in admin console or increase quota on the sending permission page |
| `Github OAuth unable to get email 400 Failed to get user email` | GitHub user needs to set email to public |
| `Cannot read properties of undefined (reading 'map')` | Worker variables not set successfully |
## Pages Related
| Issue | Solution |
| --------------- | --------------------------------------------------------- |
| `network error` | Use incognito mode or clear browser cache and DNS cache |
## Telegram Bot
| Issue | Solution |
| -------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `Telgram Bot failed to get email: 400: Bad Request:BUTTON_URL_INVALID` | tg mini app URL is incorrect, should be the pages URL |
| `Telegram bot bind error: bind adress count reach the limit` | Need to set worker variable `TG_MAX_ADDRESS` |
## Github Actions
| Issue | Solution |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| After Github Action deployment, CF always shows preview branch | Go to CF pages settings to confirm that the frontend branch matches the Github Action frontend deployment branch |

View File

@@ -0,0 +1,84 @@
# Configure Email Sending
::: warning Note
All three methods can be configured simultaneously. When sending emails, it will prioritize using `resend`, if `resend` is not configured, it will use `smtp`.
If a Cloudflare authenticated forwarding email address is configured, CF's internal API will be prioritized for sending emails
:::
## Send Emails Using Resend
Register at `https://resend.com/domains` and add DNS records according to the instructions.
Create an `api key` on the `API KEYS` page.
Then execute the following command to add `RESEND_TOKEN` to secrets:
> [!NOTE]
> If you find this troublesome, you can also put it directly in plain text under `[vars]` in `wrangler.toml`, but this is not recommended
If you deployed through the UI, you can add it under `Variables and Secrets` in the Cloudflare UI interface.
```bash
# Switch to worker directory
cd worker
wrangler secret put RESEND_TOKEN
```
If you have multiple domains with different `api keys`, you can add multiple secrets in `wrangler.toml`, named `RESEND_TOKEN_` + `<UPPERCASE DOMAIN WITH . REPLACED BY _>`, for example:
```bash
wrangler secret put RESEND_TOKEN_XXX_COM
wrangler secret put RESEND_TOKEN_DREAMHUNTER2333_XYZ
```
## Send Emails Using SMTP
The format of `SMTP_CONFIG` is as follows, where key is the domain name and value is the SMTP configuration. For SMTP configuration format details, refer to [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
```json
{
"awsl.uk": {
"host": "smtp.xxx.com",
"port": 465,
"secure": true,
"authType": [
"plain",
"login"
],
"credentials": {
"username": "username",
"password": "password"
}
}
}
```
Then execute the following command to add `SMTP_CONFIG` to secrets:
> [!NOTE]
> If you find this troublesome, you can also put it directly in plain text under `[vars]` in `wrangler.toml`, but this is not recommended
If you deployed through the UI, you can add it under `Variables and Secrets` in the Cloudflare UI interface.
```bash
# Switch to worker directory
cd worker
wrangler secret put SMTP_CONFIG
```
## Send Emails to Authenticated Forwarding Addresses on Cloudflare
Only supported for CLI deployment, add `send_email` configuration in `wrangler.toml`.
The destination email address must be an authenticated email address on Cloudflare, which has significant limitations. If you need to send emails to other addresses, you can use `resend` or `smtp` to send emails.
```toml
# Send emails through Cloudflare
send_email = [
{ name = "SEND_MAIL" },
]
```
Admin console account configuration `Verified address list (can send emails through CF internal API)`

View File

@@ -0,0 +1,9 @@
# Cloudflare Email Routing
1. In the CF console for the corresponding domain under `Email Routing`, configure the `Email DNS records`. If there are multiple domains, you need to configure `Email DNS records` for each domain.
2. Before binding an email address to your Worker, you need to enable email routing and have at least one verified email address (destination address).
3. Configure the `Catch-all address` in the routing rules of each domain's `Email Routing` to send to `worker`.
![email](/readme_assets/email.png)

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