Compare commits

..

62 Commits

Author SHA1 Message Date
Dream Hunter
c5d01e09e8 feat: change version (#294) 2024-06-01 21:31:30 +08:00
Dream Hunter
201c7658be feat: UI: admin mail page style add margin-top: 10px (#293) 2024-06-01 21:27:45 +08:00
Dream Hunter
77155299e0 feat: add mailbox multi delete and download (#292) 2024-06-01 21:23:17 +08:00
Dream Hunter
9725407c77 feat: add s3 attachment (#291) 2024-06-01 20:08:42 +08:00
Dream Hunter
e91bbe273a feat: UI i18n depends on router (#290) 2024-06-01 12:13:44 +08:00
Dream Hunter
b792c196c1 feat: UI i18n depends on router (#289) 2024-06-01 12:12:13 +08:00
Dream Hunter
7a368d7b23 feat: add global forward address list (#288) 2024-05-31 23:21:12 +08:00
Dream Hunter
f882e4cf97 feat: add Local Address Manage (#285) 2024-05-29 13:40:02 +08:00
Dream Hunter
00abf79417 fix: cannot delete addres when not bind KV (#284) 2024-05-29 12:08:56 +08:00
Dream Hunter
1f8edbc295 feat: add TITLE in worker for custom website title (#276) 2024-05-26 16:21:27 +08:00
Dream Hunter
268f3d6446 Update CHANGELOG.md 2024-05-26 15:35:18 +08:00
Dream Hunter
8dc9d32a7e feat: add resend for send mail (#275) 2024-05-26 15:30:18 +08:00
Dream Hunter
3b6736924b feat: add resend for send mail (#274) 2024-05-26 12:37:11 +08:00
Dream Hunter
dc14338b69 fix: telegram bot golbalPush (#273) 2024-05-25 17:37:39 +08:00
Dream Hunter
954ae2dfb1 fix: telegram bot golbalPush (#272) 2024-05-25 14:38:33 +08:00
Dream Hunter
6d55acdd42 fix: telegram bot golbalPush (#271) 2024-05-25 14:34:16 +08:00
Dream Hunter
03bb210016 fix: telegram bot golbalPush (#270) 2024-05-25 14:20:34 +08:00
Dream Hunter
bf3c372d8c feat: telegram bot global push (#269) 2024-05-25 14:07:00 +08:00
Dream Hunter
9414f7a977 Update README.md 2024-05-25 11:53:23 +08:00
Dream Hunter
32440706d2 feat: add sendmail sunset in readme (#267) 2024-05-23 12:32:07 +08:00
Dream Hunter
c976664f4e feat: UI: lazy load (#266) 2024-05-23 12:23:43 +08:00
Dream Hunter
aa04dc4efa feat: smtp_proxy_server use httpx (#265) 2024-05-22 22:24:59 +08:00
Dream Hunter
02e3e755e7 feat: docs: Telegram Mini App (#264) 2024-05-22 20:57:30 +08:00
Dream Hunter
37ed2955ff fix: webhook JSON.stringify (#263) 2024-05-22 20:48:03 +08:00
Dream Hunter
dd49768cfc feat: smtp_proxy_server update package (#262) 2024-05-21 23:53:32 +08:00
Dream Hunter
9ec11f7040 fix: telegram bot/miniapp bugs (#261) 2024-05-21 22:45:48 +08:00
Dream Hunter
2533257b68 fix: telegram bot/miniapp bugs (#259) 2024-05-21 13:32:47 +08:00
Dream Hunter
96ea81e055 fix: telegram bot/miniapp bugs (#258) 2024-05-21 13:28:02 +08:00
Dream Hunter
8459e0c306 fix: telegram bot/miniapp bugs (#257) 2024-05-21 13:18:15 +08:00
Dream Hunter
91d7896e65 feat: telegram mini app open mail from bot (#256) 2024-05-21 02:03:06 +08:00
Dream Hunter
69771fc1d1 feat: telegram bot unbind && delete address (#254) 2024-05-20 13:23:41 +08:00
Dream Hunter
c00382259a fix: telegram mini app pipeline (#253) 2024-05-19 11:37:06 +08:00
Dream Hunter
8ac96bff1f fix: telegram mini app pipeline (#252) 2024-05-19 11:34:30 +08:00
Dream Hunter
9f3ff7b980 fix: telegram mini app (#251) 2024-05-19 11:32:57 +08:00
Dream Hunter
870b7b9198 feat: add telegram mini app (#250) 2024-05-19 00:35:10 +08:00
Dream Hunter
46576316e6 Update CHANGELOG.md 2024-05-18 17:08:41 +08:00
Dream Hunter
a5ff4f2d90 feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view (#248) 2024-05-18 17:02:21 +08:00
Dream Hunter
745e36f838 feat: UI changes (#247) 2024-05-18 14:46:24 +08:00
Dream Hunter
a351839408 fix build (#245) 2024-05-18 14:07:52 +08:00
Dream Hunter
ca00a877ad feat: telegram bot TelegramSettings && webhook (#244)
* feat: telegram bot TelegramSettings

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

View File

@@ -31,6 +31,22 @@ jobs:
version: 8 version: 8
run_install: false run_install: false
- name: check github release done
run: |
for ((attempt=1; attempt<=10; attempt++)); do
if wget -q --spider "https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip"; then
echo "frontend.zip found."
break
else
if [ $attempt -eq 10 ]; then
echo "Exceeded maximum retries. frontend.zip not found."
else
echo "frontend.zip not found. Retrying in 30 seconds..."
sleep 30
fi
fi
done
- name: Deploy Docs for ${{github.ref_name}} - name: Deploy Docs for ${{github.ref_name}}
run: | run: |
cd vitepress-docs/ cd vitepress-docs/

View File

@@ -38,6 +38,13 @@ jobs:
pnpm run deploy --project-name=$project_name pnpm run deploy --project-name=$project_name
echo "Deploying prodcution for ${{ github.ref_name }}" echo "Deploying prodcution for ${{ github.ref_name }}"
echo "Deployed for tag ${{ github.ref_name }}" echo "Deployed for tag ${{ github.ref_name }}"
export tg_mini_app_project_name=${{ secrets.TG_FRONTEND_NAME }}
if [ -n "$tg_mini_app_project_name" ]; then
echo "Deploying telegram mini app $tg_mini_app_project_name"
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
echo "Deployed telegram mini app for ${{ github.ref_name }}"
fi
env: env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@@ -1,8 +1,91 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
# CHANGE LOG # CHANGE LOG
## main branch ## v0.5.0
### DB Changes - UI: 增加本地缓存进行地址管理
- worker: 增加 `FORWARD_ADDRESS_LIST` 全局邮件转发地址(等同于 `catch all`)
- UI: 多语言使用路由进行切换
- 添加保存附件到 S3 的功能
- UI: 增加收取邮件列表 `批量删除``批量下载`
## v0.4.6
- worker 配置文件添加 `TITLE = "Custom Title"`, 可自定义网站标题
- 修复 KV 未绑定无法删除地址的问题
## v0.4.5
- UI lazy load 懒加载
- telegram bot 添加用户全局推送功能(admin 用户)
- 增加对 cloudflare verified 用户发送邮件
- 增加使用 `resend` 发送邮件, `resend` 提供 http 和 smtp api, 使用更加方便, 文档: https://temp-mail-docs.awsl.uk/zh/guide/config-send-mail.html
## v0.4.4
- 增加 telegram mini app
- telegram bot 增加 `ubind`, `delete` 指令
- 修复 webhook 多行文本的问题
## v0.4.3
### Breaking Changes
配置文件 `main = "src/worker.js"` 改为 `main = "src/worker.ts"`
### Changes
- `telegram bot` 白名单配置
- `ENABLE_WEBHOOK` 添加 webhook
- UI: admin 页面使用双层 tab
- UI: 登录后可直接主页切换地址
- UI: 发件箱也采用左右分栏显示(类似收件箱)
- `SMTP IMAP Proxy` 添加发件箱查看
* feat: telegram bot TelegramSettings && webhook by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/244
* fix build by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/245
* feat: UI changes by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/247
* feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/248
## v0.4.2
- 修复 smtp imap proxy sever 的一些 bug
- 修复 UI 界面文字错误, 界面增加版本号
- 增加 telegram bot 文档 https://temp-mail-docs.awsl.uk/zh/guide/feature/telegram.html
* fix: imap server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/227
* fix: Maintenance wrong label by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/229
* feat: add version for frontend && backend by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/230
* feat: add page functions proxy to make response faster by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/234
* feat: add about page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/235
* feat: remove mailV1Alert && fix mobile showSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/236
* feat: telegram bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/238
* fix: remove cleanup address due to many table need to be clean by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/240
* feat: docs: Telegram Bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/241
* fix: smtp_proxy: cannot decode 8bit && tg bot new random address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/242
* fix: smtp_proxy: update raise imap4.NoSuchMailbox by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/243
### v0.4.1
- 用户名限制最长30个字符
- 修复 `/external/api/send_mail` 未返回的 bug (#222)
- 添加 `IMAP proxy` 服务,支持 `IMAP` 查看邮件
- UI 界面增加版本号显示
* feat: use common function handleListQuery when query by page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/220
* fix: typos by @lwd-temp in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
* fix: name max 30 && /external/api/send_mail not return result by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/222
* fix: smtp_proxy_server support decode from mail charset by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/223
* feat: add imap proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/225
* feat: UI show version by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/226
### New Contributors
* @lwd-temp made their first contribution in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
## v0.4.0
### DB Changes/Breaking changes
新增 user 相关表,用于存储用户信息 新增 user 相关表,用于存储用户信息
@@ -10,6 +93,8 @@
### config changs ### config changs
启用用户注册邮箱验证需要 `KV`
```toml ```toml
# kv config for send email verification code # kv config for send email verification code
# [[kv_namespaces]] # [[kv_namespaces]]
@@ -26,6 +111,15 @@
- 修复删除地址时邮件未删除的BUG #213 - 修复删除地址时邮件未删除的BUG #213
- UI 增加全局标签页位置配置, 侧边距配置 - UI 增加全局标签页位置配置, 侧边距配置
* feat: update docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/204
* feat: add Deploy to Cloudflare Workers button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/205
* feat: add Deploy to Cloudflare Workers docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/206
* feat: add UserLogin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/209
* feat: admin search mailbox && fix generateName multi dot && user jwt exp in 30 days && UI globalTabplacement && useSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/214
* feat: UI check openSettings in Login page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/215
* feat: UI move AdminContact to common by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/217
* feat: docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/218
## v0.3.3 ## v0.3.3
- 修复 Admin 删除邮件报错 - 修复 Admin 删除邮件报错
@@ -128,7 +222,6 @@ set
- 添加 RATE_LIMITER 限流 发送邮件 和 新建地址 - 添加 RATE_LIMITER 限流 发送邮件 和 新建地址
- 一些 bug 修复 - 一些 bug 修复
---
- feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132 - feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132
- feat: requset_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143 - feat: requset_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146 - fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146

View File

@@ -1,5 +1,20 @@
# 使用 cloudflare 免费服务,搭建临时邮箱 # 使用 cloudflare 免费服务,搭建临时邮箱
<p align="center">
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE">
<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">
<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="https://discord.gg/dQEwTWhA6Q">
<img alt="Join Discord Chat" src="https://img.shields.io/discord/1238705663623036939.svg?label=discord&logo=discord">
</a>
</p>
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。 > 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
## [查看部署文档](https://temp-mail-docs.awsl.uk) ## [查看部署文档](https://temp-mail-docs.awsl.uk)
@@ -31,6 +46,7 @@
- [在线演示](#在线演示) - [在线演示](#在线演示)
- [功能/TODO](#功能todo) - [功能/TODO](#功能todo)
- [Reference](#reference) - [Reference](#reference)
- [Join Community](#join-community)
## 功能/TODO ## 功能/TODO
@@ -44,8 +60,9 @@
- [x] 支持发送邮件 - [x] 支持发送邮件
- [x] 支持 `DKIM` - [x] 支持 `DKIM`
- [x] `admin` 后台创建无前缀邮箱 - [x] `admin` 后台创建无前缀邮箱
- [x] 添加 `SMTP proxy server`,支持 SMTP 发送邮件 - [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件, `IMAP` 查看邮件
- [x] 添加完整的用户注册登录功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证切换不同邮箱 - [x] 添加完整的用户注册登录功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证切换不同邮箱
- [x] `Telegram Bot` 使用,以及 `Telegram` 推送
## Reference ## Reference
@@ -53,3 +70,8 @@
- 使用 Cloudflare Pages 部署前端 - 使用 Cloudflare Pages 部署前端
- 使用 Cloudflare Workers 部署后端 - 使用 Cloudflare Workers 部署后端
- email 转发使用 Cloudflare Email Routing - email 转发使用 Cloudflare Email Routing
## Join Community
- [Discord](https://discord.gg/dQEwTWhA6Q)
- [Telegram](https://t.me/cloudflare_temp_email)

View File

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

2
frontend/.env.pages Normal file
View File

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

2
frontend/.gitignore vendored
View File

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

View File

@@ -1,27 +1,32 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "0.0.0", "version": "0.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build -m prod --emptyOutDir", "build": "vite build -m prod --emptyOutDir",
"build:release": "vite build -m example --emptyOutDir", "build:release": "vite build -m example --emptyOutDir",
"build:pages": "vite build -m pages --emptyOutDir",
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
"preview": "vite preview", "preview": "vite preview",
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview", "deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
"deploy": "npm run build && wrangler pages deploy ./dist --branch production" "deploy": "npm run build && wrangler pages deploy ./dist --branch production"
}, },
"dependencies": { "dependencies": {
"@unhead/vue": "^1.9.11",
"@vicons/material": "^0.12.0", "@vicons/material": "^0.12.0",
"@vueuse/core": "^10.9.0", "@vueuse/core": "^10.9.0",
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.6.8", "axios": "^1.7.2",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.1.6", "mail-parser-wasm": "^0.1.6",
"naive-ui": "^2.38.2", "naive-ui": "^2.38.2",
"postal-mime": "^2.2.5", "postal-mime": "^2.2.5",
"vooks": "^0.2.12", "vooks": "^0.2.12",
"vue": "^3.4.26", "vue": "^3.4.27",
"vue-clipboard3": "^2.0.0", "vue-clipboard3": "^2.0.0",
"vue-i18n": "^9.13.1", "vue-i18n": "^9.13.1",
"vue-router": "^4.3.2" "vue-router": "^4.3.2"
@@ -29,13 +34,13 @@
"devDependencies": { "devDependencies": {
"@vicons/fa": "^0.12.0", "@vicons/fa": "^0.12.0",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"unplugin-auto-import": "^0.17.5", "unplugin-auto-import": "^0.17.6",
"unplugin-vue-components": "^0.27.0", "unplugin-vue-components": "^0.27.0",
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-pwa": "^0.19.8", "vite-plugin-pwa": "^0.19.8",
"vite-plugin-top-level-await": "^1.4.1", "vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-wasm": "^3.3.0", "vite-plugin-wasm": "^3.3.0",
"workbox-window": "^7.1.0", "workbox-window": "^7.1.0",
"wrangler": "^3.53.1" "wrangler": "^3.57.1"
} }
} }

881
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,16 +8,15 @@ import Header from './views/Header.vue';
import Footer from './views/Footer.vue'; import Footer from './views/Footer.vue';
const { localeCache, isDark, loading, useSideMargin } = useGlobalState() const {
isDark, loading, useSideMargin, telegramApp, isTelegram
} = useGlobalState()
const { locale } = useI18n({});
const theme = computed(() => isDark.value ? darkTheme : null) const theme = computed(() => isDark.value ? darkTheme : null)
const localeConfig = computed(() => localeCache.value == 'zh' ? zhCN : null) const localeConfig = computed(() => locale.value == 'zh' ? zhCN : null)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const showSideMargin = computed(() => !isMobile.value && !useSideMargin.value); const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
const { locale } = useI18n({
useScope: 'global',
});
locale.value = localeCache.value;
onMounted(async () => { onMounted(async () => {
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN; const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
@@ -31,6 +30,23 @@ onMounted(async () => {
document.body.appendChild(script); document.body.appendChild(script);
} }
// check if telegram is enabled
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
if (
(typeof enableTelegram === 'boolean' && enableTelegram === true)
||
(typeof enableTelegram === 'string' && enableTelegram === 'true')
) {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-web-app.js';
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
telegramApp.value = window.Telegram?.WebApp || {};
isTelegram.value = !!window.Telegram?.WebApp?.initData;
}
}); });
</script> </script>
@@ -40,8 +56,8 @@ onMounted(async () => {
<n-spin description="loading..." :show="loading"> <n-spin description="loading..." :show="loading">
<n-message-provider> <n-message-provider>
<n-grid x-gap="12" :cols="12"> <n-grid x-gap="12" :cols="12">
<n-gi v-if="!showSideMargin" span="1"></n-gi> <n-gi v-if="showSideMargin" span="1"></n-gi>
<n-gi :span="showSideMargin ? 12 : 10"> <n-gi :span="!showSideMargin ? 12 : 10">
<div class="main"> <div class="main">
<n-space vertical> <n-space vertical>
<n-layout style="min-height: 80vh;"> <n-layout style="min-height: 80vh;">
@@ -52,7 +68,7 @@ onMounted(async () => {
</n-space> </n-space>
</div> </div>
</n-gi> </n-gi>
<n-gi v-if="!showSideMargin" span="1"></n-gi> <n-gi v-if="showSideMargin" span="1"></n-gi>
</n-grid> </n-grid>
<n-back-top /> <n-back-top />
</n-message-provider> </n-message-provider>

View File

@@ -33,7 +33,7 @@ const apiFetch = async (path, options = {}) => {
} }
if (response.status === 401 && path.startsWith("/admin")) { if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true; showAdminAuth.value = true;
throw new Error("Unauthorized, you admin password is wrong") throw new Error("Unauthorized, your admin password is wrong")
} }
if (response.status >= 300) { if (response.status >= 300) {
throw new Error(`${response.status} ${response.data}` || "error"); throw new Error(`${response.status} ${response.data}` || "error");
@@ -54,6 +54,7 @@ const getOpenSettings = async (message) => {
try { try {
const res = await api.fetch("/open_api/settings"); const res = await api.fetch("/open_api/settings");
Object.assign(openSettings.value, { Object.assign(openSettings.value, {
title: res["title"] || "",
prefix: res["prefix"] || "", prefix: res["prefix"] || "",
needAuth: res["needAuth"] || false, needAuth: res["needAuth"] || false,
domains: res["domains"].map((domain) => { domains: res["domains"].map((domain) => {
@@ -66,8 +67,11 @@ const getOpenSettings = async (message) => {
enableUserCreateEmail: res["enableUserCreateEmail"] || false, enableUserCreateEmail: res["enableUserCreateEmail"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false, enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableAutoReply: res["enableAutoReply"] || false, enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false,
copyright: res["copyright"] || openSettings.value.copyright, copyright: res["copyright"] || openSettings.value.copyright,
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "", cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
enableWebhook: res["enableWebhook"] || false,
isS3Enabled: res["isS3Enabled"] || false,
}); });
if (openSettings.value.needAuth) { if (openSettings.value.needAuth) {
showAuth.value = true; showAuth.value = true;
@@ -86,7 +90,6 @@ const getSettings = async () => {
settings.value = { settings.value = {
address: res["address"], address: res["address"],
auto_reply: res["auto_reply"], auto_reply: res["auto_reply"],
has_v1_mails: res["has_v1_mails"],
send_balance: res["send_balance"], send_balance: res["send_balance"],
}; };
} finally { } finally {

View File

@@ -1,6 +1,5 @@
<script setup> <script setup>
import { watch, onMounted, ref, onBeforeUnmount } from "vue"; import { watch, onMounted, ref, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store' import { useGlobalState } from '../store'
@@ -10,7 +9,6 @@ import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
const message = useMessage() const message = useMessage()
const isMobile = useIsMobile() const isMobile = useIsMobile()
const router = useRouter()
const props = defineProps({ const props = defineProps({
enableUserDeleteEmail: { enableUserDeleteEmail: {
@@ -37,11 +35,21 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
requried: false requried: false
} },
showSaveS3: {
type: Boolean,
default: false,
requried: false
},
saveToS3: {
type: Function,
default: (mail_id, filename, blob) => { },
requried: false
},
}) })
const { const {
localeCache, isDark, mailboxSplitSize, indexTab, isDark, mailboxSplitSize, indexTab, loading,
useIframeShowMail, sendMailModel, preferShowTextMail useIframeShowMail, sendMailModel, preferShowTextMail
} = useGlobalState() } = useGlobalState()
const autoRefresh = ref(false) const autoRefresh = ref(false)
@@ -58,8 +66,13 @@ const curAttachments = ref([])
const curMail = ref(null); const curMail = ref(null);
const showTextMail = ref(preferShowTextMail.value) const showTextMail = ref(preferShowTextMail.value)
const multiActionMode = ref(false)
const showMultiActionDownload = ref(false)
const showMultiActionDelete = ref(false)
const multiActionDownloadZip = ref({})
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
success: 'Success', success: 'Success',
@@ -68,12 +81,17 @@ const { t } = useI18n({
refresh: 'Refresh', refresh: 'Refresh',
attachments: 'Show Attachments', attachments: 'Show Attachments',
downloadMail: 'Download Mail', downloadMail: 'Download Mail',
pleaseSelectMail: "Please select a mail to view.", pleaseSelectMail: "Please select mail",
delete: 'Delete', delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete this mail?', deleteMailTip: 'Are you sure you want to delete mail?',
reply: 'Reply', reply: 'Reply',
showTextMail: 'Show Text Mail', showTextMail: 'Show Text Mail',
showHtmlMail: 'Show Html Mail' showHtmlMail: 'Show Html Mail',
saveToS3: 'Save to S3',
multiAction: 'Multi Action',
cancelMultiAction: 'Cancel Multi Action',
selectAll: 'Select All',
unselectAll: 'Unselect All',
}, },
zh: { zh: {
success: '成功', success: '成功',
@@ -82,12 +100,17 @@ const { t } = useI18n({
refresh: '刷新', refresh: '刷新',
downloadMail: '下载邮件', downloadMail: '下载邮件',
attachments: '查看附件', attachments: '查看附件',
pleaseSelectMail: "请选择一封邮件查看。", pleaseSelectMail: "请选择邮件",
delete: '删除', delete: '删除',
deleteMailTip: '确定要删除这封邮件吗?', deleteMailTip: '确定要删除邮件吗?',
reply: '回复', reply: '回复',
showTextMail: '显示纯文本邮件', showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件' showHtmlMail: '显示HTML邮件',
saveToS3: '保存到S3',
multiAction: '多选',
cancelMultiAction: '取消多选',
selectAll: '全选',
unselectAll: '取消全选',
} }
} }
}); });
@@ -125,12 +148,14 @@ const refresh = async () => {
pageSize.value, (page.value - 1) * pageSize.value pageSize.value, (page.value - 1) * pageSize.value
); );
data.value = await Promise.all(results.map(async (item) => { data.value = await Promise.all(results.map(async (item) => {
item.checked = false;
return await processItem(item); return await processItem(item);
})); }));
if (totalCount > 0) { if (totalCount > 0) {
count.value = totalCount; count.value = totalCount;
} }
if (!isMobile.value && !curMail.value && data.value.length > 0) { curMail.value = null;
if (!isMobile.value && data.value.length > 0) {
curMail.value = data.value[0]; curMail.value = data.value[0];
} }
} catch (error) { } catch (error) {
@@ -140,6 +165,10 @@ const refresh = async () => {
}; };
const clickRow = async (row) => { const clickRow = async (row) => {
if (multiActionMode.value) {
row.checked = !row.checked;
return;
}
curMail.value = row; curMail.value = row;
}; };
@@ -186,6 +215,92 @@ const onSpiltSizeChange = (size) => {
mailboxSplitSize.value = 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
}
}
const multiActionModeClick = (enableMulti) => {
if (enableMulti) {
data.value.forEach((item) => {
item.checked = false;
});
multiActionMode.value = true;
} else {
multiActionMode.value = false;
data.value.forEach((item) => {
item.checked = false;
});
}
}
const multiActionSelectAll = (checked) => {
data.value.forEach((item) => {
item.checked = checked;
});
}
const multiActionDeleteMail = async () => {
try {
loading.value = true;
const selectedMails = data.value.filter((item) => item.checked);
if (selectedMails.length === 0) {
message.error(t('pleaseSelectMail'));
return;
}
multiActionDeleteProgress.value = {
percentage: 0,
tip: `0/${selectedMails.length}`
};
for (const [index, mail] of selectedMails.entries()) {
await props.deleteMail(mail.id);
showMultiActionDelete.value = true;
multiActionDeleteProgress.value = {
percentage: Math.floor((index + 1) / selectedMails.length * 100),
tip: `${index + 1}/${selectedMails.length}`
};
}
message.success(t("success"));
await refresh();
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
showMultiActionDelete.value = true;
}
}
const multiActionDownload = async () => {
try {
loading.value = true;
const selectedMails = data.value.filter((item) => item.checked);
if (selectedMails.length === 0) {
message.error(t('pleaseSelectMail'));
return;
}
const JSZipModlue = await import('jszip');
const JSZip = JSZipModlue.default;
const zip = new JSZip();
for (const mail of selectedMails) {
zip.file(`${mail.id}.eml`, mail.raw);
}
multiActionDownloadZip.value = {
url: URL.createObjectURL(await zip.generateAsync({ type: "blob" })),
filename: `mails-${new Date().toISOString().replace(/:/g, '-')}.zip`
}
showMultiActionDownload.value = true;
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
}
}
onMounted(async () => { onMounted(async () => {
await refresh(); await refresh();
}); });
@@ -197,14 +312,38 @@ onBeforeUnmount(() => {
<template> <template>
<div> <div>
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25" <div v-if="!isMobile" class="left">
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange"> <div style="margin-bottom: 10px;">
<template #1> <n-space v-if="multiActionMode">
<div class="center"> <n-button @click="multiActionModeClick(false)" tertiary>
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;"> {{ t('cancelMultiAction') }}
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" /> </n-button>
</div> <n-button @click="multiActionSelectAll(true)" tertiary>
<n-switch v-model:value="autoRefresh" size="small" :round="false"> {{ t('selectAll') }}
</n-button>
<n-button @click="multiActionSelectAll(false)" tertiary>
{{ t('unselectAll') }}
</n-button>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="multiActionDeleteMail">
<template #trigger>
<n-button tertiary type="error">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button @click="multiActionDownload" tertiary type="info">
<template #icon>
<n-icon :component="CloudDownloadRound" />
</template>
{{ t('downloadMail') }}
</n-button>
</n-space>
<n-space v-else>
<n-button @click="multiActionModeClick(true)" type="primary" tertiary>
{{ t('multiAction') }}
</n-button>
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
show-size-picker />
<n-switch v-model:value="autoRefresh" :round="false">
<template #checked> <template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }} {{ t('refreshAfter', { msg: autoRefreshInterval }) }}
</template> </template>
@@ -212,91 +351,99 @@ onBeforeUnmount(() => {
{{ t('autoRefresh') }} {{ t('autoRefresh') }}
</template> </template>
</n-switch> </n-switch>
<n-button @click="refresh" size="small" type="primary" tertiary> <n-button @click="refresh" type="primary" tertiary>
{{ t('refresh') }} {{ t('refresh') }}
</n-button> </n-button>
</div> </n-space>
<div style="overflow: auto; height: 80vh;"> </div>
<n-list hoverable clickable> <n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)" :on-update:size="onSpiltSizeChange">
:class="mailItemClass(row)"> <template #1>
<n-thing :title="row.subject"> <div style="overflow: auto; height: 80vh;">
<template #description> <n-list hoverable clickable>
<n-tag type="info"> <n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
ID: {{ row.id }} :class="mailItemClass(row)">
</n-tag> <template #prefix v-if="multiActionMode">
<n-tag type="info"> <n-checkbox v-model:checked="row.checked" />
{{ row.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
</n-tag>
</template> </template>
</n-thing> <n-thing :title="row.subject">
</n-list-item> <template #description>
</n-list> <n-tag type="info">
</div> ID: {{ row.id }}
</template> </n-tag>
<template #2> <n-tag type="info">
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;"> {{ `${row.created_at} UTC` }}
<n-space> </n-tag>
<n-tag type="info"> <n-tag type="info">
ID: {{ curMail.id }} FROM: {{ row.source }}
</n-tag> </n-tag>
<n-tag type="info"> <n-tag v-if="showEMailTo" type="info">
{{ curMail.created_at }} TO: {{ row.address }}
</n-tag> </n-tag>
<n-tag type="info"> </template>
FROM: {{ curMail.source }} </n-thing>
</n-tag> </n-list-item>
<n-tag v-if="showEMailTo" type="info"> </n-list>
TO: {{ curMail.address }} </div>
</n-tag> </template>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail"> <template #2>
<template #trigger> <n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button> <n-space>
</template> <n-tag type="info">
{{ t('deleteMailTip') }} ID: {{ curMail.id }}
</n-popconfirm> </n-tag>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info" <n-tag type="info">
@click="getAttachments(curMail.attachments)"> {{ `${curMail.created_at} UTC` }}
{{ t('attachments') }} </n-tag>
</n-button> <n-tag type="info">
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'" FROM: {{ curMail.source }}
:href="getDownloadEmlUrl(curMail.raw)"> </n-tag>
<template #icon> <n-tag v-if="showEMailTo" type="info">
<n-icon :component="CloudDownloadRound" /> TO: {{ curMail.address }}
</template> </n-tag>
{{ t('downloadMail') }} <n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
</n-button> <template #trigger>
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail"> <n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
<template #icon> </template>
<n-icon :component="ReplyFilled" /> {{ t('deleteMailTip') }}
</template> </n-popconfirm>
{{ t('reply') }} <n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
</n-button> @click="getAttachments(curMail.attachments)">
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail"> {{ t('attachments') }}
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }} </n-button>
</n-button> <n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
</n-space> :href="getDownloadEmlUrl(curMail.raw)">
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre> <template #icon>
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message" <n-icon :component="CloudDownloadRound" />
style="margin-top: 10px;width: 100%; height: 100%;"> </template>
</iframe> {{ t('downloadMail') }}
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div> </n-button>
</n-card> <n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
<n-card class="mail-item" v-else> <template #icon>
<n-result status="info" :title="t('pleaseSelectMail')"> <n-icon :component="ReplyFilled" />
</n-result> </template>
</n-card> {{ t('reply') }}
</template> </n-button>
</n-split> <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>
</n-card>
<n-card class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
</div>
<div class="left" v-else> <div class="left" v-else>
<div class="center"> <n-space justify="center">
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;"> <div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" /> <n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div> </div>
<n-switch v-model:value="autoRefresh" size="small" :round="false"> <n-switch v-model:value="autoRefresh" size="small" :round="false">
@@ -307,10 +454,10 @@ onBeforeUnmount(() => {
{{ t('autoRefresh') }} {{ t('autoRefresh') }}
</template> </template>
</n-switch> </n-switch>
<n-button @click="refresh" size="small" type="primary"> <n-button @click="refresh" tertiary size="small" type="primary">
{{ t('refresh') }} {{ t('refresh') }}
</n-button> </n-button>
</div> </n-space>
<div style="overflow: auto; height: 80vh;"> <div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable> <n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"> <n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
@@ -320,7 +467,7 @@ onBeforeUnmount(() => {
ID: {{ row.id }} ID: {{ row.id }}
</n-tag> </n-tag>
<n-tag type="info"> <n-tag type="info">
{{ row.created_at }} {{ `${row.created_at} UTC` }}
</n-tag> </n-tag>
<n-tag type="info"> <n-tag type="info">
FROM: {{ row.source }} FROM: {{ row.source }}
@@ -342,7 +489,7 @@ onBeforeUnmount(() => {
ID: {{ curMail.id }} ID: {{ curMail.id }}
</n-tag> </n-tag>
<n-tag type="info"> <n-tag type="info">
{{ curMail.created_at }} {{ `${curMail.created_at} UTC` }}
</n-tag> </n-tag>
<n-tag type="info"> <n-tag type="info">
FROM: {{ curMail.source }} FROM: {{ curMail.source }}
@@ -371,8 +518,15 @@ onBeforeUnmount(() => {
</template> </template>
{{ t('reply') }} {{ t('reply') }}
</n-button> </n-button>
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
</n-button>
</n-space> </n-space>
<div v-html="curMail.message" style="margin-top: 10px;"></div> <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>
</n-card> </n-card>
</n-drawer-content> </n-drawer-content>
</n-drawer> </n-drawer>
@@ -381,25 +535,51 @@ onBeforeUnmount(() => {
<template #header> <template #header>
<div>{{ t("attachments") }}</div> <div>{{ t("attachments") }}</div>
</template> </template>
<n-list hoverable clickable> <n-spin v-model:show="attachmentLoding">
<n-list-item v-for="row in curAttachments" v-bind:key="row.id"> <n-list hoverable clickable>
<n-thing class="center" :title="row.filename"> <n-list-item v-for="row in curAttachments" v-bind:key="row.id">
<template #description> <n-thing class="center" :title="row.filename">
<n-space> <template #description>
<n-tag type="info"> <n-space>
Size: {{ row.size }} <n-tag type="info">
</n-tag> Size: {{ row.size }}
</n-space> </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> </template>
</n-thing> </n-list-item>
<template #suffix> </n-list>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename" </n-spin>
:href="row.url"> </n-modal>
<n-icon :component="CloudDownloadRound" /> <n-modal v-model:show="showMultiActionDownload" preset="dialog" :title="t('downloadMail')">
</n-button> <n-tag type="info">
</template> {{ multiActionDownloadZip.filename }}
</n-list-item> </n-tag>
</n-list> <n-button tag="a" target="_blank" tertiary type="info" size="small" :download="multiActionDownloadZip.filename"
:href="multiActionDownloadZip.url">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') + " zip" }}
</n-button>
</n-modal>
<n-modal v-model:show="showMultiActionDelete" preset="dialog" :title="t('delete') + t('success')"
negative-text="OK">
<n-space justify="center">
<n-progress type="circle" status="error" :percentage="multiActionDeleteProgress.percentage">
<span style="text-align: center">
{{ multiActionDeleteProgress.tip }}
</span>
</n-progress>
</n-space>
</n-modal> </n-modal>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,269 @@
<script setup>
import { watch, onMounted, ref } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { useIsMobile } from '../utils/composables'
const message = useMessage()
const isMobile = useIsMobile()
const props = defineProps({
showEMailFrom: {
type: Boolean,
default: false
},
fetchMailData: {
type: Function,
default: () => { },
requried: true
},
})
const { isDark, mailboxSplitSize } = useGlobalState()
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curMail = ref(null);
const showCode = ref(false)
const { t } = useI18n({
messages: {
en: {
success: 'Success',
refresh: 'Refresh',
showCode: 'Change View Original Code',
pleaseSelectMail: "Please select a mail to view."
},
zh: {
success: '成功',
refresh: '刷新',
showCode: '切换查看元数据',
pleaseSelectMail: "请选择一封邮件查看。",
}
}
});
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
await refresh();
}
})
const refresh = async () => {
try {
const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value
);
data.value = results.map((item) => {
try {
const data = JSON.parse(item.raw);
if (data.version == "v2") {
item.to_mail = data.to_name ? `${data.to_name} <${data.to_mail}>` : data.to_mail;
item.subject = data.subject;
item.is_html = data.is_html;
item.content = data.content;
item.raw = JSON.stringify(data, null, 2);
} else {
item.to_mail = data?.personalizations?.map(
(p) => p.to?.map((t) => t.email).join(',')
).join(';');
item.subject = data.subject;
item.is_html = (data.content[0]?.type != 'text/plain');
item.content = data.content[0]?.value;
item.raw = JSON.stringify(data, null, 2);
}
} catch (error) {
console.log(error);
}
return item;
});
if (totalCount > 0) {
count.value = totalCount;
}
if (!isMobile.value && !curMail.value && data.value.length > 0) {
curMail.value = data.value[0];
}
} catch (error) {
message.error(error.message || "error");
console.error(error);
}
};
const clickRow = async (row) => {
curMail.value = row;
};
const mailItemClass = (row) => {
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
};
const onSpiltSizeChange = (size) => {
mailboxSplitSize.value = size;
}
onMounted(async () => {
await refresh();
});
</script>
<template>
<div>
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25"
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
<template #1>
<div class="center">
<div style="display: inline-block; margin-right: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-button @click="refresh" size="small" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ `${row.created_at} UTC` }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
</n-tag>
<n-tag type="info">
TO: {{ row.to_mail }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card 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.address }}
</n-tag>
<n-tag type="info">
TO: {{ curMail.to_mail }}
</n-tag>
<n-button size="small" tertiary type="info" @click="showCode = !showCode">
{{ t('showCode') }}
</n-button>
</n-space>
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
</n-card>
<n-card class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
<div class="left" v-else>
<div class="center">
<div style="display: inline-block; margin-right: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-button @click="refresh" size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ `${row.created_at} UTC` }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
</n-tag>
<n-tag type="info">
TO: {{ row.to_mail }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
<n-drawer v-model:show="curMail" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 80vh;">
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
<n-card 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.address }}
</n-tag>
<n-tag type="info">
TO: {{ curMail.to_mail }}
</n-tag>
</n-space>
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
</n-card>
</n-drawer-content>
</n-drawer>
</div>
</div>
</template>
<style scoped>
.left {
text-align: left;
}
.center {
text-align: center;
}
.overlay {
width: 100%;
height: 100%;
z-index: 1000;
}
.overlay-dark-backgroud {
background-color: rgba(255, 255, 255, 0.1);
}
.overlay-light-backgroud {
background-color: rgba(0, 0, 0, 0.1);
}
.mail-item {
height: 100%;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

View File

@@ -2,12 +2,11 @@
import { ref, watch, defineModel, onMounted } from "vue"; import { ref, watch, defineModel, onMounted } from "vue";
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store' import { useGlobalState } from '../store'
const { localeCache, openSettings, isDark } = useGlobalState() const { openSettings, isDark } = useGlobalState()
const cfToken = defineModel('value') const cfToken = defineModel('value')
const { t } = useI18n({ const { locale, t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
refresh: 'Refresh' refresh: 'Refresh'
@@ -42,7 +41,7 @@ const checkCfTurnstile = async (remove) => {
"#cf-turnstile", "#cf-turnstile",
{ {
sitekey: openSettings.value.cfTurnstileSiteKey, sitekey: openSettings.value.cfTurnstileSiteKey,
language: localeCache.value == 'zh' ? 'zh-CN' : 'en-US', language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
theme: isDark.value ? 'dark' : 'light', theme: isDark.value ? 'dark' : 'light',
callback: function (token) { callback: function (token) {
cfToken.value = token; cfToken.value = token;
@@ -68,12 +67,15 @@ onMounted(() => {
<div v-if="openSettings.cfTurnstileSiteKey" class="center"> <div v-if="openSettings.cfTurnstileSiteKey" class="center">
<n-spin description="loading..." :show="turnstileLoading"> <n-spin description="loading..." :show="turnstileLoading">
<n-form-item-row> <n-form-item-row>
<div id="cf-turnstile"></div> <n-flex vertical>
<n-button text @click="checkCfTurnstile(true)"> <div id="cf-turnstile"></div>
{{ t('refresh') }} <n-button text @click="checkCfTurnstile(true)">
</n-button> {{ t('refresh') }}
</n-button>
</n-flex>
</n-form-item-row> </n-form-item-row>
</n-spin> </n-spin>
</div> </div>
</template> </template>

View File

@@ -3,6 +3,7 @@ import App from './App.vue'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import router from './router' import router from './router'
import { registerSW } from 'virtual:pwa-register' import { registerSW } from 'virtual:pwa-register'
import { createHead } from '@unhead/vue'
registerSW({ immediate: true }) registerSW({ immediate: true })
const i18n = createI18n({ const i18n = createI18n({
@@ -16,7 +17,19 @@ const i18n = createI18n({
messages: {} 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'
}
});
const head = createHead()
const app = createApp(App) const app = createApp(App)
app.use(i18n) app.use(i18n)
app.use(router) app.use(router)
app.use(head)
app.mount('#app') app.mount('#app')

View File

@@ -1,24 +1,35 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue' import Index from '../views/Index.vue'
import UserLogin from '../views/user/UserLogin.vue'
import User from '../views/User.vue' import User from '../views/User.vue'
import SendMail from '../views/index/SendMail.vue' import { useGlobalState } from '../store'
import Admin from '../views/Admin.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ {
path: '/', path: '/',
alias: "/:lang/",
component: Index component: Index
}, },
{ {
path: '/user', path: '/user',
alias: "/:lang/user",
component: User component: User
}, },
{ {
path: '/admin', path: '/admin',
component: Admin alias: "/:lang/admin",
component: () => import('../views/Admin.vue')
},
{
path: '/telegram_mail',
alias: "/:lang/telegram_mail",
component: () => import('../views/telegram/Mail.vue')
},
{
name: 'not-found',
path: '/:pathMatch(.*)*',
redirect: '/'
} }
] ]
}) })

View File

@@ -7,19 +7,22 @@ export const useGlobalState = createGlobalState(
const toggleDark = useToggle(isDark) const toggleDark = useToggle(isDark)
const loading = ref(false); const loading = ref(false);
const openSettings = ref({ const openSettings = ref({
title: '',
prefix: '', prefix: '',
needAuth: false, needAuth: false,
adminContact: '', adminContact: '',
enableUserCreateEmail: false, enableUserCreateEmail: false,
enableUserDeleteEmail: false, enableUserDeleteEmail: false,
enableAutoReply: false, enableAutoReply: false,
enableIndexAbout: false,
domains: [], domains: [],
copyright: 'Dream Hunter', copyright: 'Dream Hunter',
cfTurnstileSiteKey: '', cfTurnstileSiteKey: '',
enableWebhook: false,
isS3Enabled: false,
}) })
const settings = ref({ const settings = ref({
fetched: false, fetched: false,
has_v1_mails: false,
send_balance: 0, send_balance: 0,
address: '', address: '',
auto_reply: { auto_reply: {
@@ -44,7 +47,6 @@ export const useGlobalState = createGlobalState(
const auth = useStorage('auth', ''); const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', ''); const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', ''); const jwt = useStorage('jwt', '');
const localeCache = useStorage('locale', 'zh');
const adminTab = ref("account"); const adminTab = ref("account");
const adminMailTabAddress = ref(""); const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref(""); const adminSendBoxTabAddress = ref("");
@@ -68,6 +70,8 @@ export const useGlobalState = createGlobalState(
/** @type {number} */ /** @type {number} */
user_id: 0, user_id: 0,
}); });
const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
return { return {
isDark, isDark,
toggleDark, toggleDark,
@@ -79,7 +83,6 @@ export const useGlobalState = createGlobalState(
showAddressCredential, showAddressCredential,
auth, auth,
jwt, jwt,
localeCache,
adminAuth, adminAuth,
showAdminAuth, showAdminAuth,
adminTab, adminTab,
@@ -95,6 +98,8 @@ export const useGlobalState = createGlobalState(
userSettings, userSettings,
globalTabplacement, globalTabplacement,
useSideMargin, useSideMargin,
telegramApp,
isTelegram,
} }
}, },
) )

View File

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

View File

@@ -1,6 +1,13 @@
export const hashPassword = async (password) => { export const hashPassword = async (password: string) => {
// user crypto to hash password // user crypto to hash password
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password)); const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
const hashArray = Array.from(new Uint8Array(digest)); const hashArray = Array.from(new Uint8Array(digest));
return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join(''); return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
} }
export const getRouterPathWithLang = (path: string, lang: string) => {
if (!lang || lang === 'zh') {
return path;
}
return `/${lang}${path}`;
}

View File

@@ -14,11 +14,14 @@ import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue'; import UserSettings from './admin/UserSettings.vue';
import Mails from './admin/Mails.vue'; import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue'; import MailsUnknow from './admin/MailsUnknow.vue';
import About from './common/About.vue';
import Maintenance from './admin/Maintenance.vue'; import Maintenance from './admin/Maintenance.vue';
import Appearance from './common/Appearance.vue'; import Appearance from './common/Appearance.vue';
import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
const { const {
localeCache, adminAuth, showAdminAuth, adminTab, loading, globalTabplacement adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
} = useGlobalState() } = useGlobalState()
const message = useMessage() const message = useMessage()
@@ -31,7 +34,6 @@ const authFunc = async () => {
} }
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
accessHeader: 'Admin Password', accessHeader: 'Admin Password',
@@ -40,14 +42,18 @@ const { t } = useI18n({
account: 'Account', account: 'Account',
account_create: 'Create Account', account_create: 'Create Account',
account_settings: 'Account Settings', account_settings: 'Account Settings',
user: 'User',
user_management: 'User Management', user_management: 'User Management',
user_settings: 'User Settings', user_settings: 'User Settings',
unknow: 'Mails with unknow receiver', unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control', senderAccess: 'Sender Access Control',
sendBox: 'Send Box', sendBox: 'Send Box',
telegram: 'Telegram Bot',
webhook: 'Webhook',
statistics: 'Statistics', statistics: 'Statistics',
maintenance: 'Maintenance', maintenance: 'Maintenance',
appearance: 'Appearance', appearance: 'Appearance',
about: 'About',
ok: 'OK', ok: 'OK',
}, },
zh: { zh: {
@@ -57,14 +63,18 @@ const { t } = useI18n({
account: '账号', account: '账号',
account_create: '创建账号', account_create: '创建账号',
account_settings: '账号设置', account_settings: '账号设置',
user: '用户',
user_management: '用户管理', user_management: '用户管理',
user_settings: '用户设置', user_settings: '用户设置',
unknow: '无收件人邮件', unknow: '无收件人邮件',
senderAccess: '发件权限控制', senderAccess: '发件权限控制',
sendBox: '发件箱', sendBox: '发件箱',
telegram: '电报机器人',
webhook: 'Webhook',
statistics: '统计', statistics: '统计',
maintenance: '维护', maintenance: '维护',
appearance: '外观', appearance: '外观',
about: '关于',
ok: '确定', ok: '确定',
} }
} }
@@ -92,32 +102,50 @@ onMounted(async () => {
</n-modal> </n-modal>
<n-tabs type="card" v-model:value="adminTab" :placement="globalTabplacement"> <n-tabs type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="account" :tab="t('account')"> <n-tab-pane name="account" :tab="t('account')">
<Account /> <n-tabs type="bar" animated>
<n-tab-pane name="account" :tab="t('account')">
<Account />
</n-tab-pane>
<n-tab-pane name="account_create" :tab="t('account_create')">
<CreateAccount />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="webhook" :tab="t('webhook')">
<Webhook />
</n-tab-pane>
</n-tabs>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="account_create" :tab="t('account_create')"> <n-tab-pane name="user" :tab="t('user')">
<CreateAccount /> <n-tabs type="bar" animated>
</n-tab-pane> <n-tab-pane name="user_management" :tab="t('user_management')">
<n-tab-pane name="account_settings" :tab="t('account_settings')"> <UserManagement />
<AccountSettings /> </n-tab-pane>
</n-tab-pane> <n-tab-pane name="user_settings" :tab="t('user_settings')">
<n-tab-pane name="user_management" :tab="t('user_management')"> <UserSettings />
<UserManagement /> </n-tab-pane>
</n-tab-pane> </n-tabs>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettings />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')"> <n-tab-pane name="mails" :tab="t('mails')">
<Mails /> <n-tabs type="bar" animated>
</n-tab-pane> <n-tab-pane name="mails" :tab="t('mails')">
<n-tab-pane name="unknow" :tab="t('unknow')"> <Mails />
<MailsUnknow /> </n-tab-pane>
</n-tab-pane> <n-tab-pane name="unknow" :tab="t('unknow')">
<n-tab-pane name="senderAccess" :tab="t('senderAccess')"> <MailsUnknow />
<SenderAccess /> </n-tab-pane>
</n-tabs>
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="sendBox" :tab="t('sendBox')"> <n-tab-pane name="sendBox" :tab="t('sendBox')">
<SendBox /> <SendBox />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="telegram" :tab="t('telegram')">
<Telegram />
</n-tab-pane>
<n-tab-pane name="statistics" :tab="t('statistics')"> <n-tab-pane name="statistics" :tab="t('statistics')">
<Statistics /> <Statistics />
</n-tab-pane> </n-tab-pane>
@@ -127,6 +155,9 @@ onMounted(async () => {
<n-tab-pane name="appearance" :tab="t('appearance')"> <n-tab-pane name="appearance" :tab="t('appearance')">
<Appearance /> <Appearance />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="about" :tab="t('about')">
<About />
</n-tab-pane>
</n-tabs> </n-tabs>
</div> </div>
</template> </template>

View File

@@ -1,11 +1,10 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store' import { useGlobalState } from '../store'
const { localeCache, openSettings } = useGlobalState() const { openSettings } = useGlobalState()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
copyright: "Copyright" copyright: "Copyright"

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref, h, computed, onMounted } from 'vue' import { ref, h, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useHead } from '@unhead/vue'
import { useRoute, useRouter, RouterLink } from 'vue-router' import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useIsMobile } from '../utils/composables' import { useIsMobile } from '../utils/composables'
import { import {
@@ -11,11 +12,13 @@ import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store' import { useGlobalState } from '../store'
import { api } from '../api' import { api } from '../api'
import { getRouterPathWithLang } from '../utils'
const message = useMessage() const message = useMessage()
const { const {
localeCache, toggleDark, isDark, openSettings, toggleDark, isDark, isTelegram,
showAuth, adminAuth, auth, loading showAuth, adminAuth, auth, loading, openSettings
} = useGlobalState() } = useGlobalState()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -36,13 +39,15 @@ const authFunc = async () => {
} }
} }
const changeLocale = (locale) => { const changeLocale = async (lang) => {
localeCache.value = locale; if (lang == 'zh') {
location.reload() await router.push(route.fullPath.replace('/en', ''));
} else {
await router.push(`/${lang}${route.fullPath}`);
}
} }
const { t } = useI18n({ const { locale, t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
title: 'Cloudflare Temp Email', title: 'Cloudflare Temp Email',
@@ -69,6 +74,8 @@ const { t } = useI18n({
} }
}); });
const version = import.meta.env.PACKAGE_VERSION ? `v${import.meta.env.PACKAGE_VERSION}` : "";
const menuOptions = computed(() => [ const menuOptions = computed(() => [
{ {
label: () => h(NButton, label: () => h(NButton,
@@ -77,7 +84,10 @@ const menuOptions = computed(() => [
size: "small", size: "small",
type: menuValue.value == "home" ? "primary" : "default", type: menuValue.value == "home" ? "primary" : "default",
style: "width: 100%", style: "width: 100%",
onClick: async () => { await router.push('/'); showMobileMenu.value = false; } onClick: async () => {
await router.push(getRouterPathWithLang('/', locale.value));
showMobileMenu.value = false;
}
}, },
{ {
default: () => t('home'), default: () => t('home'),
@@ -93,7 +103,10 @@ const menuOptions = computed(() => [
size: "small", size: "small",
type: menuValue.value == "user" ? "primary" : "default", type: menuValue.value == "user" ? "primary" : "default",
style: "width: 100%", style: "width: 100%",
onClick: async () => { await router.push("/user"); showMobileMenu.value = false; } onClick: async () => {
await router.push(getRouterPathWithLang("/user", locale.value));
showMobileMenu.value = false;
}
}, },
{ {
default: () => t('user'), default: () => t('user'),
@@ -101,6 +114,7 @@ const menuOptions = computed(() => [
} }
), ),
key: "user", key: "user",
show: !isTelegram.value
}, },
{ {
label: () => h( label: () => h(
@@ -110,7 +124,10 @@ const menuOptions = computed(() => [
size: "small", size: "small",
type: menuValue.value == "admin" ? "primary" : "default", type: menuValue.value == "admin" ? "primary" : "default",
style: "width: 100%", style: "width: 100%",
onClick: async () => { await router.push('/admin'); showMobileMenu.value = false; } onClick: async () => {
await router.push(getRouterPathWithLang('/admin', locale.value));
showMobileMenu.value = false;
}
}, },
{ {
default: () => "Admin", default: () => "Admin",
@@ -145,13 +162,13 @@ const menuOptions = computed(() => [
text: true, text: true,
size: "small", size: "small",
style: "width: 100%", style: "width: 100%",
onClick: () => { onClick: async () => {
localeCache.value == 'zh' ? changeLocale('en') : changeLocale('zh'); locale.value == 'zh' ? await changeLocale('en') : await changeLocale('zh');
showMobileMenu.value = false; showMobileMenu.value = false;
} }
}, },
{ {
default: () => localeCache.value == 'zh' ? "English" : "中文", default: () => locale.value == 'zh' ? "English" : "中文",
icon: () => h( icon: () => h(
NIcon, { component: Language } NIcon, { component: Language }
) )
@@ -171,7 +188,7 @@ const menuOptions = computed(() => [
href: "https://github.com/dreamhunter2333/cloudflare_temp_email", href: "https://github.com/dreamhunter2333/cloudflare_temp_email",
}, },
{ {
default: () => "Github", default: () => version || "Github",
icon: () => h(NIcon, { component: GithubAlt }) icon: () => h(NIcon, { component: GithubAlt })
} }
), ),
@@ -179,6 +196,13 @@ const menuOptions = computed(() => [
} }
]); ]);
useHead({
title: () => openSettings.value.title || t('title'),
meta: [
{ name: "description", content: openSettings.value.description || t('title') },
]
});
onMounted(async () => { onMounted(async () => {
await api.getOpenSettings(message); await api.getOpenSettings(message);
}); });
@@ -188,7 +212,7 @@ onMounted(async () => {
<div> <div>
<n-page-header> <n-page-header>
<template #title> <template #title>
<h3>{{ t('title') }}</h3> <h3>{{ openSettings.title || t('title') }}</h3>
</template> </template>
<template #avatar> <template #avatar>
<n-avatar style="margin-left: 10px;" src="/logo.png" /> <n-avatar style="margin-left: 10px;" src="/logo.png" />

View File

@@ -1,4 +1,5 @@
<script setup> <script setup>
import { defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store' import { useGlobalState } from '../store'
@@ -6,15 +7,18 @@ import { api } from '../api'
import AddressBar from './index/AddressBar.vue'; import AddressBar from './index/AddressBar.vue';
import MailBox from '../components/MailBox.vue'; import MailBox from '../components/MailBox.vue';
import SendBox from '../components/SendBox.vue';
import AutoReply from './index/AutoReply.vue'; import AutoReply from './index/AutoReply.vue';
import SendBox from './index/SendBox.vue';
import SendMail from './index/SendMail.vue';
import AccountSettings from './index/AccountSettings.vue'; import AccountSettings from './index/AccountSettings.vue';
import Webhook from './index/Webhook.vue';
import Attachment from './index/Attachment.vue';
import About from './common/About.vue';
const { localeCache, settings, openSettings, indexTab, globalTabplacement } = useGlobalState() const SendMail = defineAsyncComponent(() => import('./index/SendMail.vue'));
const { settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
mailbox: 'Mail Box', mailbox: 'Mail Box',
@@ -22,6 +26,9 @@ const { t } = useI18n({
sendmail: 'Send Mail', sendmail: 'Send Mail',
auto_reply: 'Auto Reply', auto_reply: 'Auto Reply',
accountSettings: 'Account Settings', accountSettings: 'Account Settings',
about: 'About',
s3Attachment: 'S3 Attachment',
saveToS3Success: 'save to s3 success',
}, },
zh: { zh: {
mailbox: '收件箱', mailbox: '收件箱',
@@ -29,6 +36,9 @@ const { t } = useI18n({
sendmail: '发送邮件', sendmail: '发送邮件',
auto_reply: '自动回复', auto_reply: '自动回复',
accountSettings: '账户设置', accountSettings: '账户设置',
about: '关于',
s3Attachment: 'S3附件',
saveToS3Success: '保存到s3成功',
} }
} }
}); });
@@ -40,6 +50,30 @@ const fetchMailData = async (limit, offset) => {
const deleteMail = async (curMailId) => { const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' }); await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
}; };
const fetchSenboxData = async (limit, offset) => {
return await api.fetch(`/api/sendbox?limit=${limit}&offset=${offset}`);
};
const saveToS3 = async (mail_id, filename, blob) => {
try {
const { url } = await api.fetch(`/api/attachment/put_url`, {
method: 'POST',
body: JSON.stringify({ key: `${mail_id}/${filename}` })
});
// upload to s3 by formdata
const formData = new FormData();
formData.append(filename, blob);
await fetch(url, {
method: 'PUT',
body: formData
});
message.success(t('saveToS3Success'));
} catch (error) {
console.error(error);
message.error(error.message || "save to s3 error");
}
}
</script> </script>
<template> <template>
@@ -47,11 +81,12 @@ const deleteMail = async (curMailId) => {
<AddressBar /> <AddressBar />
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement"> <n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
<n-tab-pane name="mailbox" :tab="t('mailbox')"> <n-tab-pane name="mailbox" :tab="t('mailbox')">
<MailBox :showEMailTo="false" :showReply="true" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" <MailBox :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled" :saveToS3="saveToS3"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" /> :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
:deleteMail="deleteMail" />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="sendbox" :tab="t('sendbox')"> <n-tab-pane name="sendbox" :tab="t('sendbox')">
<SendBox /> <SendBox :fetchMailData="fetchSenboxData" />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="sendmail" :tab="t('sendmail')"> <n-tab-pane name="sendmail" :tab="t('sendmail')">
<SendMail /> <SendMail />
@@ -62,6 +97,15 @@ const deleteMail = async (curMailId) => {
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')"> <n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
<AutoReply /> <AutoReply />
</n-tab-pane> </n-tab-pane>
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhook')">
<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> </n-tabs>
</div> </div>
</template> </template>

View File

@@ -9,11 +9,10 @@ import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue'; import BindAddress from './user/BindAddress.vue';
const { const {
localeCache, userTab, globalTabplacement, userSettings userTab, globalTabplacement, userSettings
} = useGlobalState() } = useGlobalState()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
address_management: 'Address Management', address_management: 'Address Management',

View File

@@ -9,13 +9,12 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material' import { MenuFilled } from '@vicons/material'
const { const {
localeCache, adminAuth, showAdminAuth, loading, adminAuth, showAdminAuth, loading,
adminTab, adminMailTabAddress, adminSendBoxTabAddress adminTab, adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState() } = useGlobalState()
const message = useMessage() const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
name: 'Name', name: 'Name',
@@ -261,7 +260,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div> <div style="margin-top: 10px;">
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog"> <n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
<template #header> <template #header>
<div>{{ t("addressCredential") }}</div> <div>{{ t("addressCredential") }}</div>

View File

@@ -5,11 +5,10 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { localeCache, loading } = useGlobalState() const { loading } = useGlobalState()
const message = useMessage() const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
save: 'Save', save: 'Save',
@@ -17,6 +16,7 @@ const { t } = useI18n({
address_block_list: 'Address Block Keywords for Users(Admin can skip)', address_block_list: 'Address Block Keywords for Users(Admin can skip)',
address_block_list_placeholder: 'Please enter the keywords you want to block', address_block_list_placeholder: 'Please enter the keywords you want to block',
send_address_block_list: 'Address Block Keywords for send email', send_address_block_list: 'Address Block Keywords for send email',
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
}, },
zh: { zh: {
save: '保存', save: '保存',
@@ -24,18 +24,21 @@ const { t } = useI18n({
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)', address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
address_block_list_placeholder: '请输入您想要屏蔽的关键词', address_block_list_placeholder: '请输入您想要屏蔽的关键词',
send_address_block_list: '发送邮件地址屏蔽关键词', send_address_block_list: '发送邮件地址屏蔽关键词',
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
} }
} }
}); });
const addressBlockList = ref([]) const addressBlockList = ref([])
const sendAddressBlockList = ref([]) const sendAddressBlockList = ref([])
const verifiedAddressList = ref([])
const fetchData = async () => { const fetchData = async () => {
try { try {
const res = await api.fetch(`/admin/account_settings`) const res = await api.fetch(`/admin/account_settings`)
addressBlockList.value = res.blockList || [] addressBlockList.value = res.blockList || []
sendAddressBlockList.value = res.sendBlockList || [] sendAddressBlockList.value = res.sendBlockList || []
verifiedAddressList.value = res.verifiedAddressList || []
} catch (error) { } catch (error) {
message.error(error.message || "error"); message.error(error.message || "error");
} }
@@ -47,7 +50,8 @@ const save = async () => {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
blockList: addressBlockList.value || [], blockList: addressBlockList.value || [],
sendBlockList: sendAddressBlockList.value || [] sendBlockList: sendAddressBlockList.value || [],
verifiedAddressList: verifiedAddressList.value || []
}) })
}) })
message.success(t('successTip')) message.success(t('successTip'))
@@ -73,6 +77,10 @@ onMounted(async () => {
<n-select v-model:value="sendAddressBlockList" filterable multiple tag <n-select v-model:value="sendAddressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" /> :placeholder="t('address_block_list_placeholder')" />
</n-form-item-row> </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')" />
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading"> <n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }} {{ t('save') }}
</n-button> </n-button>

View File

@@ -6,12 +6,11 @@ import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { const {
localeCache, loading, openSettings, loading, openSettings,
} = useGlobalState() } = useGlobalState()
const message = useMessage() const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
address: 'Address', address: 'Address',

View File

@@ -7,12 +7,11 @@ import { api } from '../../api'
import MailBox from '../../components/MailBox.vue'; import MailBox from '../../components/MailBox.vue';
const { const {
localeCache, adminAuth, showAdminAuth, adminAuth, showAdminAuth,
adminMailTabAddress adminMailTabAddress
} = useGlobalState() } = useGlobalState()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
addressQueryTip: 'Leave blank to query all addresses', addressQueryTip: 'Leave blank to query all addresses',
@@ -58,7 +57,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div> <div style="margin-top: 10px;">
<n-input-group> <n-input-group>
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" /> <n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" />
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" /> <n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" />
@@ -66,6 +65,7 @@ onMounted(async () => {
{{ t('query') }} {{ t('query') }}
</n-button> </n-button>
</n-input-group> </n-input-group>
<div style="margin-top: 10px;"></div>
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="false" :fetchMailData="fetchMailData" /> <MailBox :key="mailBoxKey" :enableUserDeleteEmail="false" :fetchMailData="fetchMailData" />
</div> </div>
</template> </template>

View File

@@ -24,7 +24,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div v-if="adminAuth"> <div v-if="adminAuth" style="margin-top: 10px;">
<MailBox :enableUserDeleteEmail="false" :fetchMailData="fetchMailUnknowData" /> <MailBox :enableUserDeleteEmail="false" :fetchMailData="fetchMailUnknowData" />
</div> </div>
</template> </template>

View File

@@ -6,7 +6,7 @@ import { CleaningServicesFilled } from '@vicons/material'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { localeCache, adminAuth, showAdminAuth } = useGlobalState() const { adminAuth, showAdminAuth } = useGlobalState()
const message = useMessage() const message = useMessage()
const cleanupModel = ref({ const cleanupModel = ref({
enableMailsAutoCleanup: false, enableMailsAutoCleanup: false,
@@ -20,13 +20,11 @@ const cleanupModel = ref({
}) })
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
tip: 'Please input the cleanup days', tip: 'Please input the cleanup days',
mailBoxLabel: 'Clean up days for mailbox', mailBoxLabel: 'Clean up days for mailbox',
mailUnknowLabel: "Clean up days for unknow receiver", mailUnknowLabel: "Clean up days for unknow receiver",
addressUnActiveLabel: "Clean up days for unactive address",
sendBoxLabel: "Clean up days for sendbox", sendBoxLabel: "Clean up days for sendbox",
cleanupNow: "Cleanup now", cleanupNow: "Cleanup now",
autoCleanup: "Auto cleanup", autoCleanup: "Auto cleanup",
@@ -38,7 +36,6 @@ const { t } = useI18n({
tip: '请输入清理天数', tip: '请输入清理天数',
mailBoxLabel: '收件箱清理天数', mailBoxLabel: '收件箱清理天数',
mailUnknowLabel: "无收件人邮件清理天数", mailUnknowLabel: "无收件人邮件清理天数",
addressUnActiveLabel: "未活跃地址清理天数",
sendBoxLabel: "发件箱清理天数", sendBoxLabel: "发件箱清理天数",
autoCleanup: "自动清理", autoCleanup: "自动清理",
cleanupSuccess: "清理成功", cleanupSuccess: "清理成功",
@@ -123,19 +120,7 @@ onMounted(async () => {
{{ t('cleanupNow') }} {{ t('cleanupNow') }}
</n-button> </n-button>
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('addressUnActiveLabel')"> <n-form-item-row :label="t('sendBoxLabel')">
<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('address', cleanupModel.cleanAddressDays)">
<template #icon>
<n-icon :component="CleaningServicesFilled" />
</template>
{{ t('cleanupNow') }}
</n-button>
</n-form-item-row>
<n-form-item-row :label="t('mailBoxLabel')">
<n-checkbox v-model:checked="cleanupModel.enableSendBoxAutoCleanup"> <n-checkbox v-model:checked="cleanupModel.enableSendBoxAutoCleanup">
{{ t('autoCleanup') }} {{ t('autoCleanup') }}
</n-checkbox> </n-checkbox>

View File

@@ -1,153 +1,42 @@
<script setup> <script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import SendBox from '../../components/SendBox.vue';
const { localeCache, adminAuth, adminSendBoxTabAddress, showAdminAuth } = useGlobalState() const { adminSendBoxTabAddress } = useGlobalState()
const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
address: 'Address',
success: 'Success',
to_mail: 'To Mail',
subject: 'Subject',
created_at: 'Created At',
action: 'Action',
query: 'Query', query: 'Query',
itemCount: 'itemCount', queryTip: 'Please input address to query, leave blank to query all',
view: 'View',
}, },
zh: { zh: {
address: '地址',
success: '成功',
to_mail: '收件人邮箱',
subject: '主题',
created_at: '创建时间',
action: '操作',
query: '查询', query: '查询',
itemCount: '总数', queryTip: '请输入地址查询, 留空则查询所有',
view: '查看',
} }
} }
}); });
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curRow = ref({}) const fetchData = async (limit, offset) => {
const showModal = ref(false) return await api.fetch(
`/admin/sendbox?limit=${limit}&offset=${offset}`
const fetchData = async () => { + (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
try { );
const { results, count: addressCount } = await api.fetch(
`/admin/sendbox`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
);
data.value = results.map((item) => {
try {
const data = JSON.parse(item.raw);
item.to_mail = data?.personalizations?.map(
(p) => p.to?.map((t) => t.email).join(',')
).join(';');
item.subject = data.subject;
item.raw = JSON.stringify(data, null, 2);
} catch (error) {
console.log(error);
}
return item;
});
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
} }
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('address'),
key: "address"
},
{
title: t('to_mail'),
key: "to_mail"
},
{
title: t('subject'),
key: "subject"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
tertiary: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
}
},
{ default: () => t('view') }
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script> </script>
<template> <template>
<div> <div>
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
</n-modal>
<n-input-group> <n-input-group>
<n-input v-model:value="adminSendBoxTabAddress" /> <n-input v-model:value="adminSendBoxTabAddress" :placeholder="t('queryTip')" />
<n-button @click="fetchData" type="primary" tertiary> <n-button @click="fetchData" type="primary" tertiary>
{{ t('query') }} {{ t('query') }}
</n-button> </n-button>
</n-input-group> </n-input-group>
<div style="display: inline-block;"> <SendBox style="margin-top: 10px;" :fetchMailData="fetchData" :showEMailFrom="true" />
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div> </div>
</template> </template>

View File

@@ -5,11 +5,10 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { localeCache, loading } = useGlobalState() const { loading } = useGlobalState()
const message = useMessage() const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
address: 'Address', address: 'Address',

View File

@@ -7,11 +7,10 @@ import { SendOutlined } from '@vicons/material'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { localeCache, adminAuth } = useGlobalState() const { adminAuth } = useGlobalState()
const message = useMessage() const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
userCount: 'Account Count', userCount: 'Account Count',

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
init: 'Init',
successTip: 'Success',
status: 'Check Status',
enableTelegramAllowList: 'Enable Telegram Allow List(Manually input user ID)',
enable: 'Enable',
telegramAllowList: 'Telegram Allow List',
save: 'Save',
miniAppUrl: 'Telegram Mini App URL',
enableGlobalMailPush: 'Enable Global Mail Push(Manually input telegram user ID)',
globalMailPushList: 'Global Mail Push List',
},
zh: {
init: '初始化',
successTip: '成功',
status: '查看状态',
enableTelegramAllowList: '启用 Telegram 白名单(手动输入用户 ID)',
enable: '启用',
telegramAllowList: 'Telegram 白名单',
save: '保存',
miniAppUrl: '电报小程序 URL(请输入你部署的电报小程序网页地址)',
enableGlobalMailPush: '启用全局邮件推送(手动输入邮箱管理员的 telegram 用户 ID)',
globalMailPushList: '全局邮件推送用户列表',
}
}
});
const status = ref({
fetched: false,
})
const fetchStatus = async () => {
try {
const res = await api.fetch(`/admin/telegram/status`)
Object.assign(status.value, res)
status.value.fetched = true
} catch (error) {
message.error((error as Error).message || "error");
}
}
const init = async () => {
try {
await api.fetch(`/admin/telegram/init`, {
method: 'POST',
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
class TelegramSettings {
enableAllowList: boolean;
allowList: string[];
miniAppUrl: string;
enableGlobalMailPush: boolean;
globalMailPushList: string[];
constructor(
enableAllowList: boolean, allowList: string[], miniAppUrl: string,
enableGlobalMailPush: boolean, globalMailPushList: string[]
) {
this.enableAllowList = enableAllowList;
this.allowList = allowList;
this.miniAppUrl = miniAppUrl;
this.enableGlobalMailPush = enableGlobalMailPush;
this.globalMailPushList = globalMailPushList;
}
}
const settings = ref(new TelegramSettings(false, [], '', false, []))
const getSettings = async () => {
try {
const res = await api.fetch(`/admin/telegram/settings`)
Object.assign(settings.value, res)
} catch (error) {
message.error((error as Error).message || "error");
}
}
const saveSettings = async () => {
try {
await api.fetch(`/admin/telegram/settings`, {
method: 'POST',
body: JSON.stringify(settings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
onMounted(async () => {
await getSettings();
})
</script>
<template>
<div class="center">
<n-card style="max-width: 800px; overflow: auto;">
<n-card>
<n-form-item-row :label="t('enableTelegramAllowList')">
<n-input-group>
<n-checkbox v-model:checked="settings.enableAllowList" style="width: 20%;">
{{ t('enable') }}
</n-checkbox>
<n-select v-model:value="settings.allowList" filterable multiple tag style="width: 80%;"
:placeholder="t('telegramAllowList')" />
</n-input-group>
</n-form-item-row>
<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')" />
</n-input-group>
</n-form-item-row>
<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>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -8,11 +8,10 @@ import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import { hashPassword } from '../../utils'; import { hashPassword } from '../../utils';
const { localeCache, loading } = useGlobalState() const { loading } = useGlobalState()
const message = useMessage() const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
success: 'Success', success: 'Success',
@@ -223,7 +222,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div> <div style="margin-top: 10px;">
<n-modal v-model:show="showCreateUser" preset="dialog" :title="t('createUser')"> <n-modal v-model:show="showCreateUser" preset="dialog" :title="t('createUser')">
<n-form> <n-form>
<n-form-item-row :label="t('email')" required> <n-form-item-row :label="t('email')" required>

View File

@@ -5,11 +5,10 @@ import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { localeCache, loading } = useGlobalState() const { loading } = useGlobalState()
const message = useMessage() const message = useMessage()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
save: 'Save', save: 'Save',

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
successTip: 'Success',
webhookAllowList: 'Webhook Allow List(Enter the address that is allowed to use webhook)',
save: 'Save',
},
zh: {
successTip: '成功',
webhookAllowList: 'Webhook 白名单(请输入允许使用webhook 的地址)',
save: '保存',
}
}
});
class WebhookSettings {
allowList: string[];
constructor(allowList: string[]) {
this.allowList = allowList;
}
}
const webhookSettings = ref(new WebhookSettings([]))
const getSettings = async () => {
try {
const res = await api.fetch(`/admin/webhook/settings`)
Object.assign(webhookSettings.value, res)
} catch (error) {
message.error((error as Error).message || "error");
}
}
const saveSettings = async () => {
try {
await api.fetch(`/admin/webhook/settings`, {
method: 'POST',
body: JSON.stringify(webhookSettings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
onMounted(async () => {
await getSettings();
})
</script>
<template>
<div class="center">
<n-card style="max-width: 800px; overflow: auto;">
<n-form-item-row :label="t('webhookAllowList')">
<n-select v-model:value="webhookSettings.allowList" filterable multiple tag
:placeholder="t('webhookAllowList')" />
</n-form-item-row>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup>
import { GithubAlt, Discord, Telegram } from '@vicons/fa'
</script>
<template>
<div class="center">
<n-card>
<n-button tag="a" target="_blank" href="https://github.com/dreamhunter2333/cloudflare_temp_email">
<template #icon>
<n-icon :component="GithubAlt" />
</template>
Github
</n-button>
<n-button tag="a" target="_blank" href="https://discord.gg/dQEwTWhA6Q">
<template #icon>
<n-icon :component="Discord" />
</template>
Discord
</n-button>
<n-button tag="a" target="_blank" href="https://t.me/cloudflare_temp_email">
<template #icon>
<n-icon :component="Telegram" />
</template>
Telegram
</n-button>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
justify-content: center;
}
.n-card {
max-width: 800px;
}
.n-button {
margin-top: 10px;
margin-left: 10px;
}
</style>

View File

@@ -1,10 +1,9 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
const { localeCache, openSettings } = useGlobalState() const { openSettings } = useGlobalState()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
adminContact: 'If you need help, please contact the administrator ({msg})', adminContact: 'If you need help, please contact the administrator ({msg})',

View File

@@ -1,15 +1,16 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useIsMobile } from '../../utils/composables'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
const { const {
localeCache, mailboxSplitSize, useIframeShowMail, preferShowTextMail, mailboxSplitSize, useIframeShowMail, preferShowTextMail,
globalTabplacement, useSideMargin globalTabplacement, useSideMargin
} = useGlobalState() } = useGlobalState()
const isMobile = useIsMobile()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
mailboxSplitSize: 'Mailbox Split Size', mailboxSplitSize: 'Mailbox Split Size',
@@ -40,7 +41,7 @@ const { t } = useI18n({
<template> <template>
<div class="center"> <div class="center">
<n-card> <n-card>
<n-form-item-row :label="t('mailboxSplitSize')"> <n-form-item-row v-if="!isMobile" :label="t('mailboxSplitSize')">
<n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{ <n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{
0.25: '0.25', 0.25: '0.25',
0.5: '0.5', 0.5: '0.5',
@@ -53,7 +54,7 @@ const { t } = useI18n({
<n-form-item-row :label="t('useIframeShowMail')"> <n-form-item-row :label="t('useIframeShowMail')">
<n-switch v-model:value="useIframeShowMail" :round="false" /> <n-switch v-model:value="useIframeShowMail" :round="false" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('useSideMargin')"> <n-form-item-row v-if="!isMobile" :label="t('useSideMargin')">
<n-switch v-model:value="useSideMargin" :round="false" /> <n-switch v-model:value="useSideMargin" :round="false" />
</n-form-item-row> </n-form-item-row>
<n-form-item-row :label="t('globalTabplacement')"> <n-form-item-row :label="t('globalTabplacement')">

View File

@@ -9,11 +9,35 @@ import Turnstile from '../../components/Turnstile.vue'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
const props = defineProps({
bindUserAddress: {
type: Function,
default: async () => { await api.bindUserAddress(); },
requried: true
},
newAddressPath: {
type: Function,
default: async (address_name, domain, cf_token) => {
return await api.fetch("/api/new_address", {
method: "POST",
body: JSON.stringify({
name: address_name,
domain: domain,
cf_token: cf_token,
}),
});
},
requried: true
},
})
const message = useMessage() const message = useMessage()
const router = useRouter() const router = useRouter()
const { const {
jwt, localeCache, loading, openSettings, jwt, loading, openSettings,
showAddressCredential, userSettings showAddressCredential, userSettings
} = useGlobalState() } = useGlobalState()
@@ -32,18 +56,17 @@ const login = async () => {
jwt.value = credential.value; jwt.value = credential.value;
await api.getSettings(); await api.getSettings();
try { try {
await api.bindUserAddress(); await props.bindUserAddress();
} catch (error) { } catch (error) {
message.error(`${t('bindUserAddressError')}: ${error.message}`); message.error(`${t('bindUserAddressError')}: ${error.message}`);
} }
await router.push("/"); await router.push(getRouterPathWithLang("/", locale.value));
} catch (error) { } catch (error) {
message.error(error.message || "error"); message.error(error.message || "error");
} }
} }
const { t } = useI18n({ const { locale, t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
login: 'Login', login: 'Login',
@@ -98,20 +121,17 @@ const generateName = async () => {
const newEmail = async () => { const newEmail = async () => {
try { try {
const res = await api.fetch(`/api/new_address`, { const res = await props.newAddressPath(
method: "POST", emailName.value,
body: JSON.stringify({ emailDomain.value,
name: emailName.value, cfToken.value
domain: emailDomain.value, );
cf_token: cfToken.value,
}),
});
jwt.value = res["jwt"]; jwt.value = res["jwt"];
await api.getSettings(); await api.getSettings();
await router.push("/"); await router.push(getRouterPathWithLang("/", locale.value));
showAddressCredential.value = true; showAddressCredential.value = true;
try { try {
await api.bindUserAddress(); await props.bindUserAddress();
} catch (error) { } catch (error) {
message.error(`${t('bindUserAddressError')}: ${error.message}`); message.error(`${t('bindUserAddressError')}: ${error.message}`);
} }

View File

@@ -6,17 +6,17 @@ import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import Appearance from '../common/Appearance.vue' import Appearance from '../common/Appearance.vue'
import { getRouterPathWithLang } from '../../utils'
const { const {
jwt, localeCache, settings, showAddressCredential, loading jwt, settings, showAddressCredential, loading
} = useGlobalState() } = useGlobalState()
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const showLogout = ref(false) const showLogout = ref(false)
const showDelteAccount = ref(false) const showDelteAccount = ref(false)
const { t } = useI18n({ const { locale, t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
logout: "Logout", logout: "Logout",
@@ -39,7 +39,7 @@ const { t } = useI18n({
const logout = async () => { const logout = async () => {
jwt.value = ''; jwt.value = '';
await router.push('/') await router.push(getRouterPathWithLang("/", locale.value))
location.reload() location.reload()
} }
@@ -49,7 +49,7 @@ const deleteAccount = async () => {
method: 'DELETE' method: 'DELETE'
}); });
jwt.value = ''; jwt.value = '';
await router.push('/') await router.push(getRouterPathWithLang("/", locale.value))
location.reload() location.reload()
} catch (error) { } catch (error) {
message.error(error.message || "error"); message.error(error.message || "error");

View File

@@ -1,43 +1,47 @@
<script setup> <script setup>
import useClipboard from 'vue-clipboard3' import useClipboard from 'vue-clipboard3'
import { onMounted } from 'vue' import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Copy, User } from '@vicons/fa' import { Copy, User, ExchangeAlt } from '@vicons/fa'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import Login from '../common/Login.vue' import Login from '../common/Login.vue'
import AddressManagement from '../user/AddressManagement.vue'
import TelegramAddress from './TelegramAddress.vue'
import LocalAddress from './LocalAddress.vue'
import { getRouterPathWithLang } from '../../utils'
const { toClipboard } = useClipboard() const { toClipboard } = useClipboard()
const message = useMessage() const message = useMessage()
const router = useRouter() const router = useRouter()
const { const {
jwt, localeCache, settings, showAddressCredential, openSettings jwt, settings, showAddressCredential, userJwt,
isTelegram
} = useGlobalState() } = useGlobalState()
const { t } = useI18n({ const { locale, t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
yourAddress: 'Your email address is', addressManage: 'Address Manage',
changeAddress: 'Change Address',
ok: 'OK', ok: 'OK',
copy: 'Copy', copy: 'Copy',
copied: 'Copied', copied: 'Copied',
fetchAddressError: 'Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.', fetchAddressError: 'Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
addressCredential: '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.', addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
userLogin: 'User Login', userLogin: 'User Login',
}, },
zh: { zh: {
yourAddress: '你的邮箱地址是', addressManage: '地址管理',
changeAddress: '更换地址',
ok: '确定', ok: '确定',
copy: '复制', copy: '复制',
copied: '已复制', copied: '已复制',
fetchAddressError: '邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。', fetchAddressError: '邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
addressCredential: '邮箱地址凭证', addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。', addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
userLogin: '用户登录', userLogin: '用户登录',
@@ -45,6 +49,10 @@ const { t } = useI18n({
} }
}); });
const showChangeAddress = ref(false)
const showTelegramChangeAddress = ref(false)
const showLocalAddress = ref(false)
const copy = async () => { const copy = async () => {
try { try {
await toClipboard(settings.value.address) await toClipboard(settings.value.address)
@@ -54,6 +62,10 @@ const copy = async () => {
} }
} }
const onUserLogin = async () => {
await router.push(getRouterPathWithLang("/user", locale.value))
}
onMounted(async () => { onMounted(async () => {
await api.getSettings(); await api.getSettings();
}); });
@@ -65,22 +77,30 @@ onMounted(async () => {
<n-skeleton style="height: 50vh" /> <n-skeleton style="height: 50vh" />
</n-card> </n-card>
<div v-else-if="settings.address"> <div v-else-if="settings.address">
<n-alert v-if="settings.has_v1_mails" type="warning" :show-icon="false" closable>
<span>
<n-button tag="a" target="_blank" tertiary type="info" size="small" href="https://mail-v1.awsl.uk">
<b>{{ t('mailV1Alert') }} </b>
</n-button>
</span>
</n-alert>
<n-alert type="info" :show-icon="false"> <n-alert type="info" :show-icon="false">
<span> <span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b> <b>{{ settings.address }}</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-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
<n-icon :component="Copy" /> {{ t('copy') }} <n-icon :component="Copy" /> {{ t('copy') }}
</n-button> </n-button>
</span> </span>
</n-alert> </n-alert>
</div> </div>
<div v-else-if="isTelegram">
<TelegramAddress />
</div>
<div v-else class="center"> <div v-else class="center">
<n-card style="max-width: 600px;"> <n-card style="max-width: 600px;">
<n-alert v-if="jwt" type="warning" :show-icon="false" closable> <n-alert v-if="jwt" type="warning" :show-icon="false" closable>
@@ -88,7 +108,7 @@ onMounted(async () => {
</n-alert> </n-alert>
<Login /> <Login />
<n-divider /> <n-divider />
<n-button @click="router.push('/user')" type="primary" block secondary strong> <n-button @click="onUserLogin" type="primary" block secondary strong>
<template #icon> <template #icon>
<n-icon :component="User" /> <n-icon :component="User" />
</template> </template>
@@ -96,6 +116,15 @@ onMounted(async () => {
</n-button> </n-button>
</n-card> </n-card>
</div> </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')"> <n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
<span> <span>
<p>{{ t("addressCredentialTip") }}</p> <p>{{ t("addressCredentialTip") }}</p>

View File

@@ -0,0 +1,91 @@
<script setup>
import { ref, h, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { api } from '../../api'
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
download: 'Download',
action: 'Action',
},
zh: {
download: '下载',
action: '操作',
}
}
});
const data = ref([])
const showDownload = ref(false)
const curRow = ref({})
const curDownloadUrl = ref('')
const fetchData = async () => {
try {
const { results } = await api.fetch(
`/api/attachment/list`
);
data.value = results;
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "key",
key: "key"
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
tertiary: true,
onClick: async () => {
try {
const { url } = await api.fetch(`/api/attachment/get_url`, {
method: 'POST',
body: JSON.stringify({ key: row.key })
});
curDownloadUrl.value = url;
curRow.value = row;
showDownload.value = true;
}
catch (error) {
console.error(error);
message.error(error.message || "error");
}
}
},
{ default: () => t('download') }
)
])
}
}
]
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showDownload" preset="dialog" :title="t('download')">
<n-tag type="info">{{ curRow.key }}</n-tag>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curRow.key.replace('/', '_')"
:href="curDownloadUrl">
{{ t('download') }}
</n-button>
</n-modal>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { ref, h, computed } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import { useI18n } from 'vue-i18n'
import { NPopconfirm, NButton } from 'naive-ui'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import Login from '../common/Login.vue';
const { jwt } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
tip: 'These addresses are stored in your browser, maybe loss if you clear the browser cache.',
success: 'success',
address: 'Address',
actions: 'Actions',
changeMailAddress: 'Change Mail Address',
unbindMailAddress: 'Unbind Mail Address credential',
bind: 'Bind',
bindAddressSuccess: 'Bind Address Success',
},
zh: {
tip: '这些地址存储在您的浏览器中,如果您清除浏览器缓存,可能会丢失。',
success: '成功',
address: '地址',
actions: '操作',
changeMailAddress: '切换邮箱地址',
unbindMailAddress: '解绑邮箱地址',
bind: '绑定',
bindAddressSuccess: '绑定地址成功',
}
}
});
const tabValue = ref('address')
const localAddressCache = useLocalStorage("LocalAddressCache", []);
const data = computed(() => {
// @ts-ignore
if (!localAddressCache.value.includes(jwt.value)) {
// @ts-ignore
localAddressCache.value.push(jwt.value)
}
return localAddressCache.value.map((curJwt: string) => {
try {
var payload = JSON.parse(
decodeURIComponent(
atob(curJwt.split(".")[1]
.replace(/-/g, "+").replace(/_/g, "/")
)
)
);
return {
valid: true,
address: payload.address,
jwt: curJwt
}
} catch (e) {
return {
valid: false,
address: `invalid jwt [${curJwt}]`,
jwt: curJwt
}
}
})
})
const bindAddress = async () => {
try {
// @ts-ignore
if (!localAddressCache.value.includes(jwt.value)) {
// @ts-ignore
localAddressCache.value.push(jwt.value)
}
tabValue.value = 'address'
message.success(t('bindAddressSuccess'));
} catch (error) {
message.error((error as Error).message || "error");
}
}
const columns = [
{
title: t('address'),
key: "address"
},
{
title: t('actions'),
key: 'actions',
render(row: any) {
return h('div', [
h(NPopconfirm,
{
onPositiveClick: () => {
jwt.value = row.jwt
location.reload()
}
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "primary",
},
{ default: () => t('changeMailAddress') }
),
default: () => `${t('changeMailAddress')}?`
}
),
h(NPopconfirm,
{
onPositiveClick: () => {
if (jwt.value === row.jwt) {
return;
}
localAddressCache.value = localAddressCache.value.filter(
(curJwt: string) => curJwt !== row.jwt
);
}
},
{
trigger: () => h(NButton,
{
tertiary: true,
disabled: jwt.value === row.jwt,
type: "warning",
},
{ default: () => t('unbindMailAddress') }
),
default: () => `${t('unbindMailAddress')}?`
}
)
])
}
}
]
</script>
<template>
<div>
<n-alert type="warning" :show-icon="false">
<span>{{ t('tip') }}</span>
</n-alert>
<n-tabs type="segment" v-model:value="tabValue">
<n-tab-pane name="address" :tab="t('address')">
<n-data-table :columns="columns" :data="data" :bordered="false" />
</n-tab-pane>
<n-tab-pane name="bind" :tab="t('bind')">
<Login :bindUserAddress="bindAddress" />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

@@ -1,156 +0,0 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, settings } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
address: 'Address',
success: 'Success',
to_mail: 'To Mail',
subject: 'Subject',
created_at: 'Created At',
action: 'Action',
refresh: 'Refresh',
itemCount: 'itemCount',
view: 'View',
ok: 'OK'
},
zh: {
address: '地址',
success: '成功',
to_mail: '收件人邮箱',
subject: '主题',
created_at: '创建时间',
action: '操作',
refresh: '刷新',
itemCount: '总数',
view: '查看',
ok: '确定'
}
}
});
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curRow = ref({})
const showModal = ref(false)
const fetchData = async () => {
try {
const { results, count: addressCount } = await api.fetch(
`/api/sendbox`
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
);
data.value = results.map((item) => {
try {
const data = JSON.parse(item.raw);
item.to_mail = data?.personalizations?.map(
(p) => p.to?.map((t) => t.email).join(',')
).join(';');
item.subject = data.subject;
item.raw = JSON.stringify(data, null, 2);
} catch (error) {
console.log(error);
}
return item;
});
if (addressCount > 0) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
}
}
const columns = [
{
title: "ID",
key: "id"
},
{
title: t('address'),
key: "address"
},
{
title: t('to_mail'),
key: "to_mail"
},
{
title: t('subject'),
key: "subject"
},
{
title: t('created_at'),
key: "created_at"
},
{
title: t('action'),
key: 'actions',
render(row) {
return h('div', [
h(NButton,
{
type: 'success',
ghost: true,
onClick: () => {
showModal.value = true;
curRow.value = row;
}
},
{ default: () => t('view') }
)
])
}
}
]
watch([page, pageSize], async () => {
await fetchData()
})
onMounted(async () => {
await fetchData()
})
</script>
<template>
<div v-if="settings.address">
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
</n-modal>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
<template #suffix>
<n-button @click="fetchData" type="primary" size="small" tertiary>
{{ t('refresh') }}
</n-button>
</template>
</n-pagination>
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
</div>
</template>
<style scoped>
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import { ref, h, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'
import { NPopconfirm, NButton } from 'naive-ui'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
// @ts-ignore
import Login from '../common/Login.vue';
const { jwt, telegramApp } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
success: 'success',
address: 'Address',
actions: 'Actions',
changeMailAddress: 'Change Mail Address',
unbindMailAddress: 'Unbind Mail Address',
bind: 'Bind',
bindAddressSuccess: 'Bind Address Success',
},
zh: {
success: '成功',
address: '地址',
actions: '操作',
changeMailAddress: '切换邮箱地址',
unbindMailAddress: '解绑邮箱地址',
bind: '绑定',
bindAddressSuccess: '绑定地址成功',
}
}
});
const data = ref([]);
const fetchData = async () => {
try {
data.value = await api.fetch(`/telegram/get_bind_address`, {
method: 'POST',
body: JSON.stringify({
initData: telegramApp.value.initData
})
});
} catch (error) {
message.error((error as Error).message || "error");
}
}
const newAddressPath = async (address_name: string, domain: string, cf_token: string) => {
return await api.fetch("/telegram/new_address", {
method: "POST",
body: JSON.stringify({
initData: telegramApp.value.initData,
address: `${address_name}@${domain}`,
cf_token: cf_token,
}),
});
}
const bindAddress = async () => {
try {
await api.fetch(`/telegram/bind_address`, {
method: 'POST',
body: JSON.stringify({
initData: telegramApp.value.initData,
jwt: jwt.value
})
});
message.success(t('bindAddressSuccess'));
} catch (error) {
message.error((error as Error).message || "error");
}
}
const columns = [
{
title: t('address'),
key: "address"
},
{
title: t('actions'),
key: 'actions',
render(row: any) {
return h('div', [
h(NPopconfirm,
{
onPositiveClick: () => {
jwt.value = row.jwt
location.reload()
}
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "primary",
},
{ default: () => t('changeMailAddress') }
),
default: () => `${t('changeMailAddress')}?`
}
),
h(NPopconfirm,
{
onPositiveClick: () => {
api.fetch(`/telegram/unbind_address`, {
method: 'POST',
body: JSON.stringify({
initData: telegramApp.value.initData,
address: row.address
})
});
jwt.value = ""
location.reload()
}
},
{
trigger: () => h(NButton,
{
tertiary: true,
type: "warning",
},
{ default: () => t('unbindMailAddress') }
),
default: () => `${t('unbindMailAddress')}?`
}
)
])
}
}
]
onMounted(async () => {
if (!telegramApp.value?.initData || data.value.length > 0) {
return
}
await fetchData()
})
</script>
<template>
<div>
<n-tabs type="segment">
<n-tab-pane name="address" :tab="t('address')">
<n-data-table :columns="columns" :data="data" :bordered="false" />
</n-tab-pane>
<n-tab-pane name="bind" :tab="t('bind')">
<Login :newAddressPath="newAddressPath" :bindUserAddress="bindAddress" />
</n-tab-pane>
</n-tabs>
</div>
</template>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
// @ts-ignore
import { useGlobalState } from '../../store'
// @ts-ignore
import { api } from '../../api'
const { settings } = useGlobalState()
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
successTip: 'Success',
test: 'Test',
save: 'Save',
notEnabled: 'Webhook is not enabled for you',
urlMissing: 'URL is required',
},
zh: {
successTip: '成功',
test: '测试',
save: '保存',
notEnabled: 'Webhook 未开启,请联系管理员开启',
urlMissing: 'URL 不能为空',
}
}
});
class WebhookSettings {
url: string = ''
method: string = 'POST'
headers: string = JSON.stringify({}, null, 2)
body: string = JSON.stringify({}, null, 2)
}
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
const enableWebhook = ref(false)
const fetchData = async () => {
try {
const res = await api.fetch(`/api/webhook/settings`)
Object.assign(webhookSettings.value, res)
enableWebhook.value = true
} catch (error) {
message.error((error as Error).message || "error");
}
}
const saveSettings = async () => {
if (!webhookSettings.value.url) {
message.error(t('urlMissing'))
return
}
try {
await api.fetch(`/api/webhook/settings`, {
method: 'POST',
body: JSON.stringify(webhookSettings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
const testSettings = async () => {
if (!webhookSettings.value.url) {
message.error(t('urlMissing'))
return
}
try {
await api.fetch(`/api/webhook/test`, {
method: 'POST',
body: JSON.stringify(webhookSettings.value),
})
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center" v-if="settings.address">
<n-card v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
<n-form-item-row label="URL">
<n-input v-model:value="webhookSettings.url" />
</n-form-item-row>
<n-form-item-row label="METHOD">
<n-select v-model:value="webhookSettings.method" tag :options='[
{ label: "POST", value: "POST" }
]' />
</n-form-item-row>
<n-form-item-row label="HEADERS">
<n-input v-model:value="webhookSettings.headers" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-form-item-row label="BODY">
<n-input v-model:value="webhookSettings.body" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-button @click="testSettings" secondary block strong>
{{ t('test') }}
</n-button>
<n-button @click="saveSettings" type="primary" block>
{{ t('save') }}
</n-button>
</n-card>
<n-result v-else status="404" :title="t('notEnabled')" />
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,70 @@
<script setup>
import { useRoute } from 'vue-router'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import { onMounted, watch } from 'vue';
import { processItem } from '../../utils/email-parser'
const { telegramApp } = useGlobalState()
const route = useRoute()
const curMail = ref({});
watch(telegramApp, async () => {
if (telegramApp.value.initData) {
curMail.value = await fetchMailData();
}
});
const fetchMailData = async () => {
try {
const res = await api.fetch(`/telegram/get_mail`, {
method: 'POST',
body: JSON.stringify({
initData: telegramApp.value.initData,
mailId: route.query.mail_id
})
});
return await processItem(res);
}
catch (error) {
console.error(error);
return {};
}
};
onMounted(async () => {
curMail.value = await fetchMailData();
});
</script>
<template>
<div class="center">
<n-card v-if="curMail.message" style="max-width: 800px; overflow: auto;">
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
Date: {{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<div v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
</style>

View File

@@ -6,13 +6,13 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
import { getRouterPathWithLang } from '../../utils'
const { localeCache, jwt } = useGlobalState() const { jwt } = useGlobalState()
const message = useMessage() const message = useMessage()
const router = useRouter() const router = useRouter()
const { t } = useI18n({ const { locale, t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
success: 'success', success: 'success',
@@ -48,7 +48,7 @@ const changeMailAddress = async (address_id) => {
return; return;
} }
jwt.value = res.jwt; jwt.value = res.jwt;
await router.push('/'); await router.push(getRouterPathWithLang("/", locale.value))
location.reload(); location.reload();
} catch (error) { } catch (error) {
console.log(error) console.log(error)

View File

@@ -6,10 +6,9 @@ import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import Login from '../common/Login.vue' import Login from '../common/Login.vue'
const { userJwt, localeCache, userSettings, } = useGlobalState() const { userJwt, userSettings, } = useGlobalState()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
logout: 'Logout', logout: 'Logout',

View File

@@ -11,11 +11,10 @@ const message = useMessage()
const router = useRouter() const router = useRouter()
const { const {
localeCache, userSettings, userJwt, userOpenSettings userSettings, userJwt, userOpenSettings
} = useGlobalState() } = useGlobalState()
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
currentUser: 'Current Login User', currentUser: 'Current Login User',

View File

@@ -10,12 +10,11 @@ import { hashPassword } from '../../utils';
import Turnstile from '../../components/Turnstile.vue'; import Turnstile from '../../components/Turnstile.vue';
const { userJwt, localeCache, userTab, userOpenSettings } = useGlobalState() const { userJwt, userTab, userOpenSettings } = useGlobalState()
const message = useMessage(); const message = useMessage();
const router = useRouter(); const router = useRouter();
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
login: 'Login', login: 'Login',

View File

@@ -6,14 +6,13 @@ import { useRouter } from 'vue-router'
import { useGlobalState } from '../../store' import { useGlobalState } from '../../store'
import { api } from '../../api' import { api } from '../../api'
const { userJwt, localeCache, userSettings, } = useGlobalState() const { userJwt, userSettings, } = useGlobalState()
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const showLogout = ref(false) const showLogout = ref(false)
const { t } = useI18n({ const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: { messages: {
en: { en: {
logout: 'Logout', logout: 'Logout',

13
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": [
"ESNext"
],
"types": []
},
}

View File

@@ -13,15 +13,6 @@ import topLevelAwait from "vite-plugin-top-level-await";
export default defineConfig({ export default defineConfig({
build: { build: {
outDir: './dist', outDir: './dist',
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('wangeditor')) {
return 'vendor-wangeditor';
}
}
}
}
}, },
plugins: [ plugins: [
vue(), vue(),
@@ -71,5 +62,8 @@ export default defineConfig({
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
} }
},
define: {
'import.meta.env.PACKAGE_VERSION': JSON.stringify(process.env.npm_package_version),
} }
}) })

34
pages/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env.*
*-dist/
components.d.ts
.wrangler/
pnpm-lock.yaml

View File

@@ -0,0 +1,9 @@
const API_PATHS = ["/api/", "/open_api/", "/user_api/", "/admin/"]
export async function onRequest(context) {
const reqPath = new URL(context.request.url).pathname;
if (API_PATHS.map(path => reqPath.startsWith(path)).some(Boolean)) {
return context.env.BACKEND.fetch(context.request);
}
return await context.next();
}

16
pages/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "temp-email-pages",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "wrangler pages dev",
"deploy": "wrangler pages deploy --branch production"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^3.55.0"
}
}

8
pages/wrangler.toml Normal file
View File

@@ -0,0 +1,8 @@
name = "temp-email-pages"
pages_build_output_dir = "../frontend/dist"
compatibility_date = "2024-05-13"
[[services]]
binding = "BACKEND"
service = "cloudflare_temp_email"
environment = "production"

View File

@@ -0,0 +1,21 @@
import logging
from pydantic_settings import BaseSettings
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
class Settings(BaseSettings):
proxy_url: str = "http://localhost:8787"
port: int = 8025
imap_port: int = 11143
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -7,6 +7,8 @@ services:
container_name: "smtp_proxy_server" container_name: "smtp_proxy_server"
ports: ports:
- "8025:8025" - "8025:8025"
- "11143:11143"
environment: environment:
- proxy_url=https://temp-email-api.xxx.xxx - proxy_url=https://temp-email-api.xxx.xxx
- port=8025 - port=8025
- imap_port=11143

View File

@@ -4,4 +4,4 @@ WORKDIR /app
COPY requirements.txt /requirements.txt COPY requirements.txt /requirements.txt
RUN python3 -m pip install -r /requirements.txt RUN python3 -m pip install -r /requirements.txt
COPY . /app COPY . /app
ENTRYPOINT [ "python3", "server.py" ] ENTRYPOINT [ "python3", "main.py" ]

View File

@@ -0,0 +1,249 @@
import json
import logging
import httpx
from io import BytesIO
from twisted.mail import imap4
from zope.interface import implementer
from twisted.cred.portal import Portal, IRealm
from twisted.internet import protocol, reactor, defer
from twisted.cred.checkers import ICredentialsChecker, IUsernamePassword
from config import settings
from parse_email import generate_email_model, parse_email
from models import EmailModel
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
@implementer(imap4.IMessage)
class SimpleMessage:
def __init__(self, uid=None, email_model: EmailModel = None):
self.uid = uid
self.email = email_model
self.subparts = self.email.subparts
def getUID(self):
return self.uid
def getHeaders(self, negate, *names):
self.got_headers = negate, names
return {
k.lower(): v
for k, v in self.email.headers.items()
}
def isMultipart(self):
return len(self.subparts) > 0
def getSubPart(self, part):
self.got_subpart = part
return SimpleMessage(email_model=self.subparts[part])
def getBodyFile(self):
return BytesIO(self.email.body.encode("utf-8"))
def getSize(self):
return self.email.size
def getFlags(self):
return ["\\Seen"]
def getInternalDate(self):
return self.email.headers.get("Date", "Mon, 1 Jan 1900 00:00:00 +0000")
@implementer(imap4.IMailboxInfo, imap4.IMailbox)
class SimpleMailbox:
def __init__(self, name, password):
self.name = name
self.password = password
self.listeners = []
self.addListener = self.listeners.append
self.removeListener = self.listeners.remove
self.message_count = 0
def getFlags(self):
return ["\\Seen"]
def getUIDValidity(self):
return 0
def getMessageCount(self):
return self.message_count or 1000
def getRecentCount(self):
return 0
def getUnseenCount(self):
return 0
def isWriteable(self):
return 0
def destroy(self):
pass
def getHierarchicalDelimiter(self):
return "/"
def requestStatus(self, names):
r = {}
if "MESSAGES" in names:
r["MESSAGES"] = self.getMessageCount()
if "RECENT" in names:
r["RECENT"] = self.getRecentCount()
if "UIDNEXT" in names:
r["UIDNEXT"] = self.getMessageCount() + 1
if "UIDVALIDITY" in names:
r["UIDVALIDITY"] = self.getUIDValidity()
if "UNSEEN" in names:
r["UNSEEN"] = self.getUnseenCount()
return defer.succeed(r)
def fetch(self, messages, uid):
if self.name == "INBOX":
return self.fetchINBOX(messages)
if self.name == "SENT":
return self.fetchSENT(messages)
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}",
headers={
"Authorization": f"Bearer {self.password}",
"Content-Type": "application/json"
}
)
if res.status_code != 200:
_logger.error(
"Failed: "
f"code=[{res.status_code}] text=[{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"]))
]
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}",
"Content-Type": "application/json"
}
)
if res.status_code != 200:
_logger.error(
"Failed: "
f"code=[{res.status_code}] text=[{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, generate_email_model(item)))
for uid, item in enumerate(reversed(res.json()["results"]))
]
def getUID(self, message):
return message.uid
def store(self, messages, flags, mode, uid):
# IMailboxIMAP.store
raise NotImplementedError
class Account(imap4.MemoryAccount):
def __init__(self, user, password):
self.password = password
super().__init__(user)
def isSubscribed(self, name):
return name.upper() in ["INBOX", "SENT"]
def _emptyMailbox(self, name, id):
_logger.info(f"New mailbox: {name}, {id}")
if name == "INBOX":
return SimpleMailbox(name, self.password)
if name == "SENT":
return SimpleMailbox(name, self.password)
raise imap4.NoSuchMailbox(name.encode("utf-8"))
def select(self, name, rw=1):
return imap4.MemoryAccount.select(self, name)
class SimpleIMAPServer(imap4.IMAP4Server):
def __init__(self, factory):
imap4.IMAP4Server.__init__(self)
self.factory = factory
def lineReceived(self, line):
# _logger.info(f"Received: {line}")
super().lineReceived(line)
def sendLine(self, line):
# _logger.info(f"Sent: {line}")
super().sendLine(line)
@implementer(IRealm)
class SimpleRealm:
def requestAvatar(self, avatarId, mind, *interfaces):
res = json.loads(avatarId)
account = Account(res["username"], res["password"])
account.addMailbox("INBOX")
account.addMailbox("SENT")
return imap4.IAccount, account, lambda: None
class IMAPFactory(protocol.Factory):
def __init__(self, portal):
self.portal = portal
def buildProtocol(self, addr):
p = SimpleIMAPServer(self)
p.portal = self.portal
return p
@implementer(ICredentialsChecker)
class CustomChecker:
credentialInterfaces = (IUsernamePassword,)
def requestAvatarId(self, credentials):
return defer.succeed(json.dumps({
"username": credentials.username.decode(),
"password": credentials.password.decode(),
}))
def start_imap_server():
_logger.info(f"Starting IMAP server on port {settings.imap_port}")
portal = Portal(SimpleRealm(), [CustomChecker()])
reactor.listenTCP(settings.imap_port, IMAPFactory(portal))
reactor.run()
if __name__ == "__main__":
_logger.info(f"Starting server settings[{settings}]")
start_imap_server()

24
smtp_proxy_server/main.py Normal file
View File

@@ -0,0 +1,24 @@
import logging
import multiprocessing
from smtp_server import start_smtp_server
from imap_server import start_imap_server
from config import settings
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
if __name__ == '__main__':
_logger.info(f"Starting server settings[{settings}]")
process_list = [
multiprocessing.Process(target=start_smtp_server, args=()),
multiprocessing.Process(target=start_imap_server, args=()),
]
try:
for p in process_list:
p.start()
for p in process_list:
p.join()
except KeyboardInterrupt:
for p in process_list:
p.terminate()

View File

@@ -0,0 +1,10 @@
from typing import Dict, List
from pydantic import BaseModel
class EmailModel(BaseModel):
headers: Dict[str, str]
body: str
content_type: str
subparts: List["EmailModel"]
size: int

View File

@@ -0,0 +1,62 @@
import datetime
import json
import logging
import email
from email.message import Message
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from models import EmailModel
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
def get_email_model(msg: Message):
subparts = [
get_email_model(subpart)
for subpart in msg.get_payload()
] if msg.is_multipart() else []
body = "" if msg.is_multipart() else msg._payload
return EmailModel(
headers={k: v for k, v in msg.items()},
body=body,
content_type=msg.get_content_type(),
size=len(body) + sum(subpart.size for subpart in subparts),
subparts=subparts,
)
def parse_email(raw: str) -> EmailModel:
try:
msg = email.message_from_string(raw)
return get_email_model(msg)
except Exception as e:
_logger.error(f"Could not parse email: {e}")
return EmailModel(
headers={},
body="could not parse email",
content_type="text/plain",
size=len("could not parse email"),
subparts=[],
)
def generate_email_model(item: dict) -> EmailModel:
email_json = json.loads(item["raw"])
message = MIMEMultipart()
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['Subject'] = email_json["subject"]
message["Date"] = datetime.datetime.strptime(
item["created_at"], "%Y-%m-%d %H:%M:%S"
).strftime("%a, %d %b %Y %H:%M:%S +0000")
message.attach(MIMEText(
email_json["content"][0]["value"],
"html" if "html" in email_json["content"][0]["type"] else "plain"
))
return parse_email(message.as_string())

View File

@@ -1,3 +1,5 @@
aiosmtpd==1.4.5 aiosmtpd==1.4.6
pydantic-settings==2.2.1 pydantic-settings==2.2.1
requests==2.31.0 requests==2.32.0
twisted==24.3.0
httpx==0.27.0

View File

@@ -1,28 +1,17 @@
import asyncio import asyncio
import logging import logging
import email import email
import requests import httpx
from pydantic_settings import BaseSettings
from aiosmtpd.controller import Controller from aiosmtpd.controller import Controller
from aiosmtpd.smtp import SMTP, Session, Envelope, AuthResult, LoginPassword from aiosmtpd.smtp import SMTP, Session, Envelope, AuthResult, LoginPassword
logging.basicConfig( from config import settings
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
)
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO) _logger.setLevel(logging.INFO)
class Settings(BaseSettings):
proxy_url: str = "http://localhost:8787"
port: int = 8025
class Config:
env_file = ".env"
class CustomSMTPHandler: class CustomSMTPHandler:
def authenticator(self, server, session, envelope, mechanism, auth_data): def authenticator(self, server, session, envelope, mechanism, auth_data):
@@ -51,20 +40,34 @@ class CustomSMTPHandler:
if msg.is_multipart(): if msg.is_multipart():
for part in msg.walk(): for part in msg.walk():
content_type = part.get_content_type() content_type = part.get_content_type()
payload = part.get_payload(decode=True) charset = part.get_content_charset()
cte = str(part.get('content-transfer-encoding', '')).lower()
if content_type not in ["text/plain", "text/html"]: if content_type not in ["text/plain", "text/html"]:
_logger.warning(f"Skipping {content_type}") _logger.warning(f"Skipping {content_type}")
continue continue
if not payload: if cte == "8bit":
value = part.get_payload(decode=False)
else:
payload = part.get_payload(decode=True)
value = payload.decode(charset) if charset else payload
if not value:
continue continue
content_list.append({ content_list.append({
"type": content_type, "type": content_type,
"value": payload.decode() "value": value
}) })
elif msg.get_content_type() in ["text/plain", "text/html"] and msg.get_payload(decode=True): elif msg.get_content_type() in ["text/plain", "text/html"] and msg.get_payload(decode=True):
cte = str(msg.get('content-transfer-encoding', '')).lower()
charset = msg.get_content_charset()
if cte == "8bit":
value = msg.get_payload(decode=False)
else:
payload = msg.get_payload(decode=True)
value = payload.decode(charset) if charset else payload
_logger.info(f"Payload {msg._payload} charset {charset}")
content_list.append({ content_list.append({
"type": msg.get_content_type(), "type": msg.get_content_type(),
"value": msg.get_payload(decode=True).decode() "value": value
}) })
if not content_list: if not content_list:
@@ -97,9 +100,9 @@ class CustomSMTPHandler:
"is_html": body["type"] == "text/html", "is_html": body["type"] == "text/html",
"content": body["value"], "content": body["value"],
} }
_logger.info(f"Send mail {send_body}") _logger.info(f"Send mail {dict(send_body, token='***')}")
try: try:
res = requests.post( res = httpx.post(
f"{settings.proxy_url}/external/api/send_mail", f"{settings.proxy_url}/external/api/send_mail",
json=send_body, headers={ json=send_body, headers={
"Content-Type": "application/json" "Content-Type": "application/json"
@@ -118,7 +121,6 @@ class CustomSMTPHandler:
return '250 OK' return '250 OK'
settings = Settings()
handler = CustomSMTPHandler() handler = CustomSMTPHandler()
server = Controller( server = Controller(
handler, handler,
@@ -132,11 +134,11 @@ server = Controller(
async def start(): async def start():
_logger.info(f"Starting server settings[{settings}]") _logger.info(f"Starting server on port {settings.port}")
server.start() server.start()
if __name__ == "__main__": def start_smtp_server():
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
task = loop.create_task(start()) task = loop.create_task(start())
@@ -145,3 +147,8 @@ if __name__ == "__main__":
except KeyboardInterrupt: except KeyboardInterrupt:
_logger.info("Got KeyboardInterrupt, stopping") _logger.info("Got KeyboardInterrupt, stopping")
server.stop() server.stop()
if __name__ == "__main__":
_logger.info(f"Starting server settings[{settings}]")
start_smtp_server()

View File

@@ -96,7 +96,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
}, },
{ {
text: '通过命令行部署', text: '通过命令行部署',
collapsed: false, collapsed: true,
items: [ items: [
{ text: '命令行部署准备', link: 'cli/pre-requisite' }, { text: '命令行部署准备', link: 'cli/pre-requisite' },
{ text: 'D1 数据库', link: 'cli/d1' }, { text: 'D1 数据库', link: 'cli/d1' },
@@ -109,7 +109,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
}, },
{ {
text: '通过用户界面部署', text: '通过用户界面部署',
collapsed: false, collapsed: true,
items: [ items: [
{ text: 'D1 数据库', link: 'ui/d1' }, { text: 'D1 数据库', link: 'ui/d1' },
{ text: '配置 DKIM', link: 'dkim' }, { text: '配置 DKIM', link: 'dkim' },
@@ -121,24 +121,26 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
}, },
{ {
text: '通过 Github Actions 部署', text: '通过 Github Actions 部署',
collapsed: false, collapsed: true,
items: [ items: [
{ text: '开发中', link: 'github-action' }, { text: '通过 Github Actions 部署', link: 'github-action' },
] ]
}, },
{ {
text: '附加功能', text: '附加功能',
collapsed: false, collapsed: true,
items: [ items: [
{ text: '配置 SMTP 代理服务', link: 'feature/config-smtp-proxy' }, { text: '配置 SMTP IMAP 代理服务', link: 'feature/config-smtp-proxy' },
{ text: '发送邮件 API', link: 'feature/send-mail-api' }, { text: '发送邮件 API', link: 'feature/send-mail-api' },
{ text: '查看邮件 API', link: 'feature/mail-api' }, { text: '查看邮件 API', link: 'feature/mail-api' },
{ text: '配置子域名邮箱', link: 'feature/subdomain' }, { text: '配置子域名邮箱', link: 'feature/subdomain' },
{ text: '配置 Telegram Bot', link: 'feature/telegram' },
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
] ]
}, },
{ {
text: '功能简介', text: '功能简介',
collapsed: false, collapsed: true,
items: [ items: [
{ text: 'Admin 控制台', link: 'feature/admin' }, { text: 'Admin 控制台', link: 'feature/admin' },
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' }, { text: 'Admin 用户管理', link: 'feature/admin-user-management' },

View File

@@ -60,7 +60,7 @@ pnpm run deploy
```toml ```toml
name = "cloudflare_temp_email" name = "cloudflare_temp_email"
main = "src/worker.js" main = "src/worker.ts"
compatibility_date = "2023-08-14" compatibility_date = "2023-08-14"
node_compat = true node_compat = true
@@ -68,7 +68,13 @@ node_compat = true
# [triggers] # [triggers]
# crons = [ "0 0 * * *" ] # crons = [ "0 0 * * *" ]
# send mail by cf mail
# send_email = [
# { name = "SEND_MAIL" },
# ]
[vars] [vars]
# TITLE = "Custom Title" # The title of the site
PREFIX = "tmp" # The mailbox name prefix to be processed PREFIX = "tmp" # The mailbox name prefix to be processed
# If you want your site to be private, uncomment below and change your password # If you want your site to be private, uncomment below and change your password
# PASSWORDS = ["123", "456"] # PASSWORDS = ["123", "456"]
@@ -85,6 +91,8 @@ ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true ENABLE_USER_DELETE_EMAIL = true
# Allow automatic replies to emails # Allow automatic replies to emails
ENABLE_AUTO_REPLY = false ENABLE_AUTO_REPLY = false
# Allow webhook
# ENABLE_WEBHOOK = true
# Footer text # Footer text
# COPYRIGHT = "Dream Hunter" # COPYRIGHT = "Dream Hunter"
# default send balance, if not set, it will be 0 # default send balance, if not set, it will be 0
@@ -95,6 +103,10 @@ ENABLE_AUTO_REPLY = false
# dkim config # dkim config
# DKIM_SELECTOR = "mailchannels" # Refer to the DKIM section mailchannels._domainkey for mailchannels # DKIM_SELECTOR = "mailchannels" # Refer to the DKIM section mailchannels._domainkey for mailchannels
# DKIM_PRIVATE_KEY = "" # Refer to the contents of priv_key.txt in the DKIM section # DKIM_PRIVATE_KEY = "" # Refer to the contents of priv_key.txt in the DKIM section
# telegram bot
# TG_MAX_ACCOUNTS = 5
# global forward address list, if set, all emails will be forwarded to these addresses
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
[[d1_databases]] [[d1_databases]]
binding = "DB" binding = "DB"

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,5 +1,11 @@
# Cloudflare Pages 前端 # Cloudflare Pages 前端
::: warning
下面两种方式选择一种即可
:::
## 前后端分离部署
第一次部署会提示创建项目, `production` 分支请填写 `production` 第一次部署会提示创建项目, `production` 分支请填写 `production`
```bash ```bash
@@ -23,3 +29,19 @@ pnpm run deploy
部署完成之后你可以在 Cloudflare 控制台看到你的项目, 可以为 `pages` 配置自定义域名 部署完成之后你可以在 Cloudflare 控制台看到你的项目, 可以为 `pages` 配置自定义域名
![pages](/readme_assets/pages.png) ![pages](/readme_assets/pages.png)
## 通过 page functions 转发后端请求
从 page functions 转发请求到 worker 后端, 可以获取更快的响应速度
第一次部署会提示创建项目, `production` 分支请填写 `production`
如果你的 worker 后端 名称不为 `cloudflare_temp_email` 请修改 `pages/wrangler.toml`
```bash
cd frontend
pnpm install
pnpm build:pages
cd ../pages
pnpm run deploy
```

View File

@@ -12,6 +12,7 @@ cp wrangler.toml.template wrangler.toml
> [!NOTE] > [!NOTE]
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤 > 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
> 如果需要 Telegram Bot需要创建 `KV` 缓存,不需要可跳过此步骤
通过命令行创建 KV 缓存,或者在 Cloudflare 控制台创建,然后复制对应配置到 `wrangler.toml` 文件中 通过命令行创建 KV 缓存,或者在 Cloudflare 控制台创建,然后复制对应配置到 `wrangler.toml` 文件中
@@ -23,7 +24,7 @@ wrangler kv:namespace create DEV
```toml ```toml
name = "cloudflare_temp_email" name = "cloudflare_temp_email"
main = "src/worker.js" main = "src/worker.ts"
compatibility_date = "2023-12-01" compatibility_date = "2023-12-01"
# 如果你想使用自定义域名,你需要添加 routes 配置 # 如果你想使用自定义域名,你需要添加 routes 配置
# routes = [ # routes = [
@@ -35,7 +36,13 @@ node_compat = true
# [triggers] # [triggers]
# crons = [ "0 0 * * *" ] # crons = [ "0 0 * * *" ]
# 通过 Cloudflare 发送邮件
# send_email = [
# { name = "SEND_MAIL" },
# ]
[vars] [vars]
# TITLE = "Custom Title" # 自定义网站标题
PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串 PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串
# 如果你想要你的网站私有,取消下面的注释,并修改密码 # 如果你想要你的网站私有,取消下面的注释,并修改密码
# PASSWORDS = ["123", "456"] # PASSWORDS = ["123", "456"]
@@ -52,6 +59,8 @@ ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true ENABLE_USER_DELETE_EMAIL = true
# 允许自动回复邮件 # 允许自动回复邮件
ENABLE_AUTO_REPLY = false ENABLE_AUTO_REPLY = false
# 是否启用 webhook
# ENABLE_WEBHOOK = true
# 前端界面页脚文本 # 前端界面页脚文本
# COPYRIGHT = "Dream Hunter" # COPYRIGHT = "Dream Hunter"
# 默认发送邮件余额,如果不设置,将为 0 # 默认发送邮件余额,如果不设置,将为 0
@@ -62,6 +71,10 @@ ENABLE_AUTO_REPLY = false
# dkim config # dkim config
# DKIM_SELECTOR = "mailchannels" # 参考 DKIM 部分 mailchannels._domainkey 的 mailchannels # DKIM_SELECTOR = "mailchannels" # 参考 DKIM 部分 mailchannels._domainkey 的 mailchannels
# DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容 # DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容
# telegram bot 最多绑定邮箱数量
# TG_MAX_ACCOUNTS = 5
# 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
# D1 数据库的名称和 ID 可以在 cloudflare 控制台查看 # D1 数据库的名称和 ID 可以在 cloudflare 控制台查看
[[d1_databases]] [[d1_databases]]
@@ -83,6 +96,17 @@ database_id = "xxx" # D1 数据库 ID
# simple = { limit = 10, period = 60 } # simple = { limit = 10, period = 60 }
``` ```
## Telegram Bot 配置
> [!NOTE]
> 如果不需要 Telegram Bot, 可跳过此步骤
请先创建一个 Telegram Bot然后获取 `token`,然后执行下面的命令,将 `token` 添加到 secrets 中
```bash
pnpm wrangler secret put TELEGRAM_BOT_TOKEN
```
## 部署 ## 部署
第一次部署会提示创建项目, `production` 分支请填写 `production` 第一次部署会提示创建项目, `production` 分支请填写 `production`

View File

@@ -1,6 +1,35 @@
# 配置发送邮件 # 配置发送邮件
## 使用 Cloudflare Workers 给已认证的邮箱发送邮件
admin 后台 账号配置 `已验证地址列表(可通过 cf 内部 api 发送邮件)`
## 使用 resend 发送邮件
注册 `https://resend.com/domains` 根据提示添加 DNS 记录,
`API KEYS` 页面创建 `api key`
使用 cli 或者直接添加到 `wrangler.toml``vars`,或者在 cloudflare worker 页面的变量中添加 `RESEND_TOKEN`
```bash
wrangler secret put RESEND_TOKEN
```
如果你有多个域名,对应不同的 `api key`,可以在 `wrangler.toml` 中添加多个 secret, 名称为 `RESEND_TOKEN_` + `<. 换成 _ 的 大写域名>`,例如
```bash
wrangler secret put RESEND_TOKEN_XXX_COM
wrangler secret put RESEND_TOKEN_DREAMHUNTER2333_XYZ
```
## 使用 mailchannels 发送邮件
::: warning
[Mail Channels 免费电子邮件发送 API 将于2024年6月30日结束](https://support.mailchannels.com/hc/en-us/articles/26814255454093-End-of-Life-Notice-Cloudflare-Workers)
:::
1. 找到域名 `DNS` 记录的 `TXT``SPF` 记录, 增加 `include:relay.mailchannels.net` 1. 找到域名 `DNS` 记录的 `TXT``SPF` 记录, 增加 `include:relay.mailchannels.net`
`v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all` `v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all`

View File

@@ -1,10 +1,14 @@
# 搭建 SMTP 代理服务 # 搭建 SMTP IMAP 代理服务
## 为什么需要 SMTP 代理服务 ::: warning
如果你使用了 `resend`, 可直接使用 `resend``SMTP` 服务,不需要使用此服务
:::
SMTP 的应用场景更加广泛 ## 为什么需要 SMTP IMAP 代理服务
## 如何搭建 SMTP 代理服务 `SMTP` `IMAP` 的应用场景更加广泛
## 如何搭建 SMTP IMAP 代理服务
### Local Run ### Local Run
@@ -16,7 +20,7 @@ cd smtp_proxy_server/
cp .env.example .env cp .env.example .env
python3 -m venv venv python3 -m venv venv
./venv/bin/python3 -m pip install -r requirements.txt ./venv/bin/python3 -m pip install -r requirements.txt
./venv/bin/python3 server.py ./venv/bin/python3 main.py
``` ```
### Docker Run ### Docker Run
@@ -28,14 +32,29 @@ docker-compose up -d
修改 docker-compose.yaml 中的环境变量, 注意选择合适的 `tag` 修改 docker-compose.yaml 中的环境变量, 注意选择合适的 `tag`
`proxy_url``worker` 的 URL 地址
```yaml ```yaml
services: services:
smtp_proxy_server: smtp_proxy_server:
image: ghcr.io/dreamhunter2333/cloudflare_temp_email/smtp_proxy_server:latest image: ghcr.io/dreamhunter2333/cloudflare_temp_email/smtp_proxy_server:latest
# build:
# context: .
# dockerfile: dockerfile
container_name: "smtp_proxy_server" container_name: "smtp_proxy_server"
ports: ports:
- "8025:8025" - "8025:8025"
- "11143:11143"
environment: environment:
- proxy_url=https://temp-email-api.xxx.xxx - proxy_url=https://temp-email-api.xxx.xxx
- port=8025 - port=8025
- imap_port=11143
``` ```
## 使用 Thunderbird 登录
下载 [Thunderbird](https://www.thunderbird.net/en-US/)
密码填写 `邮箱地址凭证`
![imap](/feature/imap.png)

View File

@@ -7,9 +7,9 @@
```python ```python
limit = 10 limit = 10
offset = 0 offset = 0
res = requests.post( res = requests.get(
f"http://localhost:8787/api/mails?limit={limit}&offset={offset}`;", f"http://localhost:8787/api/mails?limit={limit}&offset={offset}",
json=send_body, headers={ headers={
"Authorization": f"Bearer {你的JWT密码}", "Authorization": f"Bearer {你的JWT密码}",
# "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码 # "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码
"Content-Type": "application/json" "Content-Type": "application/json"

View File

@@ -0,0 +1,34 @@
# 配置 S3 附件
## 配置
> [!NOTE]
> 如果不需要 S3 附件, 可跳过此步骤
在 Cloudflare 创建一个 R2 bucket, 你也可以使用其他的 S3 服务(如有 bug 请提 issue)
参考: [配置 Cloudflare R2 的 cors](https://developers.cloudflare.com/r2/buckets/cors/#add-cors-policies-from-the-dashboard)
参考 [Cloudflare R2 s3 toke](https://developers.cloudflare.com/r2/api/s3/tokens/) 创建 token, 拿到 `ENDPOINT`, `Access Key ID``Secret Access Key`,然后执行下面的命令添加到 secrets 中
> [!NOTE]
> 你也可以在 Cloudflare worker 的 UI 界面中添加 `secrets`
```bash
cd worker
pnpm wrangler secret put S3_ENDPOINT
pnpm wrangler secret put S3_ACCESS_KEY_ID
pnpm wrangler secret put S3_SECRET_ACCESS_KEY
# 请注意这里的 bucket 是你的 bucket 名称
pnpm wrangler secret put S3_BUCKET
```
## 使用
保存附件
![S3 save](/feature/s3-save.png)
下载附件
![S3 download](/public/feature/s3-download.png)

View File

@@ -0,0 +1,38 @@
# 配置 Telegram Bot
## Telegram Bot 配置
> [!NOTE]
> 如果不需要 Telegram Bot, 可跳过此步骤
请先创建一个 Telegram Bot然后获取 `token`,然后执行下面的命令,将 `token` 添加到 secrets 中
你也可以在 Cloudflare 的 UI 界面中添加 `secrets`
```bash
pnpm wrangler secret put TELEGRAM_BOT_TOKEN
```
## Bot
- 可设置白名单用户
- 点击`初始化`即可完成配置。
- 点击`查看状态`,可以查看当前配置的状态。
![telegram](/feature/telegram.png)
## Mini App
```bash
cd frontend
pnpm install
cp .env.example .env.prod
# --project-name 可以单独为 mini app 创建一个 pages, 你也可以公用一个 pages但是可能遇到 js 加载不了的问题
pnpm run deploy:telegram --project-name=<你的项目名称>
```
部署完成后,请在 admin 后台的 `设置` -> `电报小程序` 页面 `电报小程序 URL`
请在 `@BotFather` 处执行 `/setmenubutton`,然后输入你的网页地址,设置左下角的 `Open App` 按钮。
你也可以在 `@BotFather` 处执行 `/newapp` 新建 app 来获得 mini app 的链接

View File

@@ -17,5 +17,6 @@
- `BACKEND_TOML`: 后端配置文件,[参考此处](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件) - `BACKEND_TOML`: 后端配置文件,[参考此处](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件)
- `FRONTEND_ENV`: 前端配置文件,请复制 `frontend/.env.example` 的内容,[并参考此处修改](/zh/guide/cli/pages.html) - `FRONTEND_ENV`: 前端配置文件,请复制 `frontend/.env.example` 的内容,[并参考此处修改](/zh/guide/cli/pages.html)
- `FRONTEND_NAME`: 你在 Cloudflare Pages 创建的项目名称,可通过 [用户界面](https://temp-mail-docs.awsl.uk/zh/guide/ui/pages.html) 或者 [命令行](https://temp-mail-docs.awsl.uk/zh/guide/cli/pages.html) 创建 - `FRONTEND_NAME`: 你在 Cloudflare Pages 创建的项目名称,可通过 [用户界面](https://temp-mail-docs.awsl.uk/zh/guide/ui/pages.html) 或者 [命令行](https://temp-mail-docs.awsl.uk/zh/guide/cli/pages.html) 创建
- `TG_FRONTEND_NAME`: (可选) 你在 Cloudflare Pages 创建的项目名称,同 `FRONTEND_NAME`,如果需要 Telegram Mini App 功能,请填写
1. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production``Deploy Frontend`,点击 `Run workflow` 选择分支手动部署 1. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production``Deploy Frontend`,点击 `Run workflow` 选择分支手动部署

View File

@@ -45,3 +45,10 @@
![worker-kv](/ui_install/worker-kv.png) ![worker-kv](/ui_install/worker-kv.png)
![worker-kv-bind](/ui_install/worker-kv-bind.png) ![worker-kv-bind](/ui_install/worker-kv-bind.png)
9. Telegram Bot 配置
> [!NOTE]
> 如果不需要 Telegram Bot, 可跳过此步骤
请先创建一个 Telegram Bot然后获取 `token`,然后执行下面的命令,将 `token` 添加到 `Variables` 中, Name: `TELEGRAM_BOT_TOKEN`

1
worker/.gitignore vendored
View File

@@ -131,3 +131,4 @@ dist
.wrangler .wrangler
wrangler.toml wrangler.toml
.dev.vars

19
worker/eslint.config.js Normal file
View File

@@ -0,0 +1,19 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
{
languageOptions: { globals: globals.browser },
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
}
}
];

View File

@@ -5,15 +5,31 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wrangler dev", "dev": "wrangler dev",
"deploy": "wrangler deploy", "lint": "eslint src",
"deploy": "wrangler deploy --minify",
"start": "wrangler dev", "start": "wrangler dev",
"build": "wrangler deploy src/worker.js --dry-run --outdir dist --minify" "build": "wrangler deploy --dry-run --outdir dist --minify"
}, },
"devDependencies": { "devDependencies": {
"wrangler": "^3.53.1" "@cloudflare/workers-types": "^4.20240512.0",
"@eslint/js": "8.56.0",
"eslint": "8.56.0",
"globals": "^15.3.0",
"typescript-eslint": "^7.10.0",
"wrangler": "^3.57.1"
}, },
"dependencies": { "dependencies": {
"hono": "^4.3.0", "@aws-sdk/client-s3": "^3.588.0",
"mimetext": "^3.0.24" "@aws-sdk/s3-request-presigner": "^3.588.0",
"hono": "^4.3.9",
"mimetext": "^3.0.24",
"postal-mime": "^2.2.5",
"resend": "^3.2.0",
"telegraf": "4.16.3"
},
"pnpm": {
"patchedDependencies": {
"telegraf@4.16.3": "patches/telegraf@4.16.3.patch"
}
} }
} }

View File

@@ -0,0 +1,163 @@
diff --git a/lib/core/network/client.js b/lib/core/network/client.js
index 25fbbbb47c7f88e83ae26f629e5ae1a0c141725c..209d4a6bf05352f44eeb082eb327581d698de5ce 100644
--- a/lib/core/network/client.js
+++ b/lib/core/network/client.js
@@ -1,18 +1,18 @@
"use strict";
-var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function (o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
- desc = { enumerable: true, get: function() { return m[k]; } };
+ desc = { enumerable: true, get: function () { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
-}) : (function(o, m, k, k2) {
+}) : (function (o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
-var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
+var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function (o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
-}) : function(o, v) {
+}) : function (o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
@@ -29,8 +29,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
/* eslint @typescript-eslint/restrict-template-expressions: [ "error", { "allowNumber": true, "allowBoolean": true } ] */
const crypto = __importStar(require("crypto"));
const fs = __importStar(require("fs"));
-const promises_1 = require("fs/promises");
-const https = __importStar(require("https"));
+// const promises_1 = require("fs/promises");
+// const https = __importStar(require("https"));
const path = __importStar(require("path"));
const node_fetch_1 = __importDefault(require("node-fetch"));
const check_1 = require("../helpers/check");
@@ -61,10 +61,10 @@ const DEFAULT_OPTIONS = {
apiRoot: 'https://api.telegram.org',
apiMode: 'bot',
webhookReply: true,
- agent: new https.Agent({
- keepAlive: true,
- keepAliveMsecs: 10000,
- }),
+ // agent: new https.Agent({
+ // keepAlive: true,
+ // keepAliveMsecs: 10000,
+ // }),
attachmentAgent: undefined,
testEnv: false,
};
@@ -112,9 +112,9 @@ async function buildFormDataConfig(payload, agent) {
}
const boundary = crypto.randomBytes(32).toString('hex');
const formData = new multipart_stream_1.default(boundary);
- await Promise.all(Object.keys(payload).map((key) =>
- // @ts-expect-error payload[key] can obviously index payload, but TS doesn't trust us
- attachFormValue(formData, key, payload[key], agent)));
+ await Promise.all(Object.keys(payload).map((key) =>
+ // @ts-expect-error payload[key] can obviously index payload, but TS doesn't trust us
+ attachFormValue(formData, key, payload[key], agent)));
return {
method: 'POST',
compress: true,
@@ -205,14 +205,15 @@ async function attachFormMedia(form, media, id, agent) {
if ('source' in media && media.source) {
let mediaSource = media.source;
if (typeof media.source === 'string') {
- const source = await (0, promises_1.realpath)(media.source);
- if ((await (0, promises_1.stat)(source)).isFile()) {
- fileName = (_c = media.filename) !== null && _c !== void 0 ? _c : path.basename(media.source);
- mediaSource = await fs.createReadStream(media.source);
- }
- else {
- throw new TypeError(`Unable to upload '${media.source}', not a file`);
- }
+ throw new TypeError(`Unable to upload '${media.source}', not a file`);
+ // const source = await (0, promises_1.realpath)(media.source);
+ // if ((await (0, promises_1.stat)(source)).isFile()) {
+ // fileName = (_c = media.filename) !== null && _c !== void 0 ? _c : path.basename(media.source);
+ // mediaSource = await fs.createReadStream(media.source);
+ // }
+ // else {
+ // throw new TypeError(`Unable to upload '${media.source}', not a file`);
+ // }
}
if (isStream(mediaSource) || Buffer.isBuffer(mediaSource)) {
form.addPart({
diff --git a/lib/core/network/polling.js b/lib/core/network/polling.js
index 42f20a5090304c56d0970da56eeaaacaa518ca92..0ae889c32d46e33440c62ad6d27a290c0fe3dda2 100644
--- a/lib/core/network/polling.js
+++ b/lib/core/network/polling.js
@@ -6,10 +6,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.Polling = void 0;
const abort_controller_1 = __importDefault(require("abort-controller"));
const debug_1 = __importDefault(require("debug"));
-const util_1 = require("util");
+// const util_1 = require("util");
const error_1 = require("./error");
const debug = (0, debug_1.default)('telegraf:polling');
-const wait = (0, util_1.promisify)(setTimeout);
+// const wait = (0, util_1.promisify)(setTimeout);
function always(x) {
return () => x;
}
@@ -47,7 +47,8 @@ class Polling {
(err instanceof error_1.TelegramError && err.code >= 500)) {
const retryAfter = (_b = (_a = err.parameters) === null || _a === void 0 ? void 0 : _a.retry_after) !== null && _b !== void 0 ? _b : 5;
debug('Failed to fetch updates, retrying after %ds.', retryAfter, err);
- await wait(retryAfter * 1000);
+ // await wait(retryAfter * 1000);
+ await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
continue;
}
if (err instanceof error_1.TelegramError &&
diff --git a/lib/telegraf.js b/lib/telegraf.js
index 23d021c3d5f98493bd714a2114ec8fa853560e5c..90094d18316138b7e12eab42f722e69ccc9b6c1f 100644
--- a/lib/telegraf.js
+++ b/lib/telegraf.js
@@ -28,8 +28,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
Object.defineProperty(exports, "__esModule", { value: true });
exports.Telegraf = void 0;
const crypto = __importStar(require("crypto"));
-const http = __importStar(require("http"));
-const https = __importStar(require("https"));
+// const http = __importStar(require("http"));
+// const https = __importStar(require("https"));
const composer_1 = require("./composer");
const compact_1 = require("./core/helpers/compact");
const context_1 = __importDefault(require("./context"));
@@ -157,13 +157,13 @@ class Telegraf extends composer_1.Composer {
const callback = typeof cb === 'function'
? (req, res) => webhookCb(req, res, () => cb(req, res))
: webhookCb;
- this.webhookServer =
- tlsOptions != null
- ? https.createServer(tlsOptions, callback)
- : http.createServer(callback);
- this.webhookServer.listen(port, host, () => {
- debug('Webhook listening on port: %s', port);
- });
+ // this.webhookServer =
+ // tlsOptions != null
+ // ? https.createServer(tlsOptions, callback)
+ // : http.createServer(callback);
+ // this.webhookServer.listen(port, host, () => {
+ // debug('Webhook listening on port: %s', port);
+ // });
return this;
}
secretPathComponent() {
@@ -176,7 +176,7 @@ class Telegraf extends composer_1.Composer {
/**
* @see https://github.com/telegraf/telegraf/discussions/1344#discussioncomment-335700
*/
- async launch(config = {},
+ async launch(config = {},
/** @experimental */
onLaunch) {
var _a, _b;

2887
worker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,27 @@
import { Context } from 'hono';
import { CONSTANTS } from '../constants'; import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting, checkUserPassword, getDomains } from '../utils'; import { getJsonSetting, saveSetting, checkUserPassword, getDomains } from '../utils';
import { UserSettings, GeoData, UserInfo } from "../models"; import { UserSettings, GeoData, UserInfo } from "../models";
import { handleListQuery } from '../common'
import { HonoCustomType } from '../types';
export default { export default {
getSetting: async (c) => { getSetting: async (c: Context<HonoCustomType>) => {
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY); const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
const settings = new UserSettings(value); const settings = new UserSettings(value);
return c.json(settings) return c.json(settings)
}, },
saveSetting: async (c) => { saveSetting: async (c: Context<HonoCustomType>) => {
const value = await c.req.json(); const value = await c.req.json();
const settings = new UserSettings(value); const settings = new UserSettings(value);
if (settings.enableMailVerify && !c.env.KV) { if (settings.enableMailVerify && !c.env.KV) {
return c.text("Please enable KV first if you want to enable mail verify", 403) return c.text("Please enable KV first if you want to enable mail verify", 403)
} }
if (settings.enableMailVerify) { if (settings.enableMailVerify && !settings.verifyMailSender) {
return c.text("Please provide verifyMailSender", 400)
}
if (settings.enableMailVerify && settings.verifyMailSender) {
const mailDomain = settings.verifyMailSender.split("@")[1]; const mailDomain = settings.verifyMailSender.split("@")[1];
const domains = getDomains(c); const domains = getDomains(c);
if (!domains.includes(mailDomain)) { if (!domains.includes(mailDomain)) {
@@ -27,61 +34,35 @@ export default {
await saveSetting(c, CONSTANTS.USER_SETTINGS_KEY, JSON.stringify(settings)); await saveSetting(c, CONSTANTS.USER_SETTINGS_KEY, JSON.stringify(settings));
return c.json({ success: true }) return c.json({ success: true })
}, },
getUsers: async (c) => { getUsers: async (c: Context<HonoCustomType>) => {
const { limit, offset, query } = c.req.query(); const { limit, offset, query } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (query) { if (query) {
const { results } = await c.env.DB.prepare( return await handleListQuery(c,
`SELECT u.id, u.user_email, u.created_at, u.updated_at,` `SELECT u.id, u.user_email, u.created_at, u.updated_at,`
+ ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count` + ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count`
+ ` FROM users u` + ` FROM users u`
+ ` where u.user_email like ?` + ` where u.user_email like ?`,
+ ` order by u.id desc limit ? offset ?` `SELECT count(*) as count FROM users where user_email like ?`,
).bind(`%${query}%`, limit, offset).all(); [`%${query}%`], limit, offset
let count = 0; );
if (offset == 0) {
const { count: userCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM users where user_email like ?`
).bind(`%${query}%`).first();
count = userCount;
}
return c.json({
results: results,
count: count
})
} }
const { results } = await c.env.DB.prepare( return await handleListQuery(c,
`SELECT u.id, u.user_email, u.created_at, u.updated_at,` `SELECT u.id, u.user_email, u.created_at, u.updated_at,`
+ ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count` + ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count`
+ ` FROM users u` + ` FROM users u`,
+ ` order by u.id desc limit ? offset ?` `SELECT count(*) as count FROM users`,
).bind(limit, offset).all(); [], limit, offset
let count = 0; );
if (offset == 0) {
const { count: userCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM users`
).first();
count = userCount;
}
return c.json({
results: results,
count: count
})
}, },
createUser: async (c) => { createUser: async (c: Context<HonoCustomType>) => {
const { email, password } = await c.req.json(); const { email, password } = await c.req.json();
if (!email || !password) { if (!email || !password) {
return c.text("Invalid email or password", 400) return c.text("Invalid email or password", 400)
} }
// geo data // geo data
const reqIp = c.req.raw.headers.get("cf-connecting-ip") const reqIp = c.req.raw.headers.get("cf-connecting-ip")
const geoData = new GeoData(reqIp, c.req.raw.cf); const geoData = new GeoData(reqIp, c.req.raw.cf as any);
const userInfo = new UserInfo(geoData); const userInfo = new UserInfo(geoData, email);
try { try {
checkUserPassword(password); checkUserPassword(password);
const { success } = await c.env.DB.prepare( const { success } = await c.env.DB.prepare(
@@ -94,14 +75,15 @@ export default {
return c.text("Failed to register", 500) return c.text("Failed to register", 500)
} }
} catch (e) { } catch (e) {
if (e.message && e.message.includes("UNIQUE")) { const errorMsg = (e as Error).message;
if (errorMsg && errorMsg.includes("UNIQUE")) {
return c.text("User already exists", 400) return c.text("User already exists", 400)
} }
return c.text(`Failed to register: ${e.message}`, 500) return c.text(`Failed to register: ${errorMsg}`, 500)
} }
return c.json({ success: true }) return c.json({ success: true })
}, },
deleteUser: async (c) => { deleteUser: async (c: Context<HonoCustomType>) => {
const { user_id } = c.req.param(); const { user_id } = c.req.param();
if (!user_id) return c.text("Invalid user_id", 400); if (!user_id) return c.text("Invalid user_id", 400);
const { success } = await c.env.DB.prepare( const { success } = await c.env.DB.prepare(
@@ -115,7 +97,7 @@ export default {
} }
return c.json({ success: true }) return c.json({ success: true })
}, },
resetPassword: async (c) => { resetPassword: async (c: Context<HonoCustomType>) => {
const { user_id } = c.req.param(); const { user_id } = c.req.param();
const { password } = await c.req.json(); const { password } = await c.req.json();
if (!user_id) return c.text("Invalid user_id", 400); if (!user_id) return c.text("Invalid user_id", 400);
@@ -128,7 +110,7 @@ export default {
return c.text("Failed to reset password", 500) return c.text("Failed to reset password", 500)
} }
} catch (e) { } catch (e) {
return c.text(`Failed to reset password: ${e.message}`, 500) return c.text(`Failed to reset password: ${(e as Error).message}`, 500)
} }
return c.json({ success: true }); return c.json({ success: true });
}, },

View File

@@ -1,25 +1,28 @@
import { Context } from 'hono';
import { cleanup } from '../common'; import { cleanup } from '../common';
import { CONSTANTS } from '../constants'; import { CONSTANTS } from '../constants';
import { getJsonSetting, saveSetting } from '../utils'; import { getJsonSetting, saveSetting } from '../utils';
import { CleanupSettings } from '../models'; import { CleanupSettings } from '../models';
import { HonoCustomType } from '../types';
export default { export default {
cleanup: async (c) => { cleanup: async (c: Context<HonoCustomType>) => {
const { cleanType, cleanDays } = await c.req.json(); const { cleanType, cleanDays } = await c.req.json();
try { try {
await cleanup(c, cleanType, cleanDays); await cleanup(c, cleanType, cleanDays);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return c.text(`Failed to cleanup ${error.message}`, 500) return c.text(`Failed to cleanup ${(error as Error).message}`, 500)
} }
return c.json({ success: true }) return c.json({ success: true })
}, },
getCleanup: async (c) => { getCleanup: async (c: Context<HonoCustomType>) => {
const value = await getJsonSetting(c, CONSTANTS.AUTO_CLEANUP_KEY); const value = await getJsonSetting(c, CONSTANTS.AUTO_CLEANUP_KEY);
const cleanupSetting = new CleanupSettings(value); const cleanupSetting = new CleanupSettings(value);
return c.json(cleanupSetting) return c.json(cleanupSetting)
}, },
saveCleanup: async (c) => { saveCleanup: async (c: Context<HonoCustomType>) => {
const value = await c.req.json(); const value = await c.req.json();
const cleanupSetting = new CleanupSettings(value); const cleanupSetting = new CleanupSettings(value);
await saveSetting(c, CONSTANTS.AUTO_CLEANUP_KEY, JSON.stringify(cleanupSetting)); await saveSetting(c, CONSTANTS.AUTO_CLEANUP_KEY, JSON.stringify(cleanupSetting));

View File

@@ -1,68 +1,50 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { Jwt } from 'hono/utils/jwt' import { Jwt } from 'hono/utils/jwt'
import { HonoCustomType } from '../types'
import { sendAdminInternalMail, getJsonSetting, saveSetting } from '../utils' import { sendAdminInternalMail, getJsonSetting, saveSetting } from '../utils'
import { newAddress, handleListQuery } from '../common' import { newAddress, handleListQuery } from '../common'
import { CONSTANTS } from '../constants' import { CONSTANTS } from '../constants'
import cleanup_api from './cleanup_api' import cleanup_api from './cleanup_api'
import admin_user_api from './admin_user_api' import admin_user_api from './admin_user_api'
import webhook_settings from './webhook_settings'
const api = new Hono() export const api = new Hono<HonoCustomType>()
api.get('/admin/address', async (c) => { api.get('/admin/address', async (c) => {
const { limit, offset, query } = c.req.query(); const { limit, offset, query } = c.req.query();
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
if (query) { if (query) {
const { results } = await c.env.DB.prepare( return await handleListQuery(c,
`SELECT a.*,` `SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,` + ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count` + ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a` + ` FROM address a`
+ ` where name like ?` + ` where name like ?`,
+ ` order by id desc limit ? offset ?` `SELECT count(*) as count FROM address where name like ?`,
).bind(`%${query}%`, limit, offset).all(); [`%${query}%`], limit, offset
let count = 0; );
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address where name like ?`
).bind(`%${query}%`).first();
count = addressCount;
}
return c.json({
results: results,
count: count
})
} }
const { results } = await c.env.DB.prepare( return await handleListQuery(c,
`SELECT a.*,` `SELECT a.*,`
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,` + ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count` + ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a` + ` FROM address a`,
+ ` order by id desc limit ? offset ?` `SELECT count(*) as count FROM address`,
).bind(limit, offset).all(); [], limit, offset
let count = 0; );
if (offset == 0) {
const { count: addressCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM address`
).first();
count = addressCount;
}
return c.json({
results: results,
count: count
})
}) })
api.post('/admin/new_address', async (c) => { api.post('/admin/new_address', async (c) => {
let { name, domain, enablePrefix } = await c.req.json(); const { name, domain, enablePrefix } = await c.req.json();
if (!name) { if (!name) {
return c.text("Please provide a name", 400) return c.text("Please provide a name", 400)
} }
return newAddress(c, name, domain, enablePrefix); try {
const res = await newAddress(c, name, domain, enablePrefix);
return c.json(res);
} catch (e) {
return c.text(`Failed create address: ${(e as Error).message}`, 400)
}
}) })
api.delete('/admin/delete_address/:id', async (c) => { api.delete('/admin/delete_address/:id', async (c) => {
@@ -100,7 +82,7 @@ api.get('/admin/show_password/:id', async (c) => {
const jwt = await Jwt.sign({ const jwt = await Jwt.sign({
address: name, address: name,
address_id: id address_id: id
}, c.env.JWT_SECRET) }, c.env.JWT_SECRET, "HS256")
return c.json({ return c.json({
jwt: jwt jwt: jwt
}) })
@@ -162,7 +144,9 @@ api.get('/admin/address_sender', async (c) => {
}) })
api.post('/admin/address_sender', async (c) => { api.post('/admin/address_sender', async (c) => {
/* eslint-disable prefer-const */
let { address, address_id, balance, enabled } = await c.req.json(); let { address, address_id, balance, enabled } = await c.req.json();
/* eslint-enable prefer-const */
if (!address_id) { if (!address_id) {
return c.text("Invalid address_id", 400) return c.text("Invalid address_id", 400)
} }
@@ -175,7 +159,7 @@ api.post('/admin/address_sender', async (c) => {
} }
await sendAdminInternalMail( await sendAdminInternalMail(
c, address, "Account Send Access Updated", c, address, "Account Send Access Updated",
`You send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}` `Your send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}`
); );
return c.json({ return c.json({
success: success success: success
@@ -184,57 +168,33 @@ api.post('/admin/address_sender', async (c) => {
api.get('/admin/sendbox', async (c) => { api.get('/admin/sendbox', async (c) => {
const { address, limit, offset } = c.req.query(); const { address, limit, offset } = c.req.query();
if (!limit || limit < 0 || limit > 100) { if (address) {
return c.text("Invalid limit", 400) return await handleListQuery(c,
`SELECT * FROM sendbox where address = ? `,
`SELECT count(*) as count FROM sendbox where address = ? `,
[address], limit, offset
);
} }
if (!offset || offset < 0) { return await handleListQuery(c,
return c.text("Invalid offset", 400) `SELECT * FROM sendbox `,
} `SELECT count(*) as count FROM sendbox `,
if (!address) { [], limit, offset
const { results } = await c.env.DB.prepare( );
`SELECT * FROM sendbox order by id desc limit ? offset ?`
).bind(limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox`
).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
}
const { results } = await c.env.DB.prepare(
`SELECT * FROM sendbox where address = ? order by id desc limit ? offset ?`
).bind(address, limit, offset).all();
let count = 0;
if (offset == 0) {
const { count: mailCount } = await c.env.DB.prepare(
`SELECT count(*) as count FROM sendbox where address = ?`
).bind(address).first();
count = mailCount;
}
return c.json({
results: results,
count: count
})
}) })
api.get('/admin/statistics', async (c) => { api.get('/admin/statistics', async (c) => {
const { count: mailCount } = await c.env.DB.prepare(` const { count: mailCount } = await c.env.DB.prepare(
SELECT count(*) as count FROM raw_mails` `SELECT count(*) as count FROM raw_mails`
).first(); ).first<{ count: number }>() || {};
const { count: addressCount } = await c.env.DB.prepare(` const { count: addressCount } = await c.env.DB.prepare(
SELECT count(*) as count FROM address` `SELECT count(*) as count FROM address`
).first(); ).first<{ count: number }>() || {};
const { count: activeUserCount7days } = await c.env.DB.prepare(` const { count: activeUserCount7days } = await c.env.DB.prepare(
SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')` `SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
).first(); ).first<{ count: number }>() || {};
const { count: sendMailCount } = await c.env.DB.prepare(` const { count: sendMailCount } = await c.env.DB.prepare(
SELECT count(*) as count FROM sendbox` `SELECT count(*) as count FROM sendbox`
).first(); ).first<{ count: number }>() || {};
return c.json({ return c.json({
mailCount: mailCount, mailCount: mailCount,
userCount: addressCount, userCount: addressCount,
@@ -245,13 +205,13 @@ api.get('/admin/statistics', async (c) => {
api.get('/admin/account_settings', async (c) => { api.get('/admin/account_settings', async (c) => {
try { try {
/** @type {Array<string>|undefined|null} */
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY); const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
/** @type {Array<string>|undefined|null} */
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY); const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
const verifiedAddressList = await getJsonSetting(c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY);
return c.json({ return c.json({
blockList: blockList || [], blockList: blockList || [],
sendBlockList: sendBlockList || [] sendBlockList: sendBlockList || [],
verifiedAddressList: verifiedAddressList || []
}) })
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -261,10 +221,13 @@ api.get('/admin/account_settings', async (c) => {
api.post('/admin/account_settings', async (c) => { api.post('/admin/account_settings', async (c) => {
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */ /** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const { blockList, sendBlockList } = await c.req.json(); const { blockList, sendBlockList, verifiedAddressList } = await c.req.json();
if (!blockList || !sendBlockList) { if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text("Invalid blockList or sendBlockList", 400) return c.text("Invalid blockList or sendBlockList", 400)
} }
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
return c.text("Please enable SEND_MAIL to use verifiedAddressList", 400)
}
await saveSetting( await saveSetting(
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY, c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
JSON.stringify(blockList) JSON.stringify(blockList)
@@ -273,6 +236,10 @@ api.post('/admin/account_settings', async (c) => {
c, CONSTANTS.SEND_BLOCK_LIST_KEY, c, CONSTANTS.SEND_BLOCK_LIST_KEY,
JSON.stringify(sendBlockList) JSON.stringify(sendBlockList)
); );
await saveSetting(
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
JSON.stringify(verifiedAddressList)
)
return c.json({ return c.json({
success: true success: true
}) })
@@ -287,5 +254,5 @@ api.get('/admin/users', admin_user_api.getUsers)
api.delete('/admin/users/:user_id', admin_user_api.deleteUser) api.delete('/admin/users/:user_id', admin_user_api.deleteUser)
api.post('/admin/users', admin_user_api.createUser) api.post('/admin/users', admin_user_api.createUser)
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword) api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
api.get("/admin/webhook/settings", webhook_settings.getWebhookSettings);
export { api } api.post("/admin/webhook/settings", webhook_settings.saveWebhookSettings);

View File

@@ -0,0 +1,20 @@
import { Context } from "hono";
import { HonoCustomType } from "../types";
import { CONSTANTS } from "../constants";
import { AdminWebhookSettings } from "../models";
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.env.KV.get<AdminWebhookSettings>(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, "json");
return c.json(settings || new AdminWebhookSettings([]));
}
async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.req.json<AdminWebhookSettings>();
await c.env.KV.put(CONSTANTS.WEBHOOK_KV_SETTINGS_KEY, JSON.stringify(settings));
return c.json({ success: true })
}
export default {
getWebhookSettings,
saveWebhookSettings,
}

36
worker/src/commom_api.ts Normal file
View File

@@ -0,0 +1,36 @@
import { Hono } from 'hono'
import { getDomains, getPasswords, getBooleanValue } from './utils';
import { CONSTANTS } from './constants';
import { HonoCustomType } from './types';
import { isS3Enabled } from './mails_api/s3_attachment';
const api = new Hono<HonoCustomType>
api.get('/open_api/settings', async (c) => {
// check header x-custom-auth
let needAuth = false;
const passwords = getPasswords(c);
if (passwords && passwords.length > 0) {
const auth = c.req.raw.headers.get("x-custom-auth");
needAuth = !auth || !passwords.includes(auth);
}
return c.json({
"title": c.env.TITLE,
"prefix": c.env.PREFIX,
"domains": getDomains(c),
"needAuth": needAuth,
"adminContact": c.env.ADMIN_CONTACT,
"enableUserCreateEmail": getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
"enableUserDeleteEmail": getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
"enableAutoReply": getBooleanValue(c.env.ENABLE_AUTO_REPLY),
"enableIndexAbout": getBooleanValue(c.env.ENABLE_INDEX_ABOUT),
"copyright": c.env.COPYRIGHT,
"cfTurnstileSiteKey": c.env.CF_TURNSTILE_SITE_KEY,
"enableWebhook": getBooleanValue(c.env.ENABLE_WEBHOOK),
"isS3Enabled": isS3Enabled(c),
"version": CONSTANTS.VERSION,
});
})
export { api }

View File

@@ -1,117 +0,0 @@
import { Jwt } from 'hono/utils/jwt'
import { getDomains, getStringValue } from './utils';
export const newAddress = async (c, name, domain, enablePrefix) => {
// remove special characters
name = name.replace(/[^a-zA-Z0-9.]/g, '')
// check name length
if (name.length < 0) {
return c.text("Name too short", 400)
}
if (name.length > 100) {
return c.text("Name too long (max 100)", 400)
}
// check domain, generate random domain
const domains = getDomains(c);
if (!domain || !domains.includes(domain)) {
domain = domains[Math.floor(Math.random() * domains.length)];
}
// create address
if (enablePrefix) {
name = getStringValue(c.env.PREFIX) + name + "@" + domain;
} else {
name = name + "@" + domain;
}
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO address(name) VALUES(?)`
).bind(name).run();
if (!success) {
return c.text("Failed to create address", 500)
}
} catch (e) {
if (e.message && e.message.includes("UNIQUE")) {
return c.text("Address already exists, please retry a new address", 400)
}
return c.text("Failed to create address", 500)
}
let address_id = 0;
try {
address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(name).first("id");
} catch (error) {
console.log(error);
}
// create jwt
const jwt = await Jwt.sign({
address: name,
address_id: address_id
}, c.env.JWT_SECRET)
return c.json({
jwt: jwt
})
}
export const cleanup = async (c, cleanType, cleanDays) => {
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
throw new Error("Invalid cleanType or cleanDays")
}
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
switch (cleanType) {
case "mails":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "mails_unknow":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE address NOT IN
(select name from address) AND created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "address":
await c.env.DB.prepare(`
DELETE FROM address WHERE updated_at < datetime('now', '-${cleanDays} day')`
+ ` AND id NOT IN (SELECT address_id FROM users_address)`
).run();
break;
case "sendbox":
await c.env.DB.prepare(`
DELETE FROM sendbox WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
default:
throw new Error("Invalid cleanType")
}
return true;
}
/**
*
* @param {*} c context
* @param {*} query @type {string} query
* @param {*} countQuery @type {string} countQuery
* @param {*} limit @type {number} limit
* @param {*} offset @type {number} offset
* @returns {Promise} Promise
*/
export const handleListQuery = async (
c, query, countQuery, params, limit, offset
) => {
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (!offset || offset < 0) {
return c.text("Invalid offset", 400)
}
const resultsQuery = `${query} order by id desc limit ? offset ?`;
const { results } = await c.env.DB.prepare(resultsQuery).bind(
...params, limit, offset
).all();
const count = offset == 0 ? await c.env.DB.prepare(
countQuery
).bind(...params).first("count") : 0;
return c.json({ results, count });
}

168
worker/src/common.ts Normal file
View File

@@ -0,0 +1,168 @@
import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { getBooleanValue, getDomains, getStringValue } from './utils';
import { HonoCustomType } from './types';
import { unbindTelegramByAddress } from './telegram_api/common';
export const newAddress = async (
c: Context<HonoCustomType>,
name: string, domain: string | undefined | null,
enablePrefix: boolean
): Promise<{ address: string, jwt: string }> => {
// remove special characters
name = name.replace(/[^a-zA-Z0-9.]/g, '')
// check name length
if (name.length <= 0) {
throw new Error("Name too short")
}
// create address
if (enablePrefix) {
name = getStringValue(c.env.PREFIX) + name;
}
if (name.length >= 30) {
throw new Error("Name too long (max 30)")
}
// check domain, generate random domain
const domains = getDomains(c);
if (!domain || !domains.includes(domain)) {
domain = domains[Math.floor(Math.random() * domains.length)];
}
// create address
name = name + "@" + domain;
try {
const { success } = await c.env.DB.prepare(
`INSERT INTO address(name) VALUES(?)`
).bind(name).run();
if (!success) {
throw new Error("Failed to create address")
}
} catch (e) {
const message = (e as Error).message;
if (message && message.includes("UNIQUE")) {
throw new Error("Address already exists")
}
throw new Error("Failed to create address")
}
const address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(name).first<number>("id");
// create jwt
const jwt = await Jwt.sign({
address: name,
address_id: address_id
}, c.env.JWT_SECRET, "HS256")
return {
jwt: jwt,
address: name,
}
}
export const cleanup = async (
c: Context<HonoCustomType>,
cleanType: string | undefined | null,
cleanDays: number | undefined | null
): Promise<boolean> => {
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
throw new Error("Invalid cleanType or cleanDays")
}
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
switch (cleanType) {
case "mails":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "mails_unknow":
await c.env.DB.prepare(`
DELETE FROM raw_mails WHERE address NOT IN
(select name from address) AND created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
case "sendbox":
await c.env.DB.prepare(`
DELETE FROM sendbox WHERE created_at < datetime('now', '-${cleanDays} day')`
).run();
break;
default:
throw new Error("Invalid cleanType")
}
return true;
}
/**
* TODO: need senbox delete?
*/
export const deleteAddressWithData = async (
c: Context<HonoCustomType>,
address: string | undefined | null,
address_id: number | undefined | null
): Promise<boolean> => {
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
throw new Error("Delete email is disabled")
}
if (!address && !address_id) {
throw new Error("Address or address_id required")
}
// get address_id or address
if (!address_id) {
address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(address).first<number>("id");
} else if (!address) {
address = await c.env.DB.prepare(
`SELECT name FROM address where id = ?`
).bind(address_id).first<string>("name");
}
// check address again
if (!address || !address_id) {
throw new Error("Can't find address");
}
// unbind telegram
await unbindTelegramByAddress(c, address);
// delete address and related data
const { success: mailSuccess } = await c.env.DB.prepare(
`DELETE FROM raw_mails WHERE address = ? `
).bind(address).run();
const { success: sendAccess } = await c.env.DB.prepare(
`DELETE FROM address_sender WHERE address = ? `
).bind(address).run();
const { success: addressSuccess } = await c.env.DB.prepare(
`DELETE FROM users_address WHERE address_id = ? `
).bind(address_id).run();
const { success } = await c.env.DB.prepare(
`DELETE FROM address WHERE name = ? `
).bind(address).run();
if (!success || !mailSuccess || !addressSuccess || !sendAccess) {
throw new Error("Failed to delete address")
}
return true;
}
export const handleListQuery = async (
c: Context<HonoCustomType>,
query: string, countQuery: string, params: string[],
limit: string | number | undefined | null,
offset: string | number | undefined | null
): Promise<Response> => {
if (typeof limit === "string") {
limit = parseInt(limit);
}
if (typeof offset === "string") {
offset = parseInt(offset);
}
if (!limit || limit < 0 || limit > 100) {
return c.text("Invalid limit", 400)
}
if (offset == null || offset == undefined || offset < 0) {
return c.text("Invalid offset", 400)
}
const resultsQuery = `${query} order by id desc limit ? offset ?`;
const { results } = await c.env.DB.prepare(resultsQuery).bind(
...params, limit, offset
).all();
const count = offset == 0 ? await c.env.DB.prepare(
countQuery
).bind(...params).first("count") : 0;
return c.json({ results, count });
}

View File

@@ -1,6 +0,0 @@
export const CONSTANTS = {
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
SEND_BLOCK_LIST_KEY: 'send_block_list',
AUTO_CLEANUP_KEY: 'auto_cleanup',
USER_SETTINGS_KEY: 'user_settings',
}

16
worker/src/constants.ts Normal file
View File

@@ -0,0 +1,16 @@
export const CONSTANTS = {
VERSION: 'v0.5.0',
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
SEND_BLOCK_LIST_KEY: 'send_block_list',
AUTO_CLEANUP_KEY: 'auto_cleanup',
USER_SETTINGS_KEY: 'user_settings',
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
// KV
TG_KV_PREFIX: "temp-mail-telegram",
TG_KV_SETTINGS_KEY: "temp-mail-telegram-settings",
WEBHOOK_KV_SETTINGS_KEY: "temp-mail-webhook-settings",
WEBHOOK_KV_USER_SETTINGS_KEY: "temp-mail-webhook-user-settings",
}

View File

@@ -1,34 +1,18 @@
import { createMimeMessage } from "mimetext"; import { createMimeMessage } from "mimetext";
import { getBooleanValue } from "./utils"; import { getBooleanValue } from "../utils";
import { Bindings } from "../types";
async function email(message, env, ctx) { export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings): Promise<void> => {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
message.setReject("Missing from address");
console.log(`Reject message from ${message.from} to ${message.to}`);
return;
}
const rawEmail = await new Response(message.raw).text();
const message_id = message.headers.get("Message-ID"); const message_id = message.headers.get("Message-ID");
// save email
const { success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, rawEmail, message_id
).run();
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.log(`Failed save message from ${message.from} to ${message.to}`);
}
// auto reply email // auto reply email
if (getBooleanValue(env.ENABLE_AUTO_REPLY)) { if (getBooleanValue(env.ENABLE_AUTO_REPLY) && message_id) {
try { try {
const results = await env.DB.prepare( const results = await env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1` `SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
).bind(message.to).first(); ).bind(message.to).first<Record<string, string>>();
if (results && results.source_prefix && message.from.startsWith(results.source_prefix)) { if (results && results.source_prefix && message.from.startsWith(results.source_prefix)) {
const msg = createMimeMessage(); const msg = createMimeMessage();
msg.setHeader("In-Reply-To", message.headers.get("Message-ID")); msg.setHeader("In-Reply-To", message_id);
msg.setSender({ msg.setSender({
name: results.name || results.address, name: results.name || results.address,
addr: results.address addr: results.address
@@ -45,6 +29,7 @@ async function email(message, env, ctx) {
message.from, message.from,
msg.asRaw() msg.asRaw()
); );
// @ts-ignore
await message.reply(replyMessage); await message.reply(replyMessage);
} }
} catch (error) { } catch (error) {
@@ -52,5 +37,3 @@ async function email(message, env, ctx) {
} }
} }
} }
export { email }

62
worker/src/email/index.ts Normal file
View File

@@ -0,0 +1,62 @@
import { Context } from "hono";
import { getEnvStringList } from "../utils";
import { sendMailToTelegram } from "../telegram_api";
import { Bindings, HonoCustomType } from "../types";
import { auto_reply } from "./auto_reply";
import { trigerWebhook } from "../mails_api/webhook_settings";
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
message.setReject("Missing from address");
console.log(`Reject message from ${message.from} to ${message.to}`);
return;
}
const rawEmail = await new Response(message.raw).text();
const message_id = message.headers.get("Message-ID");
// save email
const { success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, rawEmail, message_id
).run();
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.log(`Failed save message from ${message.from} to ${message.to}`);
}
// forward email
try {
const forwardAddressList = getEnvStringList(env.FORWARD_ADDRESS_LIST)
for (const forwardAddress of forwardAddressList) {
await message.forward(forwardAddress);
}
} catch (error) {
console.log("forward email error", error);
}
// send email to telegram
try {
await sendMailToTelegram(
{ env: env } as Context<HonoCustomType>,
message.to, rawEmail, message_id);
} catch (error) {
console.log("send mail to telegram error", error);
}
// send webhook
try {
await trigerWebhook(
{ env: env } as Context<HonoCustomType>,
message.to, rawEmail
);
} catch (error) {
console.log("send webhook error", error);
}
// auto reply email
await auto_reply(message, env);
}
export { email }

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