Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5d01e09e8 | ||
|
|
201c7658be | ||
|
|
77155299e0 | ||
|
|
9725407c77 | ||
|
|
e91bbe273a | ||
|
|
b792c196c1 | ||
|
|
7a368d7b23 | ||
|
|
f882e4cf97 | ||
|
|
00abf79417 | ||
|
|
1f8edbc295 | ||
|
|
268f3d6446 | ||
|
|
8dc9d32a7e | ||
|
|
3b6736924b | ||
|
|
dc14338b69 | ||
|
|
954ae2dfb1 | ||
|
|
6d55acdd42 | ||
|
|
03bb210016 | ||
|
|
bf3c372d8c | ||
|
|
9414f7a977 | ||
|
|
32440706d2 | ||
|
|
c976664f4e | ||
|
|
aa04dc4efa | ||
|
|
02e3e755e7 | ||
|
|
37ed2955ff | ||
|
|
dd49768cfc | ||
|
|
9ec11f7040 | ||
|
|
2533257b68 | ||
|
|
96ea81e055 | ||
|
|
8459e0c306 | ||
|
|
91d7896e65 | ||
|
|
69771fc1d1 | ||
|
|
c00382259a | ||
|
|
8ac96bff1f | ||
|
|
9f3ff7b980 | ||
|
|
870b7b9198 | ||
|
|
46576316e6 | ||
|
|
a5ff4f2d90 | ||
|
|
745e36f838 | ||
|
|
a351839408 | ||
|
|
ca00a877ad | ||
|
|
53a06fc9d6 | ||
|
|
607c04c810 | ||
|
|
243dac976b | ||
|
|
4bd876a5f4 | ||
|
|
bbc4c05d69 | ||
|
|
78badf2eaa | ||
|
|
6bb6fa8298 | ||
|
|
a5b5335137 | ||
|
|
f2685f9830 | ||
|
|
45bc5cad9e | ||
|
|
ea4ce9bf63 | ||
|
|
9de2d23be1 | ||
|
|
62bec9ef90 | ||
|
|
edc110b6ac | ||
|
|
3fc8bba234 | ||
|
|
4b9d40d04b | ||
|
|
af027fd75e | ||
|
|
386441a743 | ||
|
|
46e04fd94a | ||
|
|
cdc5c5202b | ||
|
|
58c3fdb5b4 | ||
|
|
fc6b0246b1 | ||
|
|
98cd6d9fcc | ||
|
|
45783c7494 | ||
|
|
9bfded4d1d | ||
|
|
b7308587c6 | ||
|
|
1fa56dfe98 | ||
|
|
55b2603913 | ||
|
|
7738210b93 | ||
|
|
9d84eb0634 | ||
|
|
66a6d40499 | ||
|
|
41bed8b1db | ||
|
|
869bf99340 | ||
|
|
f63c4ebd9c | ||
|
|
26969bebb8 | ||
|
|
1d191a091a | ||
|
|
4d6c4e2d10 | ||
|
|
7f456078ea | ||
|
|
68c18a6153 | ||
|
|
2d01639ecd | ||
|
|
53b7cfccde |
16
.github/workflows/docs_deploy.yml
vendored
@@ -31,6 +31,22 @@ jobs:
|
||||
version: 8
|
||||
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}}
|
||||
run: |
|
||||
cd vitepress-docs/
|
||||
|
||||
7
.github/workflows/frontend_deploy.yaml
vendored
@@ -38,6 +38,13 @@ jobs:
|
||||
pnpm run deploy --project-name=$project_name
|
||||
echo "Deploying prodcution for ${{ github.ref_name }}"
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
|
||||
export tg_mini_app_project_name=${{ secrets.TG_FRONTEND_NAME }}
|
||||
if [ -n "$tg_mini_app_project_name" ]; then
|
||||
echo "Deploying telegram mini app $tg_mini_app_project_name"
|
||||
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
|
||||
echo "Deployed telegram mini app for ${{ github.ref_name }}"
|
||||
fi
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
139
CHANGELOG.md
@@ -1,8 +1,144 @@
|
||||
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
|
||||
# CHANGE LOG
|
||||
|
||||
## main branch to be released
|
||||
## v0.5.0
|
||||
|
||||
- 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 相关表,用于存储用户信息
|
||||
|
||||
- `db/2024-05-08-patch.sql`
|
||||
|
||||
### config changs
|
||||
|
||||
启用用户注册邮箱验证需要 `KV`
|
||||
|
||||
```toml
|
||||
# kv config for send email verification code
|
||||
# [[kv_namespaces]]
|
||||
# binding = "KV"
|
||||
# id = "xxxx"
|
||||
```
|
||||
|
||||
### function changs
|
||||
|
||||
- 增加用户注册功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证
|
||||
- 增加默认以文本显示邮件,文本和HTML邮箱显示方式切换按钮
|
||||
- 修复 `BUG` 随机生成的邮箱名字不合法 #211
|
||||
- `admin` 邮件页面支持邮件内容搜索 #210
|
||||
- 修复删除地址时邮件未删除的BUG #213
|
||||
- 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
|
||||
|
||||
- 修复 Admin 删除邮件报错
|
||||
- UI: 回复邮件按钮, 引用原始邮件文本 #186
|
||||
- 添加发送邮件地址黑名单
|
||||
- 添加 `CF Turnstile` 人机验证配置
|
||||
- 添加 `/external/api/send_mail` 发送邮件 api, 使用 body 验证 #194
|
||||
|
||||
## v0.3.2
|
||||
|
||||
## What's Changed
|
||||
|
||||
- UI: 添加回复邮件按钮
|
||||
- 添加定时清理功能,可在 admin 页面配置(需要在配置文件启用定时任务)
|
||||
- 修复删除账户无反应的问题
|
||||
|
||||
* feat: UI: MailBox add reply button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/187
|
||||
* feat: add cron auto clean up by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/189
|
||||
* fix: delete account by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/190
|
||||
|
||||
## v0.3.1
|
||||
|
||||
@@ -86,7 +222,6 @@ set
|
||||
- 添加 RATE_LIMITER 限流 发送邮件 和 新建地址
|
||||
- 一些 bug 修复
|
||||
|
||||
---
|
||||
- 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
|
||||
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146
|
||||
|
||||
34
README.md
@@ -1,8 +1,29 @@
|
||||
# 使用 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)
|
||||
|
||||
## [English Docs](https://temp-mail-docs.awsl.uk/en/)
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/dreamhunter2333/cloudflare_temp_email)
|
||||
|
||||
[Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/github-action.html)
|
||||
|
||||
[English Docs](https://temp-mail-docs.awsl.uk/en/)
|
||||
|
||||
## [CHANGELOG](CHANGELOG.md)
|
||||
|
||||
@@ -21,11 +42,11 @@
|
||||
|
||||
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
|
||||
- [查看部署文档](#查看部署文档)
|
||||
- [English Docs](#english-docs)
|
||||
- [CHANGELOG](#changelog)
|
||||
- [在线演示](#在线演示)
|
||||
- [功能/TODO](#功能todo)
|
||||
- [Reference](#reference)
|
||||
- [Join Community](#join-community)
|
||||
|
||||
## 功能/TODO
|
||||
|
||||
@@ -39,7 +60,9 @@
|
||||
- [x] 支持发送邮件
|
||||
- [x] 支持 `DKIM`
|
||||
- [x] `admin` 后台创建无前缀邮箱
|
||||
- [x] 添加 `SMTP proxy server`,支持 SMTP 发送邮件
|
||||
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件, `IMAP` 查看邮件
|
||||
- [x] 添加完整的用户注册登录功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证切换不同邮箱
|
||||
- [x] `Telegram Bot` 使用,以及 `Telegram` 推送
|
||||
|
||||
## Reference
|
||||
|
||||
@@ -47,3 +70,8 @@
|
||||
- 使用 Cloudflare Pages 部署前端
|
||||
- 使用 Cloudflare Workers 部署后端
|
||||
- email 转发使用 Cloudflare Email Routing
|
||||
|
||||
## Join Community
|
||||
|
||||
- [Discord](https://discord.gg/dQEwTWhA6Q)
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
|
||||
21
db/2024-05-08-patch.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
user_info TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users_address (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
address_id INTEGER UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);
|
||||
@@ -77,3 +77,25 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
user_info TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users_address (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
address_id INTEGER UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
VITE_API_BASE=https://temp-email-api.xxx.xxx
|
||||
VITE_CF_WEB_ANALY_TOKEN=
|
||||
VITE_IS_TELEGRAM=false
|
||||
|
||||
2
frontend/.env.pages
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_CF_WEB_ANALY_TOKEN=
|
||||
2
frontend/.gitignore
vendored
@@ -28,5 +28,7 @@ coverage
|
||||
*.sw?
|
||||
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.pages
|
||||
*-dist/
|
||||
components.d.ts
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/logo.png" sizes="any">
|
||||
<link rel="apple-touch-icon" href="/logo.png">
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "0.0.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build -m prod --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",
|
||||
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
|
||||
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
|
||||
"deploy": "npm run build && wrangler pages deploy ./dist --branch production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@unhead/vue": "^1.9.11",
|
||||
"@vicons/material": "^0.12.0",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@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",
|
||||
"naive-ui": "^2.38.2",
|
||||
"postal-mime": "^2.2.5",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.4.26",
|
||||
"vue": "^3.4.27",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^9.13.1",
|
||||
"vue-router": "^4.3.2"
|
||||
@@ -29,13 +34,13 @@
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.12.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-auto-import": "^0.17.6",
|
||||
"unplugin-vue-components": "^0.27.0",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-pwa": "^0.19.8",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"workbox-window": "^7.1.0",
|
||||
"wrangler": "^3.53.1"
|
||||
"wrangler": "^3.57.1"
|
||||
}
|
||||
}
|
||||
|
||||
881
frontend/pnpm-lock.yaml
generated
@@ -8,15 +8,15 @@ import Header from './views/Header.vue';
|
||||
import Footer from './views/Footer.vue';
|
||||
|
||||
|
||||
const { localeCache, isDark, loading } = useGlobalState()
|
||||
const {
|
||||
isDark, loading, useSideMargin, telegramApp, isTelegram
|
||||
} = useGlobalState()
|
||||
const { locale } = useI18n({});
|
||||
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 showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
|
||||
|
||||
const { locale } = useI18n({
|
||||
useScope: 'global',
|
||||
});
|
||||
locale.value = localeCache.value;
|
||||
|
||||
onMounted(async () => {
|
||||
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
|
||||
@@ -30,6 +30,23 @@ onMounted(async () => {
|
||||
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>
|
||||
|
||||
@@ -39,8 +56,8 @@ onMounted(async () => {
|
||||
<n-spin description="loading..." :show="loading">
|
||||
<n-message-provider>
|
||||
<n-grid x-gap="12" :cols="12">
|
||||
<n-gi v-if="!isMobile" span="1"></n-gi>
|
||||
<n-gi :span="isMobile ? 12 : 10">
|
||||
<n-gi v-if="showSideMargin" span="1"></n-gi>
|
||||
<n-gi :span="!showSideMargin ? 12 : 10">
|
||||
<div class="main">
|
||||
<n-space vertical>
|
||||
<n-layout style="min-height: 80vh;">
|
||||
@@ -51,7 +68,7 @@ onMounted(async () => {
|
||||
</n-space>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi v-if="!isMobile" span="1"></n-gi>
|
||||
<n-gi v-if="showSideMargin" span="1"></n-gi>
|
||||
</n-grid>
|
||||
<n-back-top />
|
||||
</n-message-provider>
|
||||
|
||||
@@ -2,12 +2,15 @@ import { useGlobalState } from '../store'
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||
const { loading, auth, jwt, settings, openSettings } = useGlobalState();
|
||||
const { showAuth, adminAuth, showAdminAuth } = useGlobalState();
|
||||
const {
|
||||
loading, auth, jwt, settings, openSettings,
|
||||
userOpenSettings, userSettings,
|
||||
showAuth, adminAuth, showAdminAuth, userJwt
|
||||
} = useGlobalState();
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: API_BASE,
|
||||
timeout: 10000
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
const apiFetch = async (path, options = {}) => {
|
||||
@@ -17,6 +20,7 @@ const apiFetch = async (path, options = {}) => {
|
||||
method: options.method || 'GET',
|
||||
data: options.body || null,
|
||||
headers: {
|
||||
'x-user-token': userJwt.value,
|
||||
'x-custom-auth': auth.value,
|
||||
'x-admin-auth': adminAuth.value,
|
||||
'Authorization': `Bearer ${jwt.value}`,
|
||||
@@ -25,11 +29,11 @@ const apiFetch = async (path, options = {}) => {
|
||||
});
|
||||
if (response.status === 401 && openSettings.value.auth) {
|
||||
showAuth.value = true;
|
||||
throw new Error("Unauthorized, you password is wrong")
|
||||
throw new Error("Unauthorized, you access password is wrong")
|
||||
}
|
||||
if (response.status === 401 && path.startsWith("/admin")) {
|
||||
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) {
|
||||
throw new Error(`${response.status} ${response.data}` || "error");
|
||||
@@ -50,6 +54,7 @@ const getOpenSettings = async (message) => {
|
||||
try {
|
||||
const res = await api.fetch("/open_api/settings");
|
||||
Object.assign(openSettings.value, {
|
||||
title: res["title"] || "",
|
||||
prefix: res["prefix"] || "",
|
||||
needAuth: res["needAuth"] || false,
|
||||
domains: res["domains"].map((domain) => {
|
||||
@@ -62,7 +67,11 @@ const getOpenSettings = async (message) => {
|
||||
enableUserCreateEmail: res["enableUserCreateEmail"] || false,
|
||||
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
||||
enableAutoReply: res["enableAutoReply"] || false,
|
||||
enableIndexAbout: res["enableIndexAbout"] || false,
|
||||
copyright: res["copyright"] || openSettings.value.copyright,
|
||||
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
|
||||
enableWebhook: res["enableWebhook"] || false,
|
||||
isS3Enabled: res["isS3Enabled"] || false,
|
||||
});
|
||||
if (openSettings.value.needAuth) {
|
||||
showAuth.value = true;
|
||||
@@ -81,7 +90,6 @@ const getSettings = async () => {
|
||||
settings.value = {
|
||||
address: res["address"],
|
||||
auto_reply: res["auto_reply"],
|
||||
has_v1_mails: res["has_v1_mails"],
|
||||
send_balance: res["send_balance"],
|
||||
};
|
||||
} finally {
|
||||
@@ -89,10 +97,32 @@ const getSettings = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const adminShowPassword = async (id) => {
|
||||
|
||||
const getUserOpenSettings = async (message) => {
|
||||
try {
|
||||
const { password } = await apiFetch(`/admin/show_password/${id}`);
|
||||
return password;
|
||||
const res = await api.fetch(`/user_api/open_settings`);
|
||||
Object.assign(userOpenSettings.value, res);
|
||||
} catch (error) {
|
||||
message.error(error.message || "fetch settings failed");
|
||||
}
|
||||
}
|
||||
|
||||
const getUserSettings = async (message) => {
|
||||
try {
|
||||
if (!userJwt.value) return;
|
||||
const res = await api.fetch("/user_api/settings")
|
||||
Object.assign(userSettings.value, res)
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
userSettings.value.fetched = true;
|
||||
}
|
||||
}
|
||||
|
||||
const adminShowAddressCredential = async (id) => {
|
||||
try {
|
||||
const { jwt: addressCredential } = await apiFetch(`/admin/show_password/${id}`);
|
||||
return addressCredential;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
@@ -108,10 +138,24 @@ const adminDeleteAddress = async (id) => {
|
||||
}
|
||||
}
|
||||
|
||||
const bindUserAddress = async () => {
|
||||
if (!userJwt.value) return;
|
||||
try {
|
||||
await apiFetch(`/user_api/bind_address`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
fetch: apiFetch,
|
||||
getSettings: getSettings,
|
||||
getOpenSettings: getOpenSettings,
|
||||
adminShowPassword: adminShowPassword,
|
||||
adminDeleteAddress: adminDeleteAddress,
|
||||
getSettings,
|
||||
getOpenSettings,
|
||||
getUserOpenSettings,
|
||||
getUserSettings,
|
||||
adminShowAddressCredential,
|
||||
adminDeleteAddress,
|
||||
bindUserAddress,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { watch, onMounted, ref, onBeforeUnmount } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
@@ -10,7 +9,6 @@ import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
enableUserDeleteEmail: {
|
||||
@@ -37,11 +35,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
requried: false
|
||||
}
|
||||
},
|
||||
showSaveS3: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
requried: false
|
||||
},
|
||||
saveToS3: {
|
||||
type: Function,
|
||||
default: (mail_id, filename, blob) => { },
|
||||
requried: false
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
localeCache, isDark, mailboxSplitSize, useIframeShowMail, sendMailModel
|
||||
isDark, mailboxSplitSize, indexTab, loading,
|
||||
useIframeShowMail, sendMailModel, preferShowTextMail
|
||||
} = useGlobalState()
|
||||
const autoRefresh = ref(false)
|
||||
const autoRefreshInterval = ref(30)
|
||||
@@ -55,9 +64,15 @@ const pageSize = ref(20)
|
||||
const showAttachments = ref(false)
|
||||
const curAttachments = ref([])
|
||||
const curMail = ref(null);
|
||||
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({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
success: 'Success',
|
||||
@@ -66,10 +81,17 @@ const { t } = useI18n({
|
||||
refresh: 'Refresh',
|
||||
attachments: 'Show Attachments',
|
||||
downloadMail: 'Download Mail',
|
||||
pleaseSelectMail: "Please select a mail to view.",
|
||||
pleaseSelectMail: "Please select mail",
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete this mail?',
|
||||
reply: 'Reply'
|
||||
deleteMailTip: 'Are you sure you want to delete mail?',
|
||||
reply: 'Reply',
|
||||
showTextMail: 'Show Text Mail',
|
||||
showHtmlMail: 'Show Html Mail',
|
||||
saveToS3: 'Save to S3',
|
||||
multiAction: 'Multi Action',
|
||||
cancelMultiAction: 'Cancel Multi Action',
|
||||
selectAll: 'Select All',
|
||||
unselectAll: 'Unselect All',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
@@ -78,10 +100,17 @@ const { t } = useI18n({
|
||||
refresh: '刷新',
|
||||
downloadMail: '下载邮件',
|
||||
attachments: '查看附件',
|
||||
pleaseSelectMail: "请选择一封邮件查看。",
|
||||
pleaseSelectMail: "请选择邮件",
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除这封邮件吗?',
|
||||
reply: '回复'
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
reply: '回复',
|
||||
showTextMail: '显示纯文本邮件',
|
||||
showHtmlMail: '显示HTML邮件',
|
||||
saveToS3: '保存到S3',
|
||||
multiAction: '多选',
|
||||
cancelMultiAction: '取消多选',
|
||||
selectAll: '全选',
|
||||
unselectAll: '取消全选',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -119,12 +148,14 @@ const refresh = async () => {
|
||||
pageSize.value, (page.value - 1) * pageSize.value
|
||||
);
|
||||
data.value = await Promise.all(results.map(async (item) => {
|
||||
item.checked = false;
|
||||
return await processItem(item);
|
||||
}));
|
||||
if (totalCount > 0) {
|
||||
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];
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -134,6 +165,10 @@ const refresh = async () => {
|
||||
};
|
||||
|
||||
const clickRow = async (row) => {
|
||||
if (multiActionMode.value) {
|
||||
row.checked = !row.checked;
|
||||
return;
|
||||
}
|
||||
curMail.value = row;
|
||||
};
|
||||
|
||||
@@ -169,17 +204,103 @@ const replyMail = async () => {
|
||||
Object.assign(sendMailModel.value, {
|
||||
toName: toName,
|
||||
toMail: toMail,
|
||||
subject: localeCache.value == 'zh' ? `回复: ${curMail.value.subject}` : `Re: ${curMail.value.subject}`,
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
subject: `${t('reply')}: ${curMail.value.subject}`,
|
||||
contentType: 'rich',
|
||||
content: curMail.value.text ? `<p><br></p><blockquote>${curMail.value.text}</blockquote><p><br></p>` : '',
|
||||
});
|
||||
await router.push('/send');
|
||||
indexTab.value = 'sendmail';
|
||||
};
|
||||
|
||||
const onSpiltSizeChange = (size) => {
|
||||
mailboxSplitSize.value = size;
|
||||
}
|
||||
|
||||
const attachmentLoding = ref(false)
|
||||
const saveToS3Proxy = async (filename, blob) => {
|
||||
attachmentLoding.value = true
|
||||
try {
|
||||
await props.saveToS3(curMail.value.id, filename, blob);
|
||||
} finally {
|
||||
attachmentLoding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 () => {
|
||||
await refresh();
|
||||
});
|
||||
@@ -191,14 +312,38 @@ onBeforeUnmount(() => {
|
||||
|
||||
<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-top: 10px; margin-bottom: 10px;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
</div>
|
||||
<n-switch v-model:value="autoRefresh" size="small" :round="false">
|
||||
<div v-if="!isMobile" class="left">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<n-space v-if="multiActionMode">
|
||||
<n-button @click="multiActionModeClick(false)" tertiary>
|
||||
{{ t('cancelMultiAction') }}
|
||||
</n-button>
|
||||
<n-button @click="multiActionSelectAll(true)" tertiary>
|
||||
{{ 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>
|
||||
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
|
||||
</template>
|
||||
@@ -206,87 +351,99 @@ onBeforeUnmount(() => {
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
</n-switch>
|
||||
<n-button @click="refresh" size="small" type="primary">
|
||||
<n-button @click="refresh" 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 }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ row.address }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
</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 }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="getAttachments(curMail.attachments)">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(curMail.raw)">
|
||||
<template #icon>
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</template>
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<iframe v-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>
|
||||
<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 type="info">
|
||||
FROM: {{ row.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ row.address }}
|
||||
</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.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="getAttachments(curMail.attachments)">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(curMail.raw)">
|
||||
<template #icon>
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</template>
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
|
||||
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
|
||||
style="margin-top: 10px;width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
|
||||
</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="center">
|
||||
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
|
||||
<n-space justify="center">
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
</div>
|
||||
<n-switch v-model:value="autoRefresh" size="small" :round="false">
|
||||
@@ -297,10 +454,10 @@ onBeforeUnmount(() => {
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
</n-switch>
|
||||
<n-button @click="refresh" size="small" type="primary">
|
||||
<n-button @click="refresh" tertiary size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-space>
|
||||
<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)">
|
||||
@@ -310,7 +467,7 @@ onBeforeUnmount(() => {
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ row.created_at }}
|
||||
{{ `${row.created_at} UTC` }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
@@ -332,7 +489,7 @@ onBeforeUnmount(() => {
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ curMail.created_at }}
|
||||
{{ `${curMail.created_at} UTC` }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
@@ -361,8 +518,15 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
|
||||
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
|
||||
</n-button>
|
||||
</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-drawer-content>
|
||||
</n-drawer>
|
||||
@@ -371,25 +535,51 @@ onBeforeUnmount(() => {
|
||||
<template #header>
|
||||
<div>{{ t("attachments") }}</div>
|
||||
</template>
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.filename">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
Size: {{ row.size }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<n-spin v-model:show="attachmentLoding">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.filename">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
Size: {{ row.size }}
|
||||
</n-tag>
|
||||
<n-button v-if="showSaveS3" @click="saveToS3Proxy(row.filename, row.blob)" ghost type="info"
|
||||
size="small">
|
||||
{{ t('saveToS3') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-thing>
|
||||
<template #suffix>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
|
||||
:href="row.url">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</n-button>
|
||||
</template>
|
||||
</n-thing>
|
||||
<template #suffix>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
|
||||
:href="row.url">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</n-button>
|
||||
</template>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showMultiActionDownload" preset="dialog" :title="t('downloadMail')">
|
||||
<n-tag type="info">
|
||||
{{ multiActionDownloadZip.filename }}
|
||||
</n-tag>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -420,4 +610,9 @@ onBeforeUnmount(() => {
|
||||
.mail-item {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
269
frontend/src/components/SendBox.vue
Normal 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>
|
||||
90
frontend/src/components/Turnstile.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { ref, watch, defineModel, onMounted } from "vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
const { openSettings, isDark } = useGlobalState()
|
||||
|
||||
const cfToken = defineModel('value')
|
||||
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
refresh: 'Refresh'
|
||||
},
|
||||
zh: {
|
||||
refresh: '刷新'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const cfTurnstileId = ref("")
|
||||
const turnstileLoading = ref(false)
|
||||
|
||||
const checkCfTurnstile = async (remove) => {
|
||||
if (!openSettings.value.cfTurnstileSiteKey) return;
|
||||
turnstileLoading.value = true;
|
||||
try {
|
||||
let container = document.getElementById("cf-turnstile");
|
||||
let count = 100;
|
||||
while (!container && count-- > 0) {
|
||||
container = document.getElementById("cf-turnstile");
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
count = 100;
|
||||
while (!window.turnstile && count-- > 0) {
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
if (remove && cfTurnstileId.value) {
|
||||
window.turnstile.remove(cfTurnstileId.value);
|
||||
}
|
||||
cfTurnstileId.value = window.turnstile.render(
|
||||
"#cf-turnstile",
|
||||
{
|
||||
sitekey: openSettings.value.cfTurnstileSiteKey,
|
||||
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
|
||||
theme: isDark.value ? 'dark' : 'light',
|
||||
callback: function (token) {
|
||||
cfToken.value = token;
|
||||
},
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
turnstileLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, async (isDark) => {
|
||||
checkCfTurnstile(true)
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
cfToken.value = "";
|
||||
checkCfTurnstile(true);
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="openSettings.cfTurnstileSiteKey" class="center">
|
||||
<n-spin description="loading..." :show="turnstileLoading">
|
||||
<n-form-item-row>
|
||||
<n-flex vertical>
|
||||
<div id="cf-turnstile"></div>
|
||||
<n-button text @click="checkCfTurnstile(true)">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</n-form-item-row>
|
||||
</n-spin>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import App from './App.vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import router from './router'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { createHead } from '@unhead/vue'
|
||||
|
||||
registerSW({ immediate: true })
|
||||
const i18n = createI18n({
|
||||
@@ -16,7 +17,19 @@ const i18n = createI18n({
|
||||
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)
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
app.use(head)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Index from '../views/Index.vue'
|
||||
import User from '../views/User.vue'
|
||||
import SendMail from '../views/send/SendMail.vue'
|
||||
import Admin from '../views/Admin.vue'
|
||||
import { useGlobalState } from '../store'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
alias: "/:lang/",
|
||||
component: Index
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
alias: "/:lang/user",
|
||||
component: User
|
||||
},
|
||||
{
|
||||
path: '/send',
|
||||
component: SendMail
|
||||
path: '/admin',
|
||||
alias: "/:lang/admin",
|
||||
component: () => import('../views/Admin.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: Admin
|
||||
path: '/telegram_mail',
|
||||
alias: "/:lang/telegram_mail",
|
||||
component: () => import('../views/telegram/Mail.vue')
|
||||
},
|
||||
{
|
||||
name: 'not-found',
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref } from "vue";
|
||||
import { createGlobalState, useStorage } from '@vueuse/core'
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
import { createGlobalState, useStorage, useDark, useToggle } from '@vueuse/core'
|
||||
|
||||
export const useGlobalState = createGlobalState(
|
||||
() => {
|
||||
@@ -8,18 +7,22 @@ export const useGlobalState = createGlobalState(
|
||||
const toggleDark = useToggle(isDark)
|
||||
const loading = ref(false);
|
||||
const openSettings = ref({
|
||||
title: '',
|
||||
prefix: '',
|
||||
needAuth: false,
|
||||
adminContact: '',
|
||||
enableUserCreateEmail: false,
|
||||
enableUserDeleteEmail: false,
|
||||
enableAutoReply: false,
|
||||
enableIndexAbout: false,
|
||||
domains: [],
|
||||
copyright: 'Dream Hunter',
|
||||
cfTurnstileSiteKey: '',
|
||||
enableWebhook: false,
|
||||
isS3Enabled: false,
|
||||
})
|
||||
const settings = ref({
|
||||
fetched: false,
|
||||
has_v1_mails: false,
|
||||
send_balance: 0,
|
||||
address: '',
|
||||
auto_reply: {
|
||||
@@ -39,17 +42,36 @@ export const useGlobalState = createGlobalState(
|
||||
content: "",
|
||||
});
|
||||
const showAuth = ref(false);
|
||||
const showPassword = ref(false);
|
||||
const showAddressCredential = ref(false);
|
||||
const showAdminAuth = ref(false);
|
||||
const auth = useStorage('auth', '');
|
||||
const adminAuth = useStorage('adminAuth', '');
|
||||
const jwt = useStorage('jwt', '');
|
||||
const localeCache = useStorage('locale', 'zh');
|
||||
const adminTab = ref("account");
|
||||
const adminMailTabAddress = ref("");
|
||||
const adminSendBoxTabAddress = ref("");
|
||||
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
|
||||
const useIframeShowMail = useStorage('useIframeShowMail', false);
|
||||
const preferShowTextMail = useStorage('preferShowTextMail', false);
|
||||
const userJwt = useStorage('userJwt', '');
|
||||
const userTab = useStorage('userTab', 'user_settings');
|
||||
const indexTab = useStorage('indexTab', 'mailbox');
|
||||
const globalTabplacement = useStorage('globalTabplacement', 'top');
|
||||
const useSideMargin = useStorage('useSideMargin', true);
|
||||
const userOpenSettings = ref({
|
||||
enable: false,
|
||||
enableMailVerify: false,
|
||||
});
|
||||
const userSettings = ref({
|
||||
/** @type {boolean} */
|
||||
fetched: false,
|
||||
/** @type {string} */
|
||||
user_email: '',
|
||||
/** @type {number} */
|
||||
user_id: 0,
|
||||
});
|
||||
const telegramApp = ref(window.Telegram?.WebApp || {});
|
||||
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
|
||||
return {
|
||||
isDark,
|
||||
toggleDark,
|
||||
@@ -58,10 +80,9 @@ export const useGlobalState = createGlobalState(
|
||||
sendMailModel,
|
||||
openSettings,
|
||||
showAuth,
|
||||
showPassword,
|
||||
showAddressCredential,
|
||||
auth,
|
||||
jwt,
|
||||
localeCache,
|
||||
adminAuth,
|
||||
showAdminAuth,
|
||||
adminTab,
|
||||
@@ -69,6 +90,16 @@ export const useGlobalState = createGlobalState(
|
||||
adminSendBoxTabAddress,
|
||||
mailboxSplitSize,
|
||||
useIframeShowMail,
|
||||
preferShowTextMail,
|
||||
userJwt,
|
||||
userTab,
|
||||
indexTab,
|
||||
userOpenSettings,
|
||||
userSettings,
|
||||
globalTabplacement,
|
||||
useSideMargin,
|
||||
telegramApp,
|
||||
isTelegram,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -14,12 +14,13 @@ export async function processItem(item) {
|
||||
item.source = parsedEmail.sender || item.source;
|
||||
item.subject = parsedEmail.subject || '';
|
||||
item.message = parsedEmail.body_html || parsedEmail.text || '';
|
||||
item.text = parsedEmail.text || '';
|
||||
item.attachments = parsedEmail.attachments?.map((a_item) => {
|
||||
const blob_url = URL.createObjectURL(
|
||||
new Blob(
|
||||
[a_item.content],
|
||||
{ type: a_item.content_type || 'application/octet-stream' }
|
||||
))
|
||||
const blob = new Blob(
|
||||
[a_item.content],
|
||||
{ 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) {
|
||||
item.message = item.message.replace(`cid:${a_item.content_id}`, blob_url);
|
||||
}
|
||||
@@ -27,7 +28,8 @@ export async function processItem(item) {
|
||||
id: a_item.content_id || Math.random().toString(36).substring(2, 15),
|
||||
filename: a_item.filename || a_item.content_id || "",
|
||||
size: humanFileSize(a_item.content?.length || 0),
|
||||
url: blob_url
|
||||
url: blob_url,
|
||||
blob: blob
|
||||
}
|
||||
}) || [];
|
||||
} catch (error) {
|
||||
@@ -46,12 +48,13 @@ export async function processItem(item) {
|
||||
}
|
||||
item.subject = parsedEmail.subject || 'No Subject';
|
||||
item.message = parsedEmail.html || parsedEmail.text || item.raw;
|
||||
item.text = parsedEmail.text || '';
|
||||
item.attachments = parsedEmail.attachments?.map((a_item) => {
|
||||
const blob_url = URL.createObjectURL(
|
||||
new Blob(
|
||||
[a_item.content],
|
||||
{ type: a_item.mimeType || 'application/octet-stream' }
|
||||
))
|
||||
const blob = new Blob(
|
||||
[a_item.content],
|
||||
{ type: a_item.mimeType || 'application/octet-stream' }
|
||||
);
|
||||
const blob_url = URL.createObjectURL(blob)
|
||||
if (a_item.contentId && a_item.contentId.length > 0) {
|
||||
item.message = item.message.replace(`cid:${a_item.contentId}`, blob_url);
|
||||
}
|
||||
@@ -59,7 +62,8 @@ export async function processItem(item) {
|
||||
id: a_item.contentId || Math.random().toString(36).substring(2, 15),
|
||||
filename: a_item.filename || a_item.contentId || "",
|
||||
size: humanFileSize(a_item.content?.length || 0),
|
||||
url: blob_url
|
||||
url: blob_url,
|
||||
blob: blob
|
||||
}
|
||||
}) || [];
|
||||
} catch (error) {
|
||||
|
||||
13
frontend/src/utils/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export const hashPassword = async (password: string) => {
|
||||
// user crypto to hash password
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
|
||||
const hashArray = Array.from(new Uint8Array(digest));
|
||||
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}`;
|
||||
}
|
||||
@@ -10,12 +10,18 @@ import SendBox from './admin/SendBox.vue';
|
||||
import Account from './admin/Account.vue';
|
||||
import CreateAccount from './admin/CreateAccount.vue';
|
||||
import AccountSettings from './admin/AccountSettings.vue';
|
||||
import UserManagement from './admin/UserManagement.vue';
|
||||
import UserSettings from './admin/UserSettings.vue';
|
||||
import Mails from './admin/Mails.vue';
|
||||
import MailsUnknow from './admin/MailsUnknow.vue';
|
||||
import About from './common/About.vue';
|
||||
import Maintenance from './admin/Maintenance.vue';
|
||||
import Appearance from './common/Appearance.vue';
|
||||
import Telegram from './admin/Telegram.vue';
|
||||
import Webhook from './admin/Webhook.vue';
|
||||
|
||||
const {
|
||||
localeCache, adminAuth, showAdminAuth, adminTab, loading
|
||||
adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -28,7 +34,6 @@ const authFunc = async () => {
|
||||
}
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
accessHeader: 'Admin Password',
|
||||
@@ -37,10 +42,18 @@ const { t } = useI18n({
|
||||
account: 'Account',
|
||||
account_create: 'Create Account',
|
||||
account_settings: 'Account Settings',
|
||||
user: 'User',
|
||||
user_management: 'User Management',
|
||||
user_settings: 'User Settings',
|
||||
unknow: 'Mails with unknow receiver',
|
||||
senderAccess: 'Sender Access Control',
|
||||
sendBox: 'Send Box',
|
||||
telegram: 'Telegram Bot',
|
||||
webhook: 'Webhook',
|
||||
statistics: 'Statistics',
|
||||
maintenance: 'Maintenance',
|
||||
appearance: 'Appearance',
|
||||
about: 'About',
|
||||
ok: 'OK',
|
||||
},
|
||||
zh: {
|
||||
@@ -50,10 +63,18 @@ const { t } = useI18n({
|
||||
account: '账号',
|
||||
account_create: '创建账号',
|
||||
account_settings: '账号设置',
|
||||
user: '用户',
|
||||
user_management: '用户管理',
|
||||
user_settings: '用户设置',
|
||||
unknow: '无收件人邮件',
|
||||
senderAccess: '发件权限控制',
|
||||
sendBox: '发件箱',
|
||||
telegram: '电报机器人',
|
||||
webhook: 'Webhook',
|
||||
statistics: '统计',
|
||||
maintenance: '维护',
|
||||
appearance: '外观',
|
||||
about: '关于',
|
||||
ok: '确定',
|
||||
}
|
||||
}
|
||||
@@ -79,32 +100,64 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<Statistics />
|
||||
<n-tabs type="card" v-model:value="adminTab">
|
||||
<n-tabs type="card" v-model:value="adminTab" :placement="globalTabplacement">
|
||||
<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 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 name="user" :tab="t('user')">
|
||||
<n-tabs type="bar" animated>
|
||||
<n-tab-pane name="user_management" :tab="t('user_management')">
|
||||
<UserManagement />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettings />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<Mails />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="unknow" :tab="t('unknow')">
|
||||
<MailsUnknow />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
|
||||
<SenderAccess />
|
||||
<n-tabs type="bar" animated>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<Mails />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="unknow" :tab="t('unknow')">
|
||||
<MailsUnknow />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendBox" :tab="t('sendBox')">
|
||||
<SendBox />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="telegram" :tab="t('telegram')">
|
||||
<Telegram />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="statistics" :tab="t('statistics')">
|
||||
<Statistics />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="maintenance" :tab="t('maintenance')">
|
||||
<Maintenance />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="appearance" :tab="t('appearance')">
|
||||
<Appearance />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="about" :tab="t('about')">
|
||||
<About />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
const { localeCache, openSettings } = useGlobalState()
|
||||
const { openSettings } = useGlobalState()
|
||||
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
copyright: "Copyright"
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
<script setup>
|
||||
import useClipboard from 'vue-clipboard3'
|
||||
import { ref, h, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import {
|
||||
DarkModeFilled, LightModeFilled, MenuFilled,
|
||||
AdminPanelSettingsFilled, SendFilled
|
||||
AdminPanelSettingsFilled
|
||||
} from '@vicons/material'
|
||||
import { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
|
||||
|
||||
import Login from './Login.vue'
|
||||
import { GithubAlt, Language, User, Home } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
const { toClipboard } = useClipboard()
|
||||
import { getRouterPathWithLang } from '../utils'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const {
|
||||
jwt, localeCache, toggleDark, isDark, settings, showPassword,
|
||||
showAuth, adminAuth, auth, loading
|
||||
toggleDark, isDark, isTelegram,
|
||||
showAuth, adminAuth, auth, loading, openSettings
|
||||
} = useGlobalState()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const isAdminRoute = computed(() => route.path.includes('admin'))
|
||||
|
||||
const showMobileMenu = ref(false)
|
||||
const menuValue = computed(() => {
|
||||
if (route.path.includes("user")) return "user";
|
||||
if (route.path.includes("admin")) return "admin";
|
||||
return "home";
|
||||
});
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
@@ -36,32 +39,26 @@ const authFunc = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const changeLocale = (locale) => {
|
||||
localeCache.value = locale;
|
||||
location.reload()
|
||||
const changeLocale = async (lang) => {
|
||||
if (lang == 'zh') {
|
||||
await router.push(route.fullPath.replace('/en', ''));
|
||||
} else {
|
||||
await router.push(`/${lang}${route.fullPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
title: 'Cloudflare Temp Email',
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
accessHeader: 'Access Password',
|
||||
accessTip: 'Please enter the correct password',
|
||||
accessTip: 'Please enter the correct access password',
|
||||
home: 'Home',
|
||||
menu: 'Menu',
|
||||
user: 'User',
|
||||
sendMail: 'Send Mail',
|
||||
yourAddress: 'Your email address is',
|
||||
ok: 'OK',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
fetchAddressError: 'Login password 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.',
|
||||
password: 'Password',
|
||||
passwordTip: 'Please copy the password and you can use it to login to your email account.',
|
||||
},
|
||||
zh: {
|
||||
title: 'Cloudflare 临时邮件',
|
||||
@@ -72,36 +69,30 @@ const { t } = useI18n({
|
||||
home: '主页',
|
||||
menu: '菜单',
|
||||
user: '用户',
|
||||
sendMail: '发送邮件',
|
||||
yourAddress: '你的邮箱地址是',
|
||||
ok: '确定',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
fetchAddressError: '登录密码无效或账号不存在,也可能是网络连接异常,请稍后再尝试。',
|
||||
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
|
||||
password: '密码',
|
||||
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const showUserMenu = computed(() => !!settings.value.address)
|
||||
const version = import.meta.env.PACKAGE_VERSION ? `v${import.meta.env.PACKAGE_VERSION}` : "";
|
||||
|
||||
const menuOptions = computed(() => [
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
type: menuValue.value == "home" ? "primary" : "default",
|
||||
style: "width: 100%",
|
||||
onClick: () => { router.push('/'); showMobileMenu.value = false; }
|
||||
onClick: async () => {
|
||||
await router.push(getRouterPathWithLang('/', locale.value));
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => t('home'),
|
||||
icon: () => h(NIcon, { component: Home })
|
||||
}
|
||||
),
|
||||
}),
|
||||
key: "home"
|
||||
},
|
||||
{
|
||||
@@ -110,8 +101,33 @@ const menuOptions = computed(() => [
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
type: menuValue.value == "user" ? "primary" : "default",
|
||||
style: "width: 100%",
|
||||
onClick: () => { router.push('/admin'); showMobileMenu.value = false; }
|
||||
onClick: async () => {
|
||||
await router.push(getRouterPathWithLang("/user", locale.value));
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => t('user'),
|
||||
icon: () => h(NIcon, { component: User }),
|
||||
}
|
||||
),
|
||||
key: "user",
|
||||
show: !isTelegram.value
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
type: menuValue.value == "admin" ? "primary" : "default",
|
||||
style: "width: 100%",
|
||||
onClick: async () => {
|
||||
await router.push(getRouterPathWithLang('/admin', locale.value));
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => "Admin",
|
||||
@@ -121,23 +137,6 @@ const menuOptions = computed(() => [
|
||||
show: !!adminAuth.value,
|
||||
key: "admin"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
onClick: () => { router.push("/user"); showMobileMenu.value = false; }
|
||||
},
|
||||
{
|
||||
default: () => t('user'),
|
||||
icon: () => h(NIcon, { component: User }),
|
||||
}
|
||||
),
|
||||
show: showUserMenu.value,
|
||||
key: "user",
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
@@ -163,13 +162,13 @@ const menuOptions = computed(() => [
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
onClick: () => {
|
||||
localeCache.value == 'zh' ? changeLocale('en') : changeLocale('zh');
|
||||
onClick: async () => {
|
||||
locale.value == 'zh' ? await changeLocale('en') : await changeLocale('zh');
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => localeCache.value == 'zh' ? "English" : "中文",
|
||||
default: () => locale.value == 'zh' ? "English" : "中文",
|
||||
icon: () => h(
|
||||
NIcon, { component: Language }
|
||||
)
|
||||
@@ -189,7 +188,7 @@ const menuOptions = computed(() => [
|
||||
href: "https://github.com/dreamhunter2333/cloudflare_temp_email",
|
||||
},
|
||||
{
|
||||
default: () => "Github",
|
||||
default: () => version || "Github",
|
||||
icon: () => h(NIcon, { component: GithubAlt })
|
||||
}
|
||||
),
|
||||
@@ -197,18 +196,15 @@ const menuOptions = computed(() => [
|
||||
}
|
||||
]);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await toClipboard(settings.value.address)
|
||||
message.success(t('copied'));
|
||||
} catch (e) {
|
||||
message.error(e.message || "error");
|
||||
}
|
||||
}
|
||||
useHead({
|
||||
title: () => openSettings.value.title || t('title'),
|
||||
meta: [
|
||||
{ name: "description", content: openSettings.value.description || t('title') },
|
||||
]
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getOpenSettings(message);
|
||||
await api.getSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -216,14 +212,14 @@ onMounted(async () => {
|
||||
<div>
|
||||
<n-page-header>
|
||||
<template #title>
|
||||
<h3>{{ t('title') }}</h3>
|
||||
<h3>{{ openSettings.title || t('title') }}</h3>
|
||||
</template>
|
||||
<template #avatar>
|
||||
<n-avatar style="margin-left: 10px;" src="/logo.png" />
|
||||
</template>
|
||||
<template #extra>
|
||||
<n-space>
|
||||
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" />
|
||||
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" responsive />
|
||||
<n-button v-else :text="true" @click="showMobileMenu = !showMobileMenu" style="margin-right: 10px;">
|
||||
<template #icon>
|
||||
<n-icon :component="MenuFilled" />
|
||||
@@ -238,49 +234,6 @@ onMounted(async () => {
|
||||
<n-menu :options="menuOptions" />
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
<div v-if="!isAdminRoute">
|
||||
<n-card v-if="!settings.fetched">
|
||||
<n-skeleton style="height: 50vh" />
|
||||
</n-card>
|
||||
<div v-else-if="settings.address">
|
||||
<n-alert v-if="settings.has_v1_mails" type="warning" show-icon 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>
|
||||
<span>
|
||||
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
|
||||
<n-button style="margin-left: 10px" @click="router.push('/send')" size="small" tertiary
|
||||
type="primary">
|
||||
<n-icon :component="SendFilled" /> {{ t('sendMail') }}
|
||||
</n-button>
|
||||
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
|
||||
<n-icon :component="Copy" /> {{ t('copy') }}
|
||||
</n-button>
|
||||
</span>
|
||||
</n-alert>
|
||||
</div>
|
||||
<div v-else class="center">
|
||||
<n-card style="max-width: 600px;">
|
||||
<n-alert v-if="jwt" type="warning" show-icon>
|
||||
<span>{{ t('fetchAddressError') }}</span>
|
||||
</n-alert>
|
||||
<Login />
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
<n-modal v-model:show="showPassword" preset="dialog" :title="t('password')">
|
||||
<span>
|
||||
<p>{{ t("passwordTip") }}</p>
|
||||
</span>
|
||||
<n-card>
|
||||
<b>{{ jwt }}</b>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
|
||||
:title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
|
||||
@@ -1,9 +1,47 @@
|
||||
<script setup>
|
||||
import MailBox from '../components/MailBox.vue';
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
|
||||
const { settings, openSettings } = useGlobalState()
|
||||
import AddressBar from './index/AddressBar.vue';
|
||||
import MailBox from '../components/MailBox.vue';
|
||||
import SendBox from '../components/SendBox.vue';
|
||||
import AutoReply from './index/AutoReply.vue';
|
||||
import AccountSettings from './index/AccountSettings.vue';
|
||||
import Webhook from './index/Webhook.vue';
|
||||
import Attachment from './index/Attachment.vue';
|
||||
import About from './common/About.vue';
|
||||
|
||||
const SendMail = defineAsyncComponent(() => import('./index/SendMail.vue'));
|
||||
const { settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
mailbox: 'Mail Box',
|
||||
sendbox: 'Send Box',
|
||||
sendmail: 'Send Mail',
|
||||
auto_reply: 'Auto Reply',
|
||||
accountSettings: 'Account Settings',
|
||||
about: 'About',
|
||||
s3Attachment: 'S3 Attachment',
|
||||
saveToS3Success: 'save to s3 success',
|
||||
},
|
||||
zh: {
|
||||
mailbox: '收件箱',
|
||||
sendbox: '发件箱',
|
||||
sendmail: '发送邮件',
|
||||
auto_reply: '自动回复',
|
||||
accountSettings: '账户设置',
|
||||
about: '关于',
|
||||
s3Attachment: 'S3附件',
|
||||
saveToS3Success: '保存到s3成功',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
|
||||
@@ -12,11 +50,62 @@ const fetchMailData = async (limit, offset) => {
|
||||
const deleteMail = async (curMailId) => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div v-if="settings.address">
|
||||
<MailBox :showEMailTo="false" :showReply="true" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
|
||||
<div>
|
||||
<AddressBar />
|
||||
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="mailbox" :tab="t('mailbox')">
|
||||
<MailBox :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled" :saveToS3="saveToS3"
|
||||
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
|
||||
:deleteMail="deleteMail" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox :fetchMailData="fetchSenboxData" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendmail" :tab="t('sendmail')">
|
||||
<SendMail />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
|
||||
<AutoReply />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
|
||||
import AutoReply from './user/AutoReply.vue';
|
||||
import SendBox from './send/SendBox.vue';
|
||||
import Account from './user/Account.vue';
|
||||
import AddressMangement from './user/AddressManagement.vue';
|
||||
import UserSettingsPage from './user/UserSettings.vue';
|
||||
import UserBar from './user/UserBar.vue';
|
||||
import BindAddress from './user/BindAddress.vue';
|
||||
|
||||
const { localeCache, settings, openSettings } = useGlobalState()
|
||||
const userTab = useStorage('userTab', 'account')
|
||||
const {
|
||||
userTab, globalTabplacement, userSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
sendbox: 'Send Box',
|
||||
auto_reply: 'Auto Reply',
|
||||
account: 'Account',
|
||||
address_management: 'Address Management',
|
||||
user_settings: 'User Settings',
|
||||
bind_address: 'Bind Mail Address',
|
||||
},
|
||||
zh: {
|
||||
sendbox: '发件箱',
|
||||
auto_reply: '自动回复',
|
||||
account: '账户',
|
||||
address_management: '地址管理',
|
||||
user_settings: '用户设置',
|
||||
bind_address: '绑定邮箱地址',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -30,16 +30,17 @@ const { t } = useI18n({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="settings.address">
|
||||
<n-tabs type="card" v-model:value="userTab">
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<Account />
|
||||
<div>
|
||||
<UserBar />
|
||||
<n-tabs v-if="userSettings.user_email" type="card" v-model:value="userTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="address_management" :tab="t('address_management')">
|
||||
<AddressMangement />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox />
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettingsPage />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
|
||||
<AutoReply />
|
||||
<n-tab-pane name="bind_address" :tab="t('bind_address')">
|
||||
<BindAddress />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,12 @@ import { NButton, NMenu } from 'naive-ui';
|
||||
import { MenuFilled } from '@vicons/material'
|
||||
|
||||
const {
|
||||
localeCache, adminAuth, showAdminAuth, loading,
|
||||
adminAuth, showAdminAuth, loading,
|
||||
adminTab, adminMailTabAddress, adminSendBoxTabAddress
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
name: 'Name',
|
||||
@@ -23,9 +22,9 @@ const { t } = useI18n({
|
||||
updated_at: 'Update At',
|
||||
mail_count: 'Mail Count',
|
||||
send_count: 'Send Count',
|
||||
showPass: 'Show Passwrod',
|
||||
password: 'Password',
|
||||
passwordTip: 'Please copy the password and you can use it to login to your email account.',
|
||||
showCredential: 'Show Mail Address Credential',
|
||||
addressCredential: 'Mail Address Credential',
|
||||
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
|
||||
delete: 'Delete',
|
||||
deleteTip: 'Are you sure to delete this email?',
|
||||
delteAccount: 'Delete Account',
|
||||
@@ -42,9 +41,9 @@ const { t } = useI18n({
|
||||
updated_at: '更新时间',
|
||||
mail_count: '邮件数量',
|
||||
send_count: '发送数量',
|
||||
showPass: '显示密码',
|
||||
password: '密码',
|
||||
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
|
||||
showCredential: '查看邮箱地址凭证',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
delete: '删除',
|
||||
deleteTip: '确定要删除这个邮箱吗?',
|
||||
delteAccount: '删除邮箱',
|
||||
@@ -58,8 +57,8 @@ const { t } = useI18n({
|
||||
}
|
||||
});
|
||||
|
||||
const showEmailPassword = ref(false)
|
||||
const curEmailPassword = ref("")
|
||||
const showEmailCredential = ref(false)
|
||||
const curEmailCredential = ref("")
|
||||
const curDeleteAddressId = ref(0);
|
||||
|
||||
const addressQuery = ref("")
|
||||
@@ -68,16 +67,16 @@ const data = ref([])
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const showDelteAccount = ref(false)
|
||||
const showDeleteAccount = ref(false)
|
||||
|
||||
const showPassword = async (id) => {
|
||||
const showCredential = async (id) => {
|
||||
try {
|
||||
curEmailPassword.value = await api.adminShowPassword(id)
|
||||
showEmailPassword.value = true
|
||||
curEmailCredential.value = await api.adminShowAddressCredential(id)
|
||||
showEmailCredential.value = true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
showEmailPassword.value = false
|
||||
curEmailPassword.value = ""
|
||||
showEmailCredential.value = false
|
||||
curEmailCredential.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +87,8 @@ const deleteEmail = async () => {
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
showDelteAccount.value = false
|
||||
} finally {
|
||||
showDeleteAccount.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,9 +196,9 @@ const columns = [
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => showPassword(row.id)
|
||||
onClick: () => showCredential(row.id)
|
||||
},
|
||||
{ default: () => t('showPass') }
|
||||
{ default: () => t('showCredential') }
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -231,7 +231,7 @@ const columns = [
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curDeleteAddressId.value = row.id;
|
||||
showDelteAccount.value = true;
|
||||
showDeleteAccount.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('delete') }
|
||||
@@ -260,21 +260,21 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal v-model:show="showEmailPassword" preset="dialog" title="Dialog">
|
||||
<div style="margin-top: 10px;">
|
||||
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t("password") }}</div>
|
||||
<div>{{ t("addressCredential") }}</div>
|
||||
</template>
|
||||
<span>
|
||||
<p>{{ t("passwordTip") }}</p>
|
||||
<p>{{ t("addressCredentialTip") }}</p>
|
||||
</span>
|
||||
<n-card>
|
||||
<b>{{ curEmailPassword }}</b>
|
||||
<b>{{ curEmailCredential }}</b>
|
||||
</n-card>
|
||||
<template #action>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
|
||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('delteAccount')">
|
||||
<p>{{ t('deleteTip') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
|
||||
@@ -284,7 +284,7 @@ onMounted(async () => {
|
||||
</n-modal>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
|
||||
<n-button @click="fetchData" type="primary" ghost>
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
|
||||
@@ -5,33 +5,40 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, loading } = useGlobalState()
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
save: 'Save',
|
||||
successTip: 'Save Success',
|
||||
address_block_list: 'Address Block Keywords for Users(Admin can skip)',
|
||||
address_block_list_placeholder: 'Please enter the keywords you want to block',
|
||||
send_address_block_list: 'Address Block Keywords for send email',
|
||||
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
|
||||
},
|
||||
zh: {
|
||||
save: '保存',
|
||||
successTip: '保存成功',
|
||||
address_block_list: '用户地址屏蔽关键词(管理员可跳过检查)',
|
||||
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
|
||||
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
|
||||
send_address_block_list: '发送邮件地址屏蔽关键词',
|
||||
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const addressBlockList = ref([])
|
||||
const sendAddressBlockList = ref([])
|
||||
const verifiedAddressList = ref([])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/account_settings`)
|
||||
addressBlockList.value = res.blockList || []
|
||||
sendAddressBlockList.value = res.sendBlockList || []
|
||||
verifiedAddressList.value = res.verifiedAddressList || []
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
@@ -42,7 +49,9 @@ const save = async () => {
|
||||
await api.fetch(`/admin/account_settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
blockList: addressBlockList.value || []
|
||||
blockList: addressBlockList.value || [],
|
||||
sendBlockList: sendAddressBlockList.value || [],
|
||||
verifiedAddressList: verifiedAddressList.value || []
|
||||
})
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
@@ -64,6 +73,14 @@ onMounted(async () => {
|
||||
<n-select v-model:value="addressBlockList" filterable multiple tag
|
||||
:placeholder="t('address_block_list_placeholder')" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('send_address_block_list')">
|
||||
<n-select v-model:value="sendAddressBlockList" filterable multiple tag
|
||||
:placeholder="t('address_block_list_placeholder')" />
|
||||
</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">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
|
||||
@@ -6,12 +6,11 @@ import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const {
|
||||
localeCache, loading, openSettings,
|
||||
loading, openSettings,
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
address: 'Address',
|
||||
@@ -19,7 +18,7 @@ const { t } = useI18n({
|
||||
creatNewEmail: 'Get New Email',
|
||||
fillInAllFields: 'Please fill in all fields',
|
||||
successTip: 'Success Created',
|
||||
password: 'Password',
|
||||
addressCredential: 'Mail Address Credential',
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
@@ -27,7 +26,7 @@ const { t } = useI18n({
|
||||
creatNewEmail: '创建新邮箱',
|
||||
fillInAllFields: '请填写完整信息',
|
||||
successTip: '创建成功',
|
||||
password: '密码',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -70,8 +69,8 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('password')">
|
||||
<p>{{ t('password') }}</p>
|
||||
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
|
||||
<p>{{ t('addressCredential') }}</p>
|
||||
<n-card>
|
||||
<b>{{ result }}</b>
|
||||
</n-card>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
@@ -7,28 +7,35 @@ import { api } from '../../api'
|
||||
import MailBox from '../../components/MailBox.vue';
|
||||
|
||||
const {
|
||||
localeCache, adminAuth, showAdminAuth,
|
||||
adminAuth, showAdminAuth,
|
||||
adminMailTabAddress
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
addressQueryTip: 'Leave blank to query all addresses',
|
||||
keywordQueryTip: 'Leave blank to not query by keyword',
|
||||
query: 'Query',
|
||||
},
|
||||
zh: {
|
||||
addressQueryTip: '留空查询所有地址',
|
||||
keywordQueryTip: '留空不按关键字查询',
|
||||
query: '查询',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const mailBoxKey = ref("")
|
||||
const mailKeyword = ref("")
|
||||
|
||||
const queryAddress = () => {
|
||||
mailBoxKey.value = adminMailTabAddress.value;
|
||||
watch([adminMailTabAddress, mailKeyword], () => {
|
||||
adminMailTabAddress.value = adminMailTabAddress.value.trim();
|
||||
mailKeyword.value = mailKeyword.value.trim();
|
||||
});
|
||||
|
||||
const queryMail = () => {
|
||||
mailBoxKey.value = Date.now();
|
||||
}
|
||||
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
@@ -37,6 +44,7 @@ const fetchMailData = async (limit, offset) => {
|
||||
+ `?limit=${limit}`
|
||||
+ `&offset=${offset}`
|
||||
+ (adminMailTabAddress.value ? `&address=${adminMailTabAddress.value}` : '')
|
||||
+ (mailKeyword.value ? `&keyword=${mailKeyword.value}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,13 +57,15 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-top: 10px;">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" />
|
||||
<n-button @click="queryAddress" type="primary" ghost>
|
||||
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" />
|
||||
<n-button @click="queryMail" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div style="margin-top: 10px;"></div>
|
||||
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="false" :fetchMailData="fetchMailData" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -24,7 +24,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="adminAuth">
|
||||
<div v-if="adminAuth" style="margin-top: 10px;">
|
||||
<MailBox :enableUserDeleteEmail="false" :fetchMailData="fetchMailUnknowData" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CleaningServicesFilled } from '@vicons/material'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
|
||||
const { adminAuth, showAdminAuth } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const cleanupModel = ref({
|
||||
enableMailsAutoCleanup: false,
|
||||
@@ -20,29 +20,28 @@ const cleanupModel = ref({
|
||||
})
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
tip: 'Please input the cleanup days',
|
||||
mailBoxLabel: 'Clean up days for mailbox',
|
||||
mailUnknowLabel: "Clean up days for unknow receiver",
|
||||
addressUnActiveLabel: "Clean up days for unactive address",
|
||||
sendBoxLabel: "Clean up days for sendbox",
|
||||
cleanupNow: "Cleanup now",
|
||||
autoCleanup: "Auto cleanup",
|
||||
cleanupSuccess: "Cleanup success",
|
||||
save: "Save",
|
||||
cronTip: "Enable cron cleanup, need to configure [crons] in worker, please refer to the document",
|
||||
},
|
||||
zh: {
|
||||
tip: '请输入清理天数',
|
||||
mailBoxLabel: '收件箱清理天数',
|
||||
mailUnknowLabel: "无收件人邮件清理天数",
|
||||
addressUnActiveLabel: "未激活地址清理天数",
|
||||
sendBoxLabel: "发件箱清理天数",
|
||||
autoCleanup: "自动清理",
|
||||
cleanupSuccess: "清理成功",
|
||||
cleanupNow: "立即清理",
|
||||
save: "保存",
|
||||
cronTip: "启用定时清理, 需在 worker 配置 [crons] 参数, 请参考文档",
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -93,6 +92,9 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card>
|
||||
<n-alert :show-icon="false">
|
||||
<span>{{ t('cronTip') }}</span>
|
||||
</n-alert>
|
||||
<n-form :model="cleanupModel">
|
||||
<n-form-item-row :label="t('mailBoxLabel')">
|
||||
<n-checkbox v-model:checked="cleanupModel.enableMailsAutoCleanup">
|
||||
@@ -118,19 +120,7 @@ onMounted(async () => {
|
||||
{{ t('cleanupNow') }}
|
||||
</n-button>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('addressUnActiveLabel')">
|
||||
<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-form-item-row :label="t('sendBoxLabel')">
|
||||
<n-checkbox v-model:checked="cleanupModel.enableSendBoxAutoCleanup">
|
||||
{{ t('autoCleanup') }}
|
||||
</n-checkbox>
|
||||
@@ -162,6 +152,10 @@ onMounted(async () => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
margin: 10px;
|
||||
|
||||
@@ -1,153 +1,42 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import SendBox from '../../components/SendBox.vue';
|
||||
|
||||
const { localeCache, settings, adminAuth, adminSendBoxTabAddress } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const { adminSendBoxTabAddress } = useGlobalState()
|
||||
|
||||
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',
|
||||
query: 'Query',
|
||||
itemCount: 'itemCount',
|
||||
view: 'View',
|
||||
queryTip: 'Please input address to query, leave blank to query all',
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
success: '成功',
|
||||
to_mail: '收件人邮箱',
|
||||
subject: '主题',
|
||||
created_at: '创建时间',
|
||||
action: '操作',
|
||||
query: '查询',
|
||||
itemCount: '总数',
|
||||
view: '查看',
|
||||
queryTip: '请输入地址查询, 留空则查询所有',
|
||||
}
|
||||
}
|
||||
});
|
||||
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(
|
||||
`/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 fetchData = async (limit, offset) => {
|
||||
return await api.fetch(
|
||||
`/admin/sendbox?limit=${limit}&offset=${offset}`
|
||||
+ (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (!adminAuth.value) {
|
||||
showAdminAuth.value = true;
|
||||
return;
|
||||
}
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="settings.address">
|
||||
<n-modal v-model:show="showModal" preset="dialog">
|
||||
<pre>{{ curRow.raw }}</pre>
|
||||
</n-modal>
|
||||
<div>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="adminSendBoxTabAddress" />
|
||||
<n-button @click="fetchData" type="primary" ghost>
|
||||
<n-input v-model:value="adminSendBoxTabAddress" :placeholder="t('queryTip')" />
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<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>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" />
|
||||
<SendBox style="margin-top: 10px;" :fetchMailData="fetchData" :showEMailFrom="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,15 +5,15 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, loading } = useGlobalState()
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
address: 'Address',
|
||||
success: 'Success',
|
||||
is_enabled: 'Is Enabled',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
modify: 'Modify',
|
||||
@@ -28,6 +28,7 @@ const { t } = useI18n({
|
||||
zh: {
|
||||
address: '地址',
|
||||
success: '成功',
|
||||
is_enabled: '是否启用',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
modify: '修改',
|
||||
@@ -108,7 +109,7 @@ const columns = [
|
||||
key: "balance"
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
title: t('is_enabled'),
|
||||
key: "enabled",
|
||||
render(row) {
|
||||
return h('div', [
|
||||
@@ -124,7 +125,7 @@ const columns = [
|
||||
h(NButton,
|
||||
{
|
||||
type: 'success',
|
||||
ghost: true,
|
||||
tertiary: true,
|
||||
onClick: () => {
|
||||
showModal.value = true;
|
||||
curRow.value = row;
|
||||
@@ -170,7 +171,7 @@ onMounted(async () => {
|
||||
</n-modal>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="addressQuery" />
|
||||
<n-button @click="fetchData" type="primary" ghost>
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
|
||||
@@ -7,21 +7,20 @@ import { SendOutlined } from '@vicons/material'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, adminAuth } = useGlobalState()
|
||||
const { adminAuth } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
userCount: 'User Count',
|
||||
activeUser: '7 days Active User',
|
||||
userCount: 'Account Count',
|
||||
activeUser: '7 days Active Mail Account',
|
||||
mailCount: 'Mail Count',
|
||||
sendMailCount: 'Send Mail Count'
|
||||
},
|
||||
zh: {
|
||||
userCount: '用户总数',
|
||||
activeUser: '周活跃用户',
|
||||
userCount: '地址总数',
|
||||
activeUser: '周活跃邮箱地址',
|
||||
mailCount: '邮件总数',
|
||||
sendMailCount: '发送邮件总数'
|
||||
}
|
||||
|
||||
164
frontend/src/views/admin/Telegram.vue
Normal 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>
|
||||
288
frontend/src/views/admin/UserManagement.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NMenu, NButton, NBadge } from 'naive-ui';
|
||||
import { MenuFilled } from '@vicons/material'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { hashPassword } from '../../utils';
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
success: 'Success',
|
||||
user_email: 'User Email',
|
||||
address_count: 'Address Count',
|
||||
created_at: 'Created At',
|
||||
actions: 'Actions',
|
||||
query: 'Query',
|
||||
itemCount: 'itemCount',
|
||||
deleteUser: 'Delete User',
|
||||
delete: 'Delete',
|
||||
deleteUserTip: 'Are you sure you want to delete this user?',
|
||||
resetPassword: 'Reset Password',
|
||||
pleaseInput: 'Please input complete information',
|
||||
createUser: 'Create User',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
user_email: '用户邮箱',
|
||||
address_count: '地址数量',
|
||||
created_at: '创建时间',
|
||||
actions: '操作',
|
||||
query: '查询',
|
||||
itemCount: '总数',
|
||||
deleteUser: '删除用户',
|
||||
delete: '删除',
|
||||
deleteUserTip: '确定要删除此用户吗?',
|
||||
resetPassword: '重置密码',
|
||||
pleaseInput: '请输入完整信息',
|
||||
createUser: '创建用户',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
}
|
||||
}
|
||||
});
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const userQuery = ref('')
|
||||
const showResetPassword = ref(false)
|
||||
const newResetPassword = ref('')
|
||||
const showDeleteUser = ref(false)
|
||||
const curUserId = ref(0)
|
||||
const showCreateUser = ref(false)
|
||||
const user = ref({
|
||||
email: "",
|
||||
password: ""
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const { results, count: userCount } = await api.fetch(
|
||||
`/admin/users`
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (userQuery.value ? `&query=${userQuery.value}` : '')
|
||||
);
|
||||
data.value = results;
|
||||
if (userCount > 0) {
|
||||
count.value = userCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async () => {
|
||||
if (!newResetPassword.value) {
|
||||
message.error(t('pleaseInput'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.fetch(`/admin/users/${curUserId.value}/reset_password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
password: await hashPassword(newResetPassword.value)
|
||||
})
|
||||
});
|
||||
message.success(t('success'));
|
||||
showResetPassword.value = false;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const createUser = async () => {
|
||||
if (!user.value.email || !user.value.password) {
|
||||
message.error(t('pleaseInput'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.fetch(`/admin/users`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
password: await hashPassword(user.value.password)
|
||||
})
|
||||
});
|
||||
message.success(t('success'));
|
||||
await fetchData();
|
||||
showCreateUser.value = false;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUser = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/users/${curUserId.value}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
message.success(t('success'));
|
||||
showDeleteUser.value = false;
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
},
|
||||
{
|
||||
title: t('user_email'),
|
||||
key: "user_email"
|
||||
},
|
||||
{
|
||||
title: t('address_count'),
|
||||
key: "address_count",
|
||||
render(row) {
|
||||
return h(NBadge, {
|
||||
value: row.address_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NMenu, {
|
||||
mode: "horizontal",
|
||||
options: [
|
||||
{
|
||||
label: t('actions'),
|
||||
icon: () => h(MenuFilled),
|
||||
key: "action",
|
||||
children: [
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curUserId.value = row.id;
|
||||
newResetPassword.value = '';
|
||||
showResetPassword.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('resetPassword') }
|
||||
),
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curUserId.value = row.id;
|
||||
user.value.email = '';
|
||||
user.value.password = '';
|
||||
showDeleteUser.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('delete') }
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="margin-top: 10px;">
|
||||
<n-modal v-model:show="showCreateUser" preset="dialog" :title="t('createUser')">
|
||||
<n-form>
|
||||
<n-form-item-row :label="t('email')" required>
|
||||
<n-input v-model:value="user.email" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="createUser" size="small" tertiary type="primary">
|
||||
{{ t('createUser') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="newResetPassword" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="primary">
|
||||
{{ t('resetPassword') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDeleteUser" preset="dialog" :title="t('deleteUser')">
|
||||
<p>{{ t('deleteUserTip') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="deleteUser" size="small" tertiary type="error">
|
||||
{{ t('deleteUser') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="userQuery" />
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<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="showCreateUser = true" size="small" tertiary type="primary"
|
||||
style="margin-left: 10px">
|
||||
{{ t('createUser') }}
|
||||
</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>
|
||||
129
frontend/src/views/admin/UserSettings.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
save: 'Save',
|
||||
successTip: 'Save Success',
|
||||
enable: 'Enable',
|
||||
enableUserRegister: 'Allow User Register',
|
||||
enableMailVerify: 'Enable Mail Verify (Send address must be an address in the system with a balance and can send mail normally)',
|
||||
verifyMailSender: 'Verify Mail Sender',
|
||||
enableMailAllowList: 'Enable Mail Address Allow List(Manually enterable)',
|
||||
mailAllowList: 'Mail Address Allow List',
|
||||
maxAddressCount: 'Maximum number of email addresses that can be binded',
|
||||
},
|
||||
zh: {
|
||||
save: '保存',
|
||||
successTip: '保存成功',
|
||||
enable: '启用',
|
||||
enableUserRegister: "允许用户注册",
|
||||
enableMailVerify: '启用邮件验证(发送地址必须是系统中能有余额且能正常发送邮件的地址)',
|
||||
verifyMailSender: '验证邮件发送地址',
|
||||
enableMailAllowList: '启用邮件地址白名单(可手动输入)',
|
||||
mailAllowList: '邮件地址白名单',
|
||||
maxAddressCount: '可绑定最大邮箱地址数量',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const commonMail = [
|
||||
"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com",
|
||||
"icloud.com", "yahoo.com", "foxmail.com"
|
||||
]
|
||||
|
||||
const mailAllowOptions = commonMail.map((item) => {
|
||||
return { label: item, value: item }
|
||||
})
|
||||
|
||||
const userSettings = ref({
|
||||
enable: false,
|
||||
enableMailVerify: false,
|
||||
verifyMailSender: "",
|
||||
enableMailAllowList: false,
|
||||
mailAllowList: commonMail,
|
||||
maxAddressCount: 5,
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/user_settings`)
|
||||
Object.assign(userSettings.value, res)
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/user_settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userSettings.value)
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card style="max-width: 600px;">
|
||||
<n-form :model="userSettings">
|
||||
<n-form-item-row :label="t('enableUserRegister')">
|
||||
<n-checkbox v-model:checked="userSettings.enable" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableMailVerify')">
|
||||
<n-input-group>
|
||||
<n-checkbox v-model:checked="userSettings.enableMailVerify" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-input v-model:value="userSettings.verifyMailSender" style="width: 80%;"
|
||||
:placeholder="t('verifyMailSender')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableMailAllowList')">
|
||||
<n-input-group>
|
||||
<n-checkbox v-model:checked="userSettings.enableMailAllowList" style="width: 20%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-select v-model:value="userSettings.mailAllowList" filterable multiple tag style="width: 80%;"
|
||||
:options="mailAllowOptions" :placeholder="t('mailAllowList')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('maxAddressCount')">
|
||||
<n-input-group>
|
||||
<n-input-number v-model:value="userSettings.maxAddressCount"
|
||||
:placeholder="t('maxAddressCount')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-button @click="save" type="primary" block :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
84
frontend/src/views/admin/Webhook.vue
Normal 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>
|
||||
44
frontend/src/views/common/About.vue
Normal 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>
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../../store'
|
||||
const { localeCache, openSettings } = useGlobalState()
|
||||
const { openSettings } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
adminContact: 'If you need help, please contact the administrator ({msg})',
|
||||
@@ -17,7 +16,7 @@ const { t } = useI18n({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-alert v-if="openSettings.adminContact" type="info" show-icon>
|
||||
<n-alert v-if="openSettings.adminContact" :show-icon="false">
|
||||
<span>{{ t('adminContact', { msg: openSettings.adminContact }) }}</span>
|
||||
</n-alert>
|
||||
</template>
|
||||
83
frontend/src/views/common/Appearance.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useIsMobile } from '../../utils/composables'
|
||||
import { useGlobalState } from '../../store'
|
||||
|
||||
const {
|
||||
mailboxSplitSize, useIframeShowMail, preferShowTextMail,
|
||||
globalTabplacement, useSideMargin
|
||||
} = useGlobalState()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
mailboxSplitSize: 'Mailbox Split Size',
|
||||
useIframeShowMail: 'Use iframe Show HTML Mail',
|
||||
preferShowTextMail: 'Display text Mail by default',
|
||||
useSideMargin: 'Turn on the side margins on the left and right sides of the page',
|
||||
globalTabplacement: 'Global Tab Placement',
|
||||
left: 'left',
|
||||
top: 'top',
|
||||
right: 'right',
|
||||
bottom: 'bottom',
|
||||
},
|
||||
zh: {
|
||||
mailboxSplitSize: '邮箱界面分栏大小',
|
||||
preferShowTextMail: '默认以文本显示邮件',
|
||||
useIframeShowMail: '使用iframe显示HTML邮件',
|
||||
globalTabplacement: '全局选项卡位置',
|
||||
useSideMargin: '开启页面左右两侧侧边距',
|
||||
left: '左侧',
|
||||
top: '顶部',
|
||||
right: '右侧',
|
||||
bottom: '底部',
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card>
|
||||
<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="{
|
||||
0.25: '0.25',
|
||||
0.5: '0.5',
|
||||
0.75: '0.75'
|
||||
}" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('preferShowTextMail')">
|
||||
<n-switch v-model:value="preferShowTextMail" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('useIframeShowMail')">
|
||||
<n-switch v-model:value="useIframeShowMail" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="!isMobile" :label="t('useSideMargin')">
|
||||
<n-switch v-model:value="useSideMargin" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('globalTabplacement')">
|
||||
<n-radio-group v-model:value="globalTabplacement">
|
||||
<n-radio-button value="top" :label="t('top')" />
|
||||
<n-radio-button value="left" :label="t('left')" />
|
||||
<n-radio-button value="right" :label="t('right')" />
|
||||
<n-radio-button value="bottom" :label="t('bottom')" />
|
||||
</n-radio-group>
|
||||
</n-form-item-row>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -1,63 +1,102 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AdminContact from './admin/AdminContact.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
|
||||
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
import Turnstile from '../../components/Turnstile.vue'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
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
|
||||
},
|
||||
})
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
jwt, localeCache, loading, openSettings, showPassword
|
||||
jwt, loading, openSettings,
|
||||
showAddressCredential, userSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const tabValue = ref('signin')
|
||||
const password = ref('')
|
||||
const credential = ref('')
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const cfToken = ref("")
|
||||
|
||||
const login = async () => {
|
||||
if (!password.value) {
|
||||
message.error(t('passwordInput'));
|
||||
if (!credential.value) {
|
||||
message.error(t('credentialInput'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
jwt.value = password.value;
|
||||
await api.getSettings()
|
||||
location.reload()
|
||||
jwt.value = credential.value;
|
||||
await api.getSettings();
|
||||
try {
|
||||
await props.bindUserAddress();
|
||||
} catch (error) {
|
||||
message.error(`${t('bindUserAddressError')}: ${error.message}`);
|
||||
}
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
login: 'Login',
|
||||
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
|
||||
getNewEmail: 'Get New Email',
|
||||
getNewEmail: 'Create New Email',
|
||||
getNewEmailTip1: 'Please input the email you want to use. only allow ., a-z, A-Z and 0-9',
|
||||
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
|
||||
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
|
||||
password: 'Password',
|
||||
credential: 'Email Address Credential',
|
||||
ok: 'OK',
|
||||
generateName: 'Generate Fake Name',
|
||||
help: 'Help',
|
||||
passwordInput: 'Please input the password',
|
||||
credentialInput: 'Please input the Mail Address Credential',
|
||||
bindUserInfo: 'Logged in user, login without binding email or create new email address will bind to current user',
|
||||
bindUserAddressError: 'Error when bind email address to user',
|
||||
},
|
||||
zh: {
|
||||
login: '登录',
|
||||
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
|
||||
getNewEmail: '注册新邮箱',
|
||||
pleaseGetNewEmail: '请"登录"或点击 "注册新邮箱" 按钮来获取一个新的邮箱地址',
|
||||
getNewEmail: '创建新邮箱',
|
||||
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
|
||||
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
|
||||
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
|
||||
password: '密码',
|
||||
credential: '邮箱地址凭据',
|
||||
ok: '确定',
|
||||
generateName: '生成随机名字',
|
||||
help: '帮助',
|
||||
passwordInput: '请输入密码',
|
||||
credentialInput: '请输入邮箱地址凭据',
|
||||
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
|
||||
bindUserAddressError: '绑定邮箱地址到用户时错误',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -67,9 +106,10 @@ const generateName = async () => {
|
||||
try {
|
||||
generateNameLoading.value = true;
|
||||
const { faker } = await import('https://esm.sh/@faker-js/faker');
|
||||
emailName.value = faker.person
|
||||
.fullName()
|
||||
emailName.value = faker.internet.email()
|
||||
.split('@')[0]
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/[^a-zA-Z0-9.]/g, '')
|
||||
.toLowerCase();
|
||||
} catch (error) {
|
||||
@@ -81,37 +121,55 @@ const generateName = async () => {
|
||||
|
||||
const newEmail = async () => {
|
||||
try {
|
||||
const res = await api.fetch(
|
||||
`/api/new_address`
|
||||
+ `?name=${emailName.value || ''}`
|
||||
+ `&domain=${emailDomain.value || ''}`
|
||||
const res = await props.newAddressPath(
|
||||
emailName.value,
|
||||
emailDomain.value,
|
||||
cfToken.value
|
||||
);
|
||||
jwt.value = res["jwt"];
|
||||
await api.getSettings();
|
||||
showPassword.value = true;
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
showAddressCredential.value = true;
|
||||
try {
|
||||
await props.bindUserAddress();
|
||||
} catch (error) {
|
||||
message.error(`${t('bindUserAddressError')}: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
|
||||
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
|
||||
await api.getOpenSettings();
|
||||
}
|
||||
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0]?.value : "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-alert v-if="userSettings.user_email" :show-icon="false" closable>
|
||||
<span>{{ t('bindUserInfo') }}</span>
|
||||
</n-alert>
|
||||
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
|
||||
<n-tab-pane name="signin" :tab="t('login')">
|
||||
<n-form>
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="password" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
<n-form-item-row :label="t('credential')" required>
|
||||
<n-input v-model:value="credential" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
</n-form-item-row>
|
||||
<n-button @click="login" :loading="loading" type="primary" block secondary strong>
|
||||
<template #icon>
|
||||
<n-icon :component="EmailOutlined" />
|
||||
</template>
|
||||
{{ t('login') }}
|
||||
</n-button>
|
||||
<n-button v-if="openSettings.enableUserCreateEmail" @click="tabValue = 'register'" block secondary
|
||||
strong>
|
||||
<template #icon>
|
||||
<n-icon :component="NewLabelOutlined" />
|
||||
</template>
|
||||
{{ t('getNewEmail') }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
@@ -136,14 +194,18 @@ onMounted(async () => {
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
|
||||
{{ t('ok') }}
|
||||
<template #icon>
|
||||
<n-icon :component="NewLabelOutlined" />
|
||||
</template>
|
||||
{{ t('getNewEmail') }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="help" :tab="t('help')">
|
||||
<n-alert type="info" show-icon>
|
||||
<n-alert :show-icon="false">
|
||||
<span>{{ t('pleaseGetNewEmail') }}</span>
|
||||
</n-alert>
|
||||
<AdminContact />
|
||||
@@ -163,4 +225,8 @@ onMounted(async () => {
|
||||
.n-form .n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.n-form {
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
@@ -5,34 +5,31 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import Appearance from '../common/Appearance.vue'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
|
||||
const {
|
||||
jwt, localeCache, settings, showPassword, mailboxSplitSize, useIframeShowMail
|
||||
jwt, settings, showAddressCredential, loading
|
||||
} = useGlobalState()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const showLogout = ref(false)
|
||||
const showDelteAccount = ref(false)
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
mailboxSplitSize: 'Mailbox Split Size',
|
||||
useIframeShowMail: 'Use iframe Show Mail',
|
||||
logout: "Logout",
|
||||
delteAccount: "Delete Account",
|
||||
showPassword: 'Show Password',
|
||||
showAddressCredential: 'Show Address Credential',
|
||||
logoutConfirm: 'Are you sure to logout?',
|
||||
delteAccount: "Delete Account",
|
||||
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
|
||||
},
|
||||
zh: {
|
||||
mailboxSplitSize: '邮箱界面分栏大小',
|
||||
useIframeShowMail: '使用iframe显示邮件',
|
||||
logout: '退出登录',
|
||||
delteAccount: "删除账户",
|
||||
showPassword: '查看密码',
|
||||
showAddressCredential: '查看邮箱地址凭证',
|
||||
logoutConfirm: '确定要退出登录吗?',
|
||||
delteAccount: "删除账户",
|
||||
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
|
||||
@@ -42,7 +39,7 @@ const { t } = useI18n({
|
||||
|
||||
const logout = async () => {
|
||||
jwt.value = '';
|
||||
await router.push('/')
|
||||
await router.push(getRouterPathWithLang("/", locale.value))
|
||||
location.reload()
|
||||
}
|
||||
|
||||
@@ -52,7 +49,7 @@ const deleteAccount = async () => {
|
||||
method: 'DELETE'
|
||||
});
|
||||
jwt.value = '';
|
||||
await router.push('/')
|
||||
await router.push(getRouterPathWithLang("/", locale.value))
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
@@ -63,20 +60,9 @@ const deleteAccount = async () => {
|
||||
<template>
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card>
|
||||
<n-card>
|
||||
<n-form-item-row :label="t('mailboxSplitSize')">
|
||||
<n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{
|
||||
0.25: '0.25',
|
||||
0.5: '0.5',
|
||||
0.75: '0.75'
|
||||
}" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('useIframeShowMail')">
|
||||
<n-switch v-model:value="useIframeShowMail" :round="false" />
|
||||
</n-form-item-row>
|
||||
</n-card>
|
||||
<n-button @click="showPassword = true" type="primary" secondary block strong>
|
||||
{{ t('showPassword') }}
|
||||
<Appearance />
|
||||
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
|
||||
{{ t('showAddressCredential') }}
|
||||
</n-button>
|
||||
<n-button @click="showLogout = true" secondary block strong>
|
||||
{{ t('logout') }}
|
||||
157
frontend/src/views/index/AddressBar.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
import useClipboard from 'vue-clipboard3'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Copy, User, ExchangeAlt } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import Login from '../common/Login.vue'
|
||||
import AddressManagement from '../user/AddressManagement.vue'
|
||||
import TelegramAddress from './TelegramAddress.vue'
|
||||
import LocalAddress from './LocalAddress.vue'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
|
||||
const { toClipboard } = useClipboard()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
jwt, settings, showAddressCredential, userJwt,
|
||||
isTelegram
|
||||
} = useGlobalState()
|
||||
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
addressManage: 'Address Manage',
|
||||
changeAddress: 'Change Address',
|
||||
ok: 'OK',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
fetchAddressError: 'Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.',
|
||||
addressCredential: 'Mail Address Credential',
|
||||
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
|
||||
userLogin: 'User Login',
|
||||
},
|
||||
zh: {
|
||||
addressManage: '地址管理',
|
||||
changeAddress: '更换地址',
|
||||
ok: '确定',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
fetchAddressError: '邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
userLogin: '用户登录',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const showChangeAddress = ref(false)
|
||||
const showTelegramChangeAddress = ref(false)
|
||||
const showLocalAddress = ref(false)
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await toClipboard(settings.value.address)
|
||||
message.success(t('copied'));
|
||||
} catch (e) {
|
||||
message.error(e.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const onUserLogin = async () => {
|
||||
await router.push(getRouterPathWithLang("/user", locale.value))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-card v-if="!settings.fetched">
|
||||
<n-skeleton style="height: 50vh" />
|
||||
</n-card>
|
||||
<div v-else-if="settings.address">
|
||||
<n-alert type="info" :show-icon="false">
|
||||
<span>
|
||||
<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-icon :component="Copy" /> {{ t('copy') }}
|
||||
</n-button>
|
||||
</span>
|
||||
</n-alert>
|
||||
</div>
|
||||
<div v-else-if="isTelegram">
|
||||
<TelegramAddress />
|
||||
</div>
|
||||
<div v-else class="center">
|
||||
<n-card style="max-width: 600px;">
|
||||
<n-alert v-if="jwt" type="warning" :show-icon="false" closable>
|
||||
<span>{{ t('fetchAddressError') }}</span>
|
||||
</n-alert>
|
||||
<Login />
|
||||
<n-divider />
|
||||
<n-button @click="onUserLogin" type="primary" block secondary strong>
|
||||
<template #icon>
|
||||
<n-icon :component="User" />
|
||||
</template>
|
||||
{{ t('userLogin') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
<n-modal v-model:show="showTelegramChangeAddress" preset="card" :title="t('changeAddress')">
|
||||
<TelegramAddress />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showChangeAddress" preset="card" :title="t('changeAddress')">
|
||||
<AddressManagement />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showLocalAddress" preset="card" :title="t('changeAddress')">
|
||||
<LocalAddress />
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
|
||||
<span>
|
||||
<p>{{ t("addressCredentialTip") }}</p>
|
||||
</span>
|
||||
<n-card>
|
||||
<b>{{ jwt }}</b>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-alert {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.n-card {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
91
frontend/src/views/index/Attachment.vue
Normal 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>
|
||||
159
frontend/src/views/index/LocalAddress.vue
Normal 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>
|
||||
@@ -3,18 +3,17 @@ import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import AdminContact from '../admin/AdminContact.vue'
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import router from '../../router'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
|
||||
|
||||
const { settings, sendMailModel } = useGlobalState()
|
||||
const { settings, sendMailModel, indexTab } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: 'zh',
|
||||
@@ -91,7 +90,7 @@ const send = async () => {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
message.success(t("successSend"));
|
||||
router.push('/sendbox');
|
||||
indexTab.value = 'sendbox'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,15 +144,16 @@ onMounted(async () => {
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card>
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<n-alert type="warning" show-icon>
|
||||
<n-alert type="warning" :show-icon="false">
|
||||
{{ t('requestAccessTip') }}
|
||||
<n-button type="primary" ghost @click="requestAccess">{{ t('requestAccess') }}</n-button>
|
||||
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
|
||||
}}</n-button>
|
||||
</n-alert>
|
||||
<br />
|
||||
<AdminContact />
|
||||
</div>
|
||||
<div v-else>
|
||||
<n-alert type="info" show-icon>
|
||||
<n-alert type="info" :show-icon="false">
|
||||
{{ t('send_balance') }}: {{ settings.send_balance }}
|
||||
</n-alert>
|
||||
<div class="right">
|
||||
158
frontend/src/views/index/TelegramAddress.vue
Normal 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>
|
||||
129
frontend/src/views/index/Webhook.vue
Normal 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>
|
||||
@@ -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">
|
||||
<pre>{{ 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" ghost>
|
||||
{{ 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>
|
||||
70
frontend/src/views/telegram/Mail.vue
Normal 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>
|
||||
170
frontend/src/views/user/AddressManagement.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router';
|
||||
import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
|
||||
const { jwt } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const { locale, t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
success: 'success',
|
||||
name: 'Name',
|
||||
mail_count: 'Mail Count',
|
||||
send_count: 'Send Count',
|
||||
actions: 'Actions',
|
||||
changeMailAddress: 'Change Mail Address',
|
||||
unbindAddress: 'Unbind Address',
|
||||
unbindAddressTip: 'Before unbinding, please switch to this email address and save the email address credential.',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
name: '名称',
|
||||
mail_count: '邮件数量',
|
||||
send_count: '发送数量',
|
||||
actions: '操作',
|
||||
changeMailAddress: '切换邮箱地址',
|
||||
unbindAddress: '解绑地址',
|
||||
unbindAddressTip: '解绑前请切换到此邮箱地址并保存邮箱地址凭证。',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const data = ref([])
|
||||
|
||||
const changeMailAddress = async (address_id) => {
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/bind_address_jwt/${address_id}`);
|
||||
message.success(t('changeMailAddress') + " " + t('success'));
|
||||
if (!res.jwt) {
|
||||
message.error("jwt not found");
|
||||
return;
|
||||
}
|
||||
jwt.value = res.jwt;
|
||||
await router.push(getRouterPathWithLang("/", locale.value))
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const unbindAddress = async (address_id) => {
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/unbind_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ address_id })
|
||||
});
|
||||
message.success(t('unbindAddress') + " " + t('success'));
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const { results, count: addressCount } = await api.fetch(
|
||||
`/user_api/bind_address`
|
||||
);
|
||||
data.value = results;
|
||||
if (addressCount > 0) {
|
||||
count.value = addressCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
},
|
||||
{
|
||||
title: t('name'),
|
||||
key: "name"
|
||||
},
|
||||
{
|
||||
title: t('mail_count'),
|
||||
key: "mail_count",
|
||||
render(row) {
|
||||
return h(NBadge, {
|
||||
value: row.mail_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('send_count'),
|
||||
key: "send_count",
|
||||
render(row) {
|
||||
return h(NBadge, {
|
||||
value: row.send_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => changeMailAddress(row.id)
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
},
|
||||
{ default: () => t('changeMailAddress') }
|
||||
),
|
||||
default: () => `${t('changeMailAddress')}?`
|
||||
}
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => unbindAddress(row.id)
|
||||
},
|
||||
{
|
||||
trigger: () => h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "error",
|
||||
},
|
||||
{ default: () => t('unbindAddress') }
|
||||
),
|
||||
default: () => t('unbindAddressTip')
|
||||
}
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" />
|
||||
</div>
|
||||
</template>
|
||||
45
frontend/src/views/user/BindAddress.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import Login from '../common/Login.vue'
|
||||
|
||||
const { userJwt, userSettings, } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
logout: 'Logout',
|
||||
},
|
||||
zh: {
|
||||
logout: '退出登录',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="userSettings.user_email">
|
||||
<n-card style="max-width: 600px;">
|
||||
<Login />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
75
frontend/src/views/user/UserBar.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import UserLogin from './UserLogin.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
userSettings, userJwt, userOpenSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
currentUser: 'Current Login User',
|
||||
fetchUserSettingsError: 'Login password is invalid or account not exist, it may be network connection issue, please try again later.',
|
||||
},
|
||||
zh: {
|
||||
currentUser: '当前登录用户',
|
||||
fetchUserSettingsError: '登录信息已过期或账号不存在,也可能是网络连接异常,请稍后再尝试。',
|
||||
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getUserOpenSettings(message);
|
||||
await api.getUserSettings(message);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-card v-if="!userSettings.fetched">
|
||||
<n-skeleton style="height: 50vh" />
|
||||
</n-card>
|
||||
<div v-else-if="userSettings.user_email">
|
||||
<n-alert type="success" :show-icon="false">
|
||||
<span>
|
||||
<b>{{ t('currentUser') }} <b>{{ userSettings.user_email }}</b></b>
|
||||
</span>
|
||||
</n-alert>
|
||||
</div>
|
||||
<div v-else class="center">
|
||||
<n-card style="max-width: 600px;">
|
||||
<n-alert v-if="userJwt" type="warning" :show-icon="false" closable>
|
||||
<span>{{ t('fetchUserSettingsError') }}</span>
|
||||
</n-alert>
|
||||
<UserLogin />
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-alert {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
247
frontend/src/views/user/UserLogin.vue
Normal file
@@ -0,0 +1,247 @@
|
||||
<script setup>
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { api } from '../../api';
|
||||
import { useGlobalState } from '../../store'
|
||||
import { hashPassword } from '../../utils';
|
||||
|
||||
import Turnstile from '../../components/Turnstile.vue';
|
||||
|
||||
const { userJwt, userTab, userOpenSettings } = useGlobalState()
|
||||
const message = useMessage();
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
verifyCode: 'Verification Code',
|
||||
verifyCodeSent: 'Verification Code Sent, expires in {timeout} seconds',
|
||||
waitforVerifyCode: 'Wait for {timeout} seconds',
|
||||
sendVerificationCode: 'Send Verification Code',
|
||||
forgotPassword: 'Forgot Password',
|
||||
cannotForgotPassword: 'Mail verification is disabled or register is disabled, cannot reset password, please contact administrator',
|
||||
resetPassword: 'Reset Password',
|
||||
pleaseInput: 'Please input email and password',
|
||||
pleaseInputEmail: 'Please input email',
|
||||
pleaseInputCode: 'Please input code',
|
||||
pleaseCompleteTurnstile: 'Please complete turnstile',
|
||||
pleaseLogin: 'Please login',
|
||||
},
|
||||
zh: {
|
||||
login: '登录',
|
||||
register: '注册',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
verifyCode: '验证码',
|
||||
sendVerificationCode: '发送验证码',
|
||||
verifyCodeSent: '验证码已发送, {timeout} 秒后失效',
|
||||
waitforVerifyCode: '等待{timeout}秒',
|
||||
forgotPassword: '忘记密码',
|
||||
cannotForgotPassword: '未开启邮箱验证或未开启注册功能,无法重置密码,请联系管理员',
|
||||
resetPassword: '重置密码',
|
||||
pleaseInput: '请输入邮箱和密码',
|
||||
pleaseInputEmail: '请输入邮箱',
|
||||
pleaseInputCode: '请输入验证码',
|
||||
pleaseCompleteTurnstile: '请完成人机验证',
|
||||
pleaseLogin: '请登录',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const tabValue = ref("signin");
|
||||
const showModal = ref(false);
|
||||
const user = ref({
|
||||
email: "",
|
||||
password: "",
|
||||
code: ""
|
||||
});
|
||||
const cfToken = ref("")
|
||||
|
||||
const emailLogin = async () => {
|
||||
if (!user.value.email || !user.value.password) {
|
||||
message.error(t('pleaseInput'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/login`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
// hash password
|
||||
password: await hashPassword(user.value.password)
|
||||
})
|
||||
});
|
||||
userJwt.value = res.jwt;
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
message.error(error.message || "login failed");
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCodeExpire = ref(0);
|
||||
const verifyCodeTimeout = ref(0);
|
||||
|
||||
const getVerifyCodeTimeout = () => {
|
||||
if (!verifyCodeExpire.value || verifyCodeExpire.value < new Date().getTime()) return 0;
|
||||
return Math.round((verifyCodeExpire.value - new Date().getTime()) / 1000);
|
||||
};
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (!user.value.email) {
|
||||
message.error(t('pleaseInputEmail'));
|
||||
return;
|
||||
}
|
||||
if (!cfToken.value && userOpenSettings.value.enableMailVerify) {
|
||||
message.error(t('pleaseCompleteTurnstile'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/verify_code`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
cf_token: cfToken.value
|
||||
})
|
||||
});
|
||||
if (res && res.expirationTtl) {
|
||||
message.success(t('verifyCodeSent', { timeout: res.expirationTtl }));
|
||||
verifyCodeExpire.value = new Date().getTime() + res.expirationTtl * 1000;
|
||||
const intervalId = setInterval(() => {
|
||||
verifyCodeTimeout.value = getVerifyCodeTimeout();
|
||||
if (verifyCodeTimeout.value <= 0) {
|
||||
clearInterval(intervalId);
|
||||
verifyCodeTimeout.value = 0;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "send verification code failed");
|
||||
}
|
||||
};
|
||||
|
||||
const emailSignup = async () => {
|
||||
if (!user.value.email || !user.value.password) {
|
||||
message.error(t('pleaseInput'));
|
||||
return;
|
||||
}
|
||||
if (!user.value.code && userOpenSettings.value.enableMailVerify) {
|
||||
message.error(t('pleaseInputCode'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/register`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
// hash password
|
||||
password: await hashPassword(user.value.password),
|
||||
code: user.value.code
|
||||
}),
|
||||
message: message
|
||||
});
|
||||
if (res) {
|
||||
tabValue.value = "signin";
|
||||
message.success(t('pleaseLogin'));
|
||||
}
|
||||
showModal.value = false;
|
||||
} catch (error) {
|
||||
message.error(error.message || "register failed");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
|
||||
<n-tab-pane name="signin" :tab="t('login')">
|
||||
<n-form>
|
||||
<n-form-item-row :label="t('email')" required>
|
||||
<n-input v-model:value="user.email" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<n-button @click="emailLogin" type="primary" block secondary strong>
|
||||
{{ t('login') }}
|
||||
</n-button>
|
||||
<n-button @click="showModal = true" type="info" quaternary size="tiny">
|
||||
{{ t('forgotPassword') }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="userOpenSettings.enable" name="signup" :tab="t('register')">
|
||||
<n-form>
|
||||
<n-form-item-row :label="t('email')" required>
|
||||
<n-input v-model:value="user.email" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile v-if="userOpenSettings.enableMailVerify" v-model:value="cfToken" />
|
||||
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="user.code" />
|
||||
<n-button @click="sendVerificationCode" style="margin-bottom: 0" type="primary" ghost
|
||||
:disabled="verifyCodeTimeout > 0">
|
||||
{{ verifyCodeTimeout > 0 ? t('waitforVerifyCode', { timeout: verifyCodeTimeout })
|
||||
: t('sendVerificationCode') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
<n-button @click="emailSignup" type="primary" block secondary strong>
|
||||
{{ t('register') }}
|
||||
</n-button>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<n-modal v-model:show="showModal" style="max-width: 600px;" preset="card" :title="t('forgotPassword')">
|
||||
<n-form v-if="userOpenSettings.enable && userOpenSettings.enableMailVerify">
|
||||
<n-form-item-row :label="t('email')" required>
|
||||
<n-input v-model:value="user.email" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<n-form-item-row :label="t('verifyCode')" required>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="user.code" />
|
||||
<n-button @click="sendVerificationCode" style="margin-bottom: 0" type="primary" ghost
|
||||
:disabled="verifyCodeTimeout > 0">
|
||||
{{ verifyCodeTimeout > 0 ? t('waitforVerifyCode', { timeout: verifyCodeTimeout })
|
||||
: t('sendVerificationCode') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-button @click="emailSignup" type="primary" block secondary strong>
|
||||
{{ t('resetPassword') }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
<n-alert v-else :show-icon="false">
|
||||
<span>
|
||||
{{ t('cannotForgotPassword') }}
|
||||
</span>
|
||||
</n-alert>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
82
frontend/src/views/user/UserSettings.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { userJwt, userSettings, } = useGlobalState()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const showLogout = ref(false)
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
logout: 'Logout',
|
||||
logoutConfirm: 'Are you sure you want to logout?',
|
||||
passordTip: 'The server will only receive the hash value of the password, and will not receive the plaintext password, so it cannot view or retrieve your password. If the administrator enables email verification, you can reset the password in incognito mode',
|
||||
},
|
||||
zh: {
|
||||
logout: '退出登录',
|
||||
logoutConfirm: '确定要退出登录吗?',
|
||||
passordTip: '服务器只会接收到密码的哈希值,不会接收到明文密码,因此无法查看或者找回您的密码, 如果管理员启用了邮件验证您可以在无痕模式重置密码',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const logout = async () => {
|
||||
userJwt.value = '';
|
||||
location.reload()
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="userSettings.user_email">
|
||||
<n-card>
|
||||
<n-alert :show-icon="false">
|
||||
<span>
|
||||
{{ t('passordTip') }}
|
||||
</span>
|
||||
</n-alert>
|
||||
<n-button @click="showLogout = true" secondary block strong>
|
||||
{{ t('logout') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
|
||||
<p>{{ t('logoutConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="logout" size="small" tertiary type="primary">
|
||||
{{ t('logout') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
13
frontend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"ESNext"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
}
|
||||
@@ -13,15 +13,6 @@ import topLevelAwait from "vite-plugin-top-level-await";
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: './dist',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('wangeditor')) {
|
||||
return 'vendor-wangeditor';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
@@ -71,5 +62,8 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': 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
@@ -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
|
||||
9
pages/functions/_middleware.js
Normal 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
@@ -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
@@ -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"
|
||||
21
smtp_proxy_server/config.py
Normal 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()
|
||||
@@ -7,6 +7,8 @@ services:
|
||||
container_name: "smtp_proxy_server"
|
||||
ports:
|
||||
- "8025:8025"
|
||||
- "11143:11143"
|
||||
environment:
|
||||
- proxy_url=https://temp-email-api.xxx.xxx
|
||||
- port=8025
|
||||
- imap_port=11143
|
||||
|
||||
@@ -4,4 +4,4 @@ WORKDIR /app
|
||||
COPY requirements.txt /requirements.txt
|
||||
RUN python3 -m pip install -r /requirements.txt
|
||||
COPY . /app
|
||||
ENTRYPOINT [ "python3", "server.py" ]
|
||||
ENTRYPOINT [ "python3", "main.py" ]
|
||||
|
||||
249
smtp_proxy_server/imap_server.py
Normal 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
@@ -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()
|
||||
10
smtp_proxy_server/models.py
Normal 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
|
||||
62
smtp_proxy_server/parse_email.py
Normal 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())
|
||||
@@ -1,3 +1,5 @@
|
||||
aiosmtpd==1.4.5
|
||||
aiosmtpd==1.4.6
|
||||
pydantic-settings==2.2.1
|
||||
requests==2.31.0
|
||||
requests==2.32.0
|
||||
twisted==24.3.0
|
||||
httpx==0.27.0
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import email
|
||||
import requests
|
||||
import httpx
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
from aiosmtpd.controller import Controller
|
||||
from aiosmtpd.smtp import SMTP, Session, Envelope, AuthResult, LoginPassword
|
||||
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
)
|
||||
from config import settings
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
proxy_url: str = "http://localhost:8787"
|
||||
port: int = 8025
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
class CustomSMTPHandler:
|
||||
|
||||
def authenticator(self, server, session, envelope, mechanism, auth_data):
|
||||
@@ -51,20 +40,34 @@ class CustomSMTPHandler:
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
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"]:
|
||||
_logger.warning(f"Skipping {content_type}")
|
||||
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
|
||||
content_list.append({
|
||||
"type": content_type,
|
||||
"value": payload.decode()
|
||||
"value": value
|
||||
})
|
||||
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({
|
||||
"type": msg.get_content_type(),
|
||||
"value": msg.get_payload(decode=True).decode()
|
||||
"value": value
|
||||
})
|
||||
|
||||
if not content_list:
|
||||
@@ -87,6 +90,7 @@ class CustomSMTPHandler:
|
||||
_logger.info(f"Parsed mail from {from_name} to {to_mail_map}")
|
||||
# Send mail
|
||||
send_body = {
|
||||
"token": session.auth_data.password.decode(),
|
||||
"from_name": from_name,
|
||||
"to_name": to_mail_map.get(to_mail),
|
||||
"to_mail": to_mail,
|
||||
@@ -96,12 +100,11 @@ class CustomSMTPHandler:
|
||||
"is_html": body["type"] == "text/html",
|
||||
"content": body["value"],
|
||||
}
|
||||
_logger.info(f"Send mail {send_body}")
|
||||
_logger.info(f"Send mail {dict(send_body, token='***')}")
|
||||
try:
|
||||
res = requests.post(
|
||||
f"{settings.proxy_url}/api/send_mail",
|
||||
res = httpx.post(
|
||||
f"{settings.proxy_url}/external/api/send_mail",
|
||||
json=send_body, headers={
|
||||
"Authorization": f"Bearer {session.auth_data.password.decode()}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
@@ -118,7 +121,6 @@ class CustomSMTPHandler:
|
||||
return '250 OK'
|
||||
|
||||
|
||||
settings = Settings()
|
||||
handler = CustomSMTPHandler()
|
||||
server = Controller(
|
||||
handler,
|
||||
@@ -132,11 +134,11 @@ server = Controller(
|
||||
|
||||
|
||||
async def start():
|
||||
_logger.info(f"Starting server settings[{settings}]")
|
||||
_logger.info(f"Starting server on port {settings.port}")
|
||||
server.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def start_smtp_server():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
task = loop.create_task(start())
|
||||
@@ -145,3 +147,8 @@ if __name__ == "__main__":
|
||||
except KeyboardInterrupt:
|
||||
_logger.info("Got KeyboardInterrupt, stopping")
|
||||
server.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_logger.info(f"Starting server settings[{settings}]")
|
||||
start_smtp_server()
|
||||
@@ -96,7 +96,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
},
|
||||
{
|
||||
text: '通过命令行部署',
|
||||
collapsed: false,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: '命令行部署准备', link: 'cli/pre-requisite' },
|
||||
{ text: 'D1 数据库', link: 'cli/d1' },
|
||||
@@ -109,7 +109,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
},
|
||||
{
|
||||
text: '通过用户界面部署',
|
||||
collapsed: false,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'D1 数据库', link: 'ui/d1' },
|
||||
{ text: '配置 DKIM', link: 'dkim' },
|
||||
@@ -121,25 +121,29 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
},
|
||||
{
|
||||
text: '通过 Github Actions 部署',
|
||||
collapsed: false,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: '开发中', link: 'github-action' },
|
||||
{ text: '通过 Github Actions 部署', link: 'github-action' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '附加功能',
|
||||
collapsed: false,
|
||||
collapsed: true,
|
||||
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/mail-api' },
|
||||
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
|
||||
{ text: '配置 Telegram Bot', link: 'feature/telegram' },
|
||||
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '功能简介',
|
||||
collapsed: false,
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ text: 'Admin 控制台', link: 'feature/admin' },
|
||||
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },
|
||||
]
|
||||
},
|
||||
{ text: '参考', base: "/", link: 'reference' }
|
||||
|
||||
@@ -38,6 +38,8 @@ wrangler d1 execute dev --file=db/schema.sql
|
||||
# schema update, if you have initialized the database before this date, you can execute this command to update
|
||||
# wrangler d1 execute dev --file=db/2024-01-13-patch.sql
|
||||
# wrangler d1 execute dev --file=db/2024-04-03-patch.sql
|
||||
# create a namespace, and copy the output to wrangler.toml in the next step
|
||||
wrangler kv:namespace create DEV
|
||||
```
|
||||
|
||||

|
||||
@@ -58,7 +60,7 @@ pnpm run deploy
|
||||
|
||||
```toml
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.js"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2023-08-14"
|
||||
node_compat = true
|
||||
|
||||
@@ -66,7 +68,13 @@ node_compat = true
|
||||
# [triggers]
|
||||
# crons = [ "0 0 * * *" ]
|
||||
|
||||
# send mail by cf mail
|
||||
# send_email = [
|
||||
# { name = "SEND_MAIL" },
|
||||
# ]
|
||||
|
||||
[vars]
|
||||
# TITLE = "Custom Title" # The title of the site
|
||||
PREFIX = "tmp" # The mailbox name prefix to be processed
|
||||
# If you want your site to be private, uncomment below and change your password
|
||||
# PASSWORDS = ["123", "456"]
|
||||
@@ -83,19 +91,33 @@ ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
# Allow automatic replies to emails
|
||||
ENABLE_AUTO_REPLY = false
|
||||
# Allow webhook
|
||||
# ENABLE_WEBHOOK = true
|
||||
# Footer text
|
||||
# COPYRIGHT = "Dream Hunter"
|
||||
# default send balance, if not set, it will be 0
|
||||
# DEFAULT_SEND_BALANCE = 1
|
||||
# Turnstile verification configuration
|
||||
# CF_TURNSTILE_SITE_KEY = ""
|
||||
# CF_TURNSTILE_SECRET_KEY = ""
|
||||
# dkim config
|
||||
# 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
|
||||
# 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]]
|
||||
binding = "DB"
|
||||
database_name = "xxx" # D1 database name
|
||||
database_id = "xxx" # D1 database ID
|
||||
|
||||
# kv config for send email verification code
|
||||
# [[kv_namespaces]]
|
||||
# binding = "KV"
|
||||
# id = "xxxx"
|
||||
|
||||
# Create a new address current limiting configuration
|
||||
# [[unsafe.bindings]]
|
||||
# name = "RATE_LIMITER"
|
||||
|
||||
BIN
vitepress-docs/docs/public/feature/admin-user-management.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
vitepress-docs/docs/public/feature/admin-user-page.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
vitepress-docs/docs/public/feature/imap.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
vitepress-docs/docs/public/feature/s3-download.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
vitepress-docs/docs/public/feature/s3-save.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
vitepress-docs/docs/public/feature/telegram.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
vitepress-docs/docs/public/ui_install/worker-kv-bind.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
vitepress-docs/docs/public/ui_install/worker-kv.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
@@ -5,9 +5,11 @@
|
||||
## 初始化数据库
|
||||
|
||||
```bash
|
||||
cd worker
|
||||
cp wrangler.toml.template wrangler.toml
|
||||
# 创建 D1 并执行 schema.sql
|
||||
wrangler d1 create dev
|
||||
wrangler d1 execute dev --file=db/schema.sql
|
||||
wrangler d1 execute dev --file=../db/schema.sql
|
||||
```
|
||||
|
||||
创建完成后,我们在 cloudflare 的控制台可以看到 D1 数据库
|
||||
@@ -22,6 +24,7 @@ wrangler d1 execute dev --file=db/schema.sql
|
||||
找到需要执行的 `patch` 文件, 执行, 例如:
|
||||
|
||||
```bash
|
||||
wrangler d1 execute dev --file=db/2024-01-13-patch.sql
|
||||
wrangler d1 execute dev --file=db/2024-04-03-patch.sql
|
||||
cd worker
|
||||
wrangler d1 execute dev --file=../db/2024-01-13-patch.sql
|
||||
wrangler d1 execute dev --file=../db/2024-04-03-patch.sql
|
||||
```
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Cloudflare Pages 前端
|
||||
|
||||
::: warning
|
||||
下面两种方式选择一种即可
|
||||
:::
|
||||
|
||||
## 前后端分离部署
|
||||
|
||||
第一次部署会提示创建项目, `production` 分支请填写 `production`
|
||||
|
||||
```bash
|
||||
@@ -23,3 +29,19 @@ pnpm run deploy
|
||||
部署完成之后你可以在 Cloudflare 控制台看到你的项目, 可以为 `pages` 配置自定义域名
|
||||
|
||||

|
||||
|
||||
## 通过 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
|
||||
```
|
||||
|
||||
@@ -8,11 +8,23 @@ pnpm install
|
||||
cp wrangler.toml.template wrangler.toml
|
||||
```
|
||||
|
||||
## 创建 KV 缓存
|
||||
|
||||
> [!NOTE]
|
||||
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
|
||||
> 如果需要 Telegram Bot,需要创建 `KV` 缓存,不需要可跳过此步骤
|
||||
|
||||
通过命令行创建 KV 缓存,或者在 Cloudflare 控制台创建,然后复制对应配置到 `wrangler.toml` 文件中
|
||||
|
||||
```bash
|
||||
wrangler kv:namespace create DEV
|
||||
```
|
||||
|
||||
## 修改 `wrangler.toml` 配置文件
|
||||
|
||||
```toml
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.js"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2023-12-01"
|
||||
# 如果你想使用自定义域名,你需要添加 routes 配置
|
||||
# routes = [
|
||||
@@ -24,7 +36,13 @@ node_compat = true
|
||||
# [triggers]
|
||||
# crons = [ "0 0 * * *" ]
|
||||
|
||||
# 通过 Cloudflare 发送邮件
|
||||
# send_email = [
|
||||
# { name = "SEND_MAIL" },
|
||||
# ]
|
||||
|
||||
[vars]
|
||||
# TITLE = "Custom Title" # 自定义网站标题
|
||||
PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串
|
||||
# 如果你想要你的网站私有,取消下面的注释,并修改密码
|
||||
# PASSWORDS = ["123", "456"]
|
||||
@@ -41,13 +59,22 @@ ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
# 允许自动回复邮件
|
||||
ENABLE_AUTO_REPLY = false
|
||||
# 是否启用 webhook
|
||||
# ENABLE_WEBHOOK = true
|
||||
# 前端界面页脚文本
|
||||
# COPYRIGHT = "Dream Hunter"
|
||||
# 默认发送邮件余额,如果不设置,将为 0
|
||||
# DEFAULT_SEND_BALANCE = 1
|
||||
# Turnstile 人机验证配置
|
||||
# CF_TURNSTILE_SITE_KEY = ""
|
||||
# CF_TURNSTILE_SECRET_KEY = ""
|
||||
# dkim config
|
||||
# DKIM_SELECTOR = "mailchannels" # 参考 DKIM 部分 mailchannels._domainkey 的 mailchannels
|
||||
# DKIM_PRIVATE_KEY = "" # 参考 DKIM 部分 priv_key.txt 的内容
|
||||
# telegram bot 最多绑定邮箱数量
|
||||
# TG_MAX_ACCOUNTS = 5
|
||||
# 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址
|
||||
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
|
||||
|
||||
# D1 数据库的名称和 ID 可以在 cloudflare 控制台查看
|
||||
[[d1_databases]]
|
||||
@@ -55,6 +82,11 @@ binding = "DB"
|
||||
database_name = "xxx" # D1 数据库名称
|
||||
database_id = "xxx" # D1 数据库 ID
|
||||
|
||||
# kv config 用于用户注册发送邮件验证码,如果不启用用户注册或不启用注册验证,可以不配置
|
||||
# [[kv_namespaces]]
|
||||
# binding = "KV"
|
||||
# id = "xxxx"
|
||||
|
||||
# 新建地址限流配置 /api/new_address
|
||||
# [[unsafe.bindings]]
|
||||
# name = "RATE_LIMITER"
|
||||
@@ -64,6 +96,17 @@ database_id = "xxx" # D1 数据库 ID
|
||||
# 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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
`v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all`
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Admin 用户相关
|
||||
|
||||
默认不允许用户注册,可通过
|
||||
|
||||
## 用户管理页面
|
||||
|
||||

|
||||
|
||||
## 用户设置
|
||||
|
||||
此处开启用户登录,以及验证等配置
|
||||
|
||||

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

|
||||
|
||||
18
vitepress-docs/docs/zh/guide/feature/mail-api.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 查看邮件 API
|
||||
|
||||
## 通过 HTTP API 查看邮件
|
||||
|
||||
这是一个 `python` 的例子,使用 `requests` 库查看邮件。
|
||||
|
||||
```python
|
||||
limit = 10
|
||||
offset = 0
|
||||
res = requests.get(
|
||||
f"http://localhost:8787/api/mails?limit={limit}&offset={offset}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {你的JWT密码}",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
```
|
||||
34
vitepress-docs/docs/zh/guide/feature/s3-attachment.md
Normal 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
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
保存附件
|
||||
|
||||

|
||||
|
||||
下载附件
|
||||
|
||||

|
||||
@@ -17,7 +17,25 @@ send_body = {
|
||||
res = requests.post(
|
||||
"http://localhost:8787/api/send_mail",
|
||||
json=send_body, headers={
|
||||
"Authorization": f"Bearer {session.auth_data.password.decode()}",
|
||||
"Authorization": f"Bearer {你的JWT密码}",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
# 使用 body 验证
|
||||
send_body = {
|
||||
"token": "<你的JWT密码>",
|
||||
"from_name": "发件人名字",
|
||||
"to_name": "收件人名字",
|
||||
"to_mail": "收件人地址",
|
||||
"subject": "邮件主题",
|
||||
"is_html": False, # 根据内容设置是否为 HTML
|
||||
"content": "<邮件内容:html 或者 文本>",
|
||||
}
|
||||
res = requests.post(
|
||||
"http://localhost:8787/external/api/send_mail",
|
||||
json=send_body, headers={
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
38
vitepress-docs/docs/zh/guide/feature/telegram.md
Normal 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
|
||||
|
||||
- 可设置白名单用户
|
||||
- 点击`初始化`即可完成配置。
|
||||
- 点击`查看状态`,可以查看当前配置的状态。
|
||||
|
||||

|
||||
|
||||
## 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 的链接
|
||||
@@ -1,5 +1,22 @@
|
||||
# 通过 Github Actions 部署
|
||||
|
||||
::: warning
|
||||
开发中...
|
||||
有问题请通过 `Github Issues` 反馈,感谢。
|
||||
:::
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/dreamhunter2333/cloudflare_temp_email)
|
||||
|
||||
1. 点击按钮 fork 本仓库 或者直接 fork 本仓库
|
||||
|
||||
2. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production` 和 `Deploy Frontend`,点击 `enable workflow` 启用 `workflow`
|
||||
|
||||
3. 然后在仓库页面 `Settings` -> `Secrets and variables` -> `Actions` -> `Repository secrets`, 添加以下 `secrets`:
|
||||
|
||||
- `CLOUDFLARE_ACCOUNT_ID`: Cloudflare 账户 ID, [参考文档](https://developers.cloudflare.com/workers/wrangler/ci-cd/#cloudflare-account-id)
|
||||
- `CLOUDFLARE_API_TOKEN`: Cloudflare API Token, [参考文档](https://developers.cloudflare.com/workers/wrangler/ci-cd/#api-token)
|
||||
- `BACKEND_TOML`: 后端配置文件,[参考此处](/zh/guide/cli/worker.html#修改-wrangler-toml-配置文件)
|
||||
- `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) 创建
|
||||
- `TG_FRONTEND_NAME`: (可选) 你在 Cloudflare Pages 创建的项目名称,同 `FRONTEND_NAME`,如果需要 Telegram Mini App 功能,请填写
|
||||
|
||||
1. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production` 和 `Deploy Frontend`,点击 `Run workflow` 选择分支手动部署
|
||||
|
||||
@@ -38,3 +38,17 @@
|
||||
7. 点击 `Settings` -> `Variables`, 下拉找到 `D1 Database`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 D1 数据库,点击 `Deploy`
|
||||
|
||||

|
||||
|
||||
8. 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤,点击 `Workers & Pages` -> `KV` -> `Create Namespace`, 如图,点击 `Create Namespace`,然后在 `Settings` -> `Variables`, 下拉找到 `KV`, 点击 `Add Binding`, 名称如图,选择刚刚创建的 `KV` 缓存,点击 `Deploy`
|
||||
> [!NOTE]
|
||||
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
|
||||
|
||||

|
||||

|
||||
|
||||
9. Telegram Bot 配置
|
||||
|
||||
> [!NOTE]
|
||||
> 如果不需要 Telegram Bot, 可跳过此步骤
|
||||
|
||||
请先创建一个 Telegram Bot,然后获取 `token`,然后执行下面的命令,将 `token` 添加到 `Variables` 中, Name: `TELEGRAM_BOT_TOKEN`
|
||||
|
||||
1
worker/.gitignore
vendored
@@ -131,3 +131,4 @@ dist
|
||||
|
||||
.wrangler
|
||||
wrangler.toml
|
||||
.dev.vars
|
||||
|
||||
19
worker/eslint.config.js
Normal 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",
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -5,15 +5,31 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"lint": "eslint src",
|
||||
"deploy": "wrangler deploy --minify",
|
||||
"start": "wrangler dev",
|
||||
"build": "wrangler deploy src/worker.js --dry-run --outdir dist --minify"
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"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": {
|
||||
"hono": "^4.3.0",
|
||||
"mimetext": "^3.0.24"
|
||||
"@aws-sdk/client-s3": "^3.588.0",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
163
worker/patches/telegraf@4.16.3.patch
Normal 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;
|
||||