mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-09 16:52:41 +08:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98cd6d9fcc | ||
|
|
45783c7494 | ||
|
|
9bfded4d1d | ||
|
|
b7308587c6 | ||
|
|
1fa56dfe98 | ||
|
|
55b2603913 | ||
|
|
7738210b93 | ||
|
|
9d84eb0634 | ||
|
|
66a6d40499 | ||
|
|
41bed8b1db | ||
|
|
869bf99340 | ||
|
|
f63c4ebd9c | ||
|
|
26969bebb8 | ||
|
|
1d191a091a | ||
|
|
4d6c4e2d10 | ||
|
|
7f456078ea | ||
|
|
68c18a6153 | ||
|
|
2d01639ecd | ||
|
|
53b7cfccde | ||
|
|
854e4665b9 | ||
|
|
91a01784db | ||
|
|
55ae4d9d8e | ||
|
|
b97ce79fe4 | ||
|
|
51ad37e951 | ||
|
|
6e02e9b20b | ||
|
|
042736b67f | ||
|
|
bce503e433 | ||
|
|
83b9bc9d5f | ||
|
|
e81142f5ef | ||
|
|
32ce446a27 | ||
|
|
64bcf7d019 | ||
|
|
c015b57d73 | ||
|
|
0366699fec | ||
|
|
9c9f4565b1 | ||
|
|
8a02509ffa | ||
|
|
8b210388ee | ||
|
|
a456bfda7c | ||
|
|
23d1709ca1 | ||
|
|
6ce7e2e7f6 | ||
|
|
c0e870ce54 | ||
|
|
90e80fee53 | ||
|
|
4fd7f776f6 | ||
|
|
c73c86e86c |
2
.github/workflows/docs_deploy.yml
vendored
2
.github/workflows/docs_deploy.yml
vendored
@@ -4,8 +4,6 @@ on:
|
||||
push:
|
||||
paths:
|
||||
- "vitepress-docs/**"
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
48
.github/workflows/smtp_proxy_server.yml
vendored
Normal file
48
.github/workflows/smtp_proxy_server.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: SMTP Proxy Server Docker Image CI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "smtp_proxy_server/**"
|
||||
tags:
|
||||
- "*"
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: smtp_proxy_server
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./smtp_proxy_server
|
||||
file: ./smtp_proxy_server/dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:latest
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
dist/
|
||||
test/
|
||||
.vscode/
|
||||
|
||||
116
CHANGELOG.md
116
CHANGELOG.md
@@ -1,9 +1,123 @@
|
||||
# CHANGE LOG
|
||||
|
||||
## main branch
|
||||
|
||||
### DB Changes
|
||||
|
||||
新增 user 相关表,用于存储用户信息
|
||||
|
||||
- `db/2024-05-08-patch.sql`
|
||||
|
||||
### config changs
|
||||
|
||||
```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 增加全局标签页位置配置, 侧边距配置
|
||||
|
||||
## 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
|
||||
|
||||
### DB Changes
|
||||
|
||||
新增 `settings` 表,用于存储通用配置信息
|
||||
|
||||
- `db/2024-05-01-patch.sql`
|
||||
|
||||
### Changes
|
||||
|
||||
- `ENABLE_USER_CREATE_EMAIL` 是否允许用户创建邮件
|
||||
- 允许 admin 创建无前缀的邮件
|
||||
- 添加 `SMTP proxy server`,支持 SMTP 发送邮件
|
||||
- 修复某些情况浏览器无法加载 `wasm` 时使用 js 解析邮件
|
||||
- 页脚添加 `COPYRIGHT`
|
||||
- UI 允许用户切换邮件展示模式 `v-html` / `iframe`
|
||||
- 添加 `admin` 账户配置页面,支持配置用户注册名称黑名单
|
||||
|
||||
* feat: support admin create address && add ENABLE_USER_CREATE_EMAIL co… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/175
|
||||
* feat: add SMTP proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/177
|
||||
* fix: cf ui var is string by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/178
|
||||
* fix: UI mailbox 100vh to 80vh by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/179
|
||||
* fix: smtp_proxy_server hostname && add docker image for linux/arm64 by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/180
|
||||
* fix: some browser do not support wasm by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/182
|
||||
* feat: add COPYRIGHT by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/183
|
||||
* feat: UI: add user page: useIframeShowMail && mailboxSplitSize by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/184
|
||||
* feat: add address_block_list for new address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/185
|
||||
|
||||
## v0.3.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
`address` 表的前缀将从代码中迁移到 db 中,请将下面 sql 中的 `tmp` 替换为你的前缀,然后执行。
|
||||
如果你的数据很重要,请先备份数据库。
|
||||
|
||||
**注意替换前缀**
|
||||
|
||||
```sql
|
||||
update
|
||||
address
|
||||
set
|
||||
name = 'tmp' || name;
|
||||
```
|
||||
|
||||
### Changes
|
||||
|
||||
- `address` 表的前缀将从代码中迁移到 db 中
|
||||
- `admin` 账户页面添加收发邮件数量
|
||||
- `admin` 发件页面默认显示全部
|
||||
- `admin` 发件权限页面支持搜索地址
|
||||
- `admin` 邮件页面使用左右分栏 UI
|
||||
|
||||
* feat: remove PREFIX logic in db by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/171
|
||||
* feat: admin page add account mail count && sendbox default all && sen… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/172
|
||||
* feat: all mail use MailBox Component by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/173
|
||||
|
||||
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/0.2.10...v0.3.0
|
||||
|
||||
## v0.2.10
|
||||
|
||||
- `ENABLE_USER_DELETE_EMAIL` 是否允许用户删除账户和邮件
|
||||
- `ENABLE_AUTO_REPLY` 是否启用自动回复
|
||||
- fetchAddressError 提示改进
|
||||
- 自动刷新显示倒计时
|
||||
|
||||
* feat: docs update by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/165
|
||||
* feat: add ENABLE_USER_DELETE_EMAIL && ENABLE_AUTO_REPLY && modify fetchAddressError i18n && UI: show autoRefreshInterval by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/169
|
||||
|
||||
## v0.2.9
|
||||
|
||||
- 添加富文本编辑器
|
||||
- admin 联系方式,不配置则不显示,可配置任意字符串 `ADMIN_CONTACT = "xx@xx.xxx"`
|
||||
- admin 联系方式,不配置则不显示,可配置任意字符串 `ADMIN_CONTACT = "xx@xx.xxx"`
|
||||
- 默认发送邮件余额,如果不设置,将为 0 `DEFAULT_SEND_BALANCE = 1`
|
||||
|
||||
## v0.2.8
|
||||
|
||||
34
README.md
34
README.md
@@ -1,8 +1,14 @@
|
||||
# 使用 cloudflare 免费服务,搭建临时邮箱
|
||||
|
||||
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
|
||||
|
||||
## [查看部署文档](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,23 +27,29 @@
|
||||
|
||||
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
|
||||
- [查看部署文档](#查看部署文档)
|
||||
- [English Docs](#english-docs)
|
||||
- [CHANGELOG](#changelog)
|
||||
- [在线演示](#在线演示)
|
||||
- [功能/TODO](#功能todo)
|
||||
- [Reference](#reference)
|
||||
|
||||
## 功能/TODO
|
||||
|
||||
- [x] Cloudflare D1 作为数据库
|
||||
- [x] 使用 Cloudflare Pages 部署前端
|
||||
- [x] 使用 Cloudflare Workers 部署后端
|
||||
- [x] email 转发使用 Cloudflare Email Routing
|
||||
- [x] 使用 password 重新登录之前的邮箱
|
||||
- [x] 获取自定义名字的邮箱
|
||||
- [x] 使用 `password` 重新登录之前的邮箱
|
||||
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
|
||||
- [x] 支持多语言
|
||||
- [x] 增加访问密码,可作为私人站点
|
||||
- [x] 增加自动回复功能
|
||||
- [x] 增加查看附件功能
|
||||
- [x] 使用 rust wasm 解析邮件
|
||||
- [x] 增加查看 `附件` 功能
|
||||
- [x] 使用 `rust wasm` 解析邮件
|
||||
- [x] 支持发送邮件
|
||||
- [x] 支持 DKIM
|
||||
- [x] 支持 `DKIM`
|
||||
- [x] `admin` 后台创建无前缀邮箱
|
||||
- [x] 添加 `SMTP proxy server`,支持 SMTP 发送邮件
|
||||
- [x] 添加完整的用户注册登录功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证切换不同邮箱
|
||||
|
||||
## Reference
|
||||
|
||||
- Cloudflare D1 作为数据库
|
||||
- 使用 Cloudflare Pages 部署前端
|
||||
- 使用 Cloudflare Workers 部署后端
|
||||
- email 转发使用 Cloudflare Email Routing
|
||||
|
||||
6
db/2024-05-01-patch.sql
Normal file
6
db/2024-05-01-patch.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
21
db/2024-05-08-patch.sql
Normal file
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);
|
||||
@@ -70,3 +70,32 @@ CREATE TABLE IF NOT EXISTS sendbox (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,24 +18,24 @@
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.6.8",
|
||||
"mail-parser-wasm": "^0.1.6",
|
||||
"naive-ui": "^2.38.1",
|
||||
"naive-ui": "^2.38.2",
|
||||
"postal-mime": "^2.2.5",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.4.25",
|
||||
"vue": "^3.4.26",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^9.13.1",
|
||||
"vue-router": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.12.0",
|
||||
"@vitejs/plugin-vue": "^4.6.2",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.2.10",
|
||||
"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.52.0"
|
||||
"wrangler": "^3.53.1"
|
||||
}
|
||||
}
|
||||
|
||||
1545
frontend/pnpm-lock.yaml
generated
1545
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,18 @@
|
||||
<script setup>
|
||||
import { darkTheme, NGlobalStyle, zhCN } from 'naive-ui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from './store'
|
||||
import { useIsMobile } from './utils/composables'
|
||||
import Header from './views/Header.vue';
|
||||
import Footer from './views/Footer.vue';
|
||||
|
||||
const { localeCache, isDark, loading } = useGlobalState()
|
||||
|
||||
const { localeCache, isDark, loading, useSideMargin } = useGlobalState()
|
||||
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||
const localeConfig = computed(() => localeCache.value == 'zh' ? zhCN : null)
|
||||
const isMobile = useIsMobile()
|
||||
const showSideMargin = computed(() => !isMobile.value && !useSideMargin.value);
|
||||
|
||||
const { locale } = useI18n({
|
||||
useScope: 'global',
|
||||
@@ -38,16 +40,19 @@ 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>
|
||||
<Header />
|
||||
<router-view></router-view>
|
||||
<n-layout style="min-height: 80vh;">
|
||||
<Header />
|
||||
<router-view></router-view>
|
||||
</n-layout>
|
||||
<Footer />
|
||||
</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,7 +29,7 @@ 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;
|
||||
@@ -49,7 +53,7 @@ const apiFetch = async (path, options = {}) => {
|
||||
const getOpenSettings = async (message) => {
|
||||
try {
|
||||
const res = await api.fetch("/open_api/settings");
|
||||
openSettings.value = {
|
||||
Object.assign(openSettings.value, {
|
||||
prefix: res["prefix"] || "",
|
||||
needAuth: res["needAuth"] || false,
|
||||
domains: res["domains"].map((domain) => {
|
||||
@@ -59,9 +63,12 @@ const getOpenSettings = async (message) => {
|
||||
}
|
||||
}),
|
||||
adminContact: res["adminContact"] || "",
|
||||
enableUserCreateEmail: res["enableUserCreateEmail"] || false,
|
||||
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
||||
enableAutoReply: res["enableAutoReply"] || false,
|
||||
};
|
||||
copyright: res["copyright"] || openSettings.value.copyright,
|
||||
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
|
||||
});
|
||||
if (openSettings.value.needAuth) {
|
||||
showAuth.value = true;
|
||||
}
|
||||
@@ -87,10 +94,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;
|
||||
}
|
||||
@@ -106,10 +135,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,
|
||||
}
|
||||
|
||||
438
frontend/src/components/MailBox.vue
Normal file
438
frontend/src/components/MailBox.vue
Normal file
@@ -0,0 +1,438 @@
|
||||
<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'
|
||||
import { CloudDownloadRound, ReplyFilled } from '@vicons/material'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
enableUserDeleteEmail: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
requried: false
|
||||
},
|
||||
showEMailTo: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
requried: false
|
||||
},
|
||||
fetchMailData: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
requried: true
|
||||
},
|
||||
deleteMail: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
requried: false
|
||||
},
|
||||
showReply: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
requried: false
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
localeCache, isDark, mailboxSplitSize, indexTab,
|
||||
useIframeShowMail, sendMailModel, preferShowTextMail
|
||||
} = useGlobalState()
|
||||
const autoRefresh = ref(false)
|
||||
const autoRefreshInterval = ref(30)
|
||||
const data = ref([])
|
||||
const timer = ref(null)
|
||||
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const showAttachments = ref(false)
|
||||
const curAttachments = ref([])
|
||||
const curMail = ref(null);
|
||||
const showTextMail = ref(preferShowTextMail.value)
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
success: 'Success',
|
||||
autoRefresh: 'Auto Refresh',
|
||||
refreshAfter: 'Refresh After {msg} Seconds',
|
||||
refresh: 'Refresh',
|
||||
attachments: 'Show Attachments',
|
||||
downloadMail: 'Download Mail',
|
||||
pleaseSelectMail: "Please select a mail to view.",
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete this mail?',
|
||||
reply: 'Reply',
|
||||
showTextMail: 'Show Text Mail',
|
||||
showHtmlMail: 'Show Html Mail'
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
autoRefresh: '自动刷新',
|
||||
refreshAfter: '{msg}秒后刷新',
|
||||
refresh: '刷新',
|
||||
downloadMail: '下载邮件',
|
||||
attachments: '查看附件',
|
||||
pleaseSelectMail: "请选择一封邮件查看。",
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除这封邮件吗?',
|
||||
reply: '回复',
|
||||
showTextMail: '显示纯文本邮件',
|
||||
showHtmlMail: '显示HTML邮件'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const setupAutoRefresh = async (autoRefresh) => {
|
||||
// auto refresh every 30 seconds
|
||||
autoRefreshInterval.value = 30;
|
||||
if (autoRefresh) {
|
||||
timer.value = setInterval(async () => {
|
||||
autoRefreshInterval.value--;
|
||||
if (autoRefreshInterval.value <= 0) {
|
||||
autoRefreshInterval.value = 30;
|
||||
await refresh();
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
clearInterval(timer.value)
|
||||
timer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(autoRefresh, async (autoRefresh, old) => {
|
||||
setupAutoRefresh(autoRefresh)
|
||||
})
|
||||
|
||||
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 = await Promise.all(results.map(async (item) => {
|
||||
return await processItem(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 getAttachments = (attachments) => {
|
||||
curAttachments.value = attachments;
|
||||
showAttachments.value = true;
|
||||
};
|
||||
|
||||
const mailItemClass = (row) => {
|
||||
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
|
||||
};
|
||||
|
||||
const deleteMail = async () => {
|
||||
try {
|
||||
await props.deleteMail(curMail.value.id);
|
||||
message.success(t("success"));
|
||||
curMail.value = null;
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
const replyMail = async () => {
|
||||
const emailRegex = /(.+?) <(.+?)>/;
|
||||
let toMail = curMail.value.originalSource;
|
||||
let toName = ""
|
||||
const match = emailRegex.exec(curMail.value.source);
|
||||
if (match) {
|
||||
toName = match[1];
|
||||
toMail = match[2];
|
||||
}
|
||||
Object.assign(sendMailModel.value, {
|
||||
toName: toName,
|
||||
toMail: toMail,
|
||||
subject: `${t('reply')}: ${curMail.value.subject}`,
|
||||
contentType: 'rich',
|
||||
content: curMail.value.text ? `<p><br></p><blockquote>${curMail.value.text}</blockquote><p><br></p>` : '',
|
||||
});
|
||||
indexTab.value = 'sendmail';
|
||||
};
|
||||
|
||||
const onSpiltSizeChange = (size) => {
|
||||
mailboxSplitSize.value = size;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer.value)
|
||||
})
|
||||
</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-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">
|
||||
<template #checked>
|
||||
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
|
||||
</template>
|
||||
<template #unchecked>
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
</n-switch>
|
||||
<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 }}
|
||||
</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 }}
|
||||
</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 class="left" v-else>
|
||||
<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">
|
||||
<template #checked>
|
||||
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
|
||||
</template>
|
||||
<template #unchecked>
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
</n-switch>
|
||||
<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 }}
|
||||
</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>
|
||||
<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 }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="getAttachments(curMail.attachments)">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(curMail)">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div v-html="curMail.message" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t("attachments") }}</div>
|
||||
</template>
|
||||
<n-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>
|
||||
</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-modal>
|
||||
</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>
|
||||
88
frontend/src/components/Turnstile.vue
Normal file
88
frontend/src/components/Turnstile.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { ref, watch, defineModel, onMounted } from "vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
const { localeCache, openSettings, isDark } = useGlobalState()
|
||||
|
||||
const cfToken = defineModel('value')
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
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: localeCache.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>
|
||||
<div id="cf-turnstile"></div>
|
||||
<n-button text @click="checkCfTurnstile(true)">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</n-form-item-row>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Index from '../views/Index.vue'
|
||||
import Settings from '../views/Settings.vue'
|
||||
import SendMail from '../views/send/SendMail.vue'
|
||||
import UserLogin from '../views/user/UserLogin.vue'
|
||||
import User from '../views/User.vue'
|
||||
import SendMail from '../views/index/SendMail.vue'
|
||||
import Admin from '../views/Admin.vue'
|
||||
import SendBox from '../views/send/SendBox.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -13,16 +13,8 @@ const router = createRouter({
|
||||
component: Index
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: Settings
|
||||
},
|
||||
{
|
||||
path: '/send',
|
||||
component: SendMail
|
||||
},
|
||||
{
|
||||
path: '/sendbox',
|
||||
component: SendBox
|
||||
path: '/user',
|
||||
component: User
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
|
||||
@@ -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(
|
||||
() => {
|
||||
@@ -11,12 +10,12 @@ export const useGlobalState = createGlobalState(
|
||||
prefix: '',
|
||||
needAuth: false,
|
||||
adminContact: '',
|
||||
enableUserCreateEmail: false,
|
||||
enableUserDeleteEmail: false,
|
||||
enableAutoReply: false,
|
||||
domains: [{
|
||||
label: 'test.com',
|
||||
value: 'test.com'
|
||||
}]
|
||||
domains: [],
|
||||
copyright: 'Dream Hunter',
|
||||
cfTurnstileSiteKey: '',
|
||||
})
|
||||
const settings = ref({
|
||||
fetched: false,
|
||||
@@ -30,35 +29,72 @@ export const useGlobalState = createGlobalState(
|
||||
source_prefix: '',
|
||||
name: '',
|
||||
}
|
||||
})
|
||||
});
|
||||
const sendMailModel = useStorage('sendMailModel', {
|
||||
fromName: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
subject: "",
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
});
|
||||
const showAuth = 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 themeSwitch = useStorage('themeSwitch', false);
|
||||
const showLogin = ref(false);
|
||||
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,
|
||||
});
|
||||
return {
|
||||
isDark,
|
||||
toggleDark,
|
||||
loading,
|
||||
settings,
|
||||
sendMailModel,
|
||||
openSettings,
|
||||
showAuth,
|
||||
showAddressCredential,
|
||||
auth,
|
||||
jwt,
|
||||
localeCache,
|
||||
themeSwitch,
|
||||
adminAuth,
|
||||
showAdminAuth,
|
||||
showLogin,
|
||||
adminTab,
|
||||
adminMailTabAddress,
|
||||
adminSendBoxTabAddress,
|
||||
mailboxSplitSize,
|
||||
useIframeShowMail,
|
||||
preferShowTextMail,
|
||||
userJwt,
|
||||
userTab,
|
||||
indexTab,
|
||||
userOpenSettings,
|
||||
userSettings,
|
||||
globalTabplacement,
|
||||
useSideMargin,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import PostalMime from 'postal-mime';
|
||||
import { parse_message } from 'mail-parser-wasm'
|
||||
|
||||
function humanFileSize(size) {
|
||||
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
|
||||
@@ -8,11 +7,14 @@ function humanFileSize(size) {
|
||||
|
||||
export async function processItem(item) {
|
||||
// Try to parse the email using mail-parser-wasm
|
||||
item.originalSource = item.source;
|
||||
try {
|
||||
const { parse_message } = await import('mail-parser-wasm');
|
||||
const parsedEmail = parse_message(item.raw);
|
||||
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(
|
||||
@@ -45,6 +47,7 @@ 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(
|
||||
@@ -67,6 +70,7 @@ export async function processItem(item) {
|
||||
item.subject = 'No Subject';
|
||||
item.message = item.raw;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export function getDownloadEmlUrl(raw) {
|
||||
|
||||
6
frontend/src/utils/index.js
Normal file
6
frontend/src/utils/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export const hashPassword = async (password) => {
|
||||
// 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('');
|
||||
}
|
||||
@@ -8,12 +8,17 @@ import SenderAccess from './admin/SenderAccess.vue'
|
||||
import Statistics from "./admin/Statistics.vue"
|
||||
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 Maintenance from './admin/Maintenance.vue';
|
||||
import Appearance from './common/Appearance.vue';
|
||||
|
||||
const {
|
||||
localeCache, adminAuth, showAdminAuth, adminTab, loading
|
||||
localeCache, adminAuth, showAdminAuth, adminTab, loading, globalTabplacement
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -33,10 +38,16 @@ const { t } = useI18n({
|
||||
accessTip: 'Please enter the admin password',
|
||||
mails: 'Emails',
|
||||
account: 'Account',
|
||||
account_create: 'Create Account',
|
||||
account_settings: 'Account Settings',
|
||||
user_management: 'User Management',
|
||||
user_settings: 'User Settings',
|
||||
unknow: 'Mails with unknow receiver',
|
||||
senderAccess: 'Sender Access Control',
|
||||
sendBox: 'Send Box',
|
||||
statistics: 'Statistics',
|
||||
maintenance: 'Maintenance',
|
||||
appearance: 'Appearance',
|
||||
ok: 'OK',
|
||||
},
|
||||
zh: {
|
||||
@@ -44,10 +55,16 @@ const { t } = useI18n({
|
||||
accessTip: '请输入 Admin 密码',
|
||||
mails: '邮件',
|
||||
account: '账号',
|
||||
account_create: '创建账号',
|
||||
account_settings: '账号设置',
|
||||
user_management: '用户管理',
|
||||
user_settings: '用户设置',
|
||||
unknow: '无收件人邮件',
|
||||
senderAccess: '发件权限控制',
|
||||
sendBox: '发件箱',
|
||||
statistics: '统计',
|
||||
maintenance: '维护',
|
||||
appearance: '外观',
|
||||
ok: '确定',
|
||||
}
|
||||
}
|
||||
@@ -64,10 +81,7 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div>
|
||||
<n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
|
||||
title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('accessHeader') }}</div>
|
||||
</template>
|
||||
:title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="adminAuth" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
<template #action>
|
||||
@@ -76,11 +90,22 @@ 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-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="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-tab-pane name="mails" :tab="t('mails')">
|
||||
<Mails />
|
||||
</n-tab-pane>
|
||||
@@ -93,9 +118,15 @@ onMounted(async () => {
|
||||
<n-tab-pane name="sendBox" :tab="t('sendBox')">
|
||||
<SendBox />
|
||||
</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-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
38
frontend/src/views/Footer.vue
Normal file
38
frontend/src/views/Footer.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
const { localeCache, openSettings } = useGlobalState()
|
||||
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
copyright: "Copyright"
|
||||
},
|
||||
zh: {
|
||||
copyright: "版权所有"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-divider class="footer-divider" />
|
||||
<div style="text-align: center; padding: 20px">
|
||||
<n-text depth="3">
|
||||
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }} {{ openSettings.copyright }}
|
||||
</n-text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.footer-divider {
|
||||
margin: 0;
|
||||
padding: 0 var(--x-padding);
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +1,32 @@
|
||||
<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 { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { DarkModeFilled, LightModeFilled, MenuFilled, AdminPanelSettingsFilled, SendFilled } from '@vicons/material'
|
||||
import { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
|
||||
import AdminContact from './admin/AdminContact.vue'
|
||||
import {
|
||||
DarkModeFilled, LightModeFilled, MenuFilled,
|
||||
AdminPanelSettingsFilled
|
||||
} from '@vicons/material'
|
||||
import { GithubAlt, Language, User, Home } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
const { toClipboard } = useClipboard()
|
||||
const message = useMessage()
|
||||
|
||||
const {
|
||||
jwt, localeCache, toggleDark, isDark,
|
||||
localeCache, toggleDark, isDark, openSettings,
|
||||
showAuth, adminAuth, auth, loading
|
||||
} = useGlobalState()
|
||||
const { showLogin, openSettings, settings } = useGlobalState()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const isAdminRoute = computed(() => route.path.includes('admin'))
|
||||
|
||||
const showMobileMenu = ref(false)
|
||||
const showNewEmail = ref(false)
|
||||
const showLogout = ref(false)
|
||||
const showDelteAccount = ref(false)
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
jwt.value = password.value;
|
||||
await api.getSettings()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
jwt.value = '';
|
||||
location.reload()
|
||||
}
|
||||
const menuValue = computed(() => {
|
||||
if (route.path.includes("user")) return "user";
|
||||
if (route.path.includes("admin")) return "admin";
|
||||
return "home";
|
||||
});
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
@@ -67,90 +48,41 @@ const { t } = useI18n({
|
||||
title: 'Cloudflare Temp Email',
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
login: 'Login',
|
||||
logout: 'Logout',
|
||||
logoutConfirm: 'Are you sure to logout?',
|
||||
delteAccount: "Delete Account",
|
||||
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
|
||||
accessHeader: 'Access Password',
|
||||
accessTip: 'Please enter the correct password',
|
||||
settings: 'Settings',
|
||||
accessTip: 'Please enter the correct access password',
|
||||
home: 'Home',
|
||||
menu: 'Menu',
|
||||
user: 'User',
|
||||
sendbox: 'Send Box',
|
||||
sendMail: 'Send Mail',
|
||||
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
|
||||
getNewEmail: 'Get 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.',
|
||||
yourAddress: 'Your email address is',
|
||||
password: 'Password',
|
||||
passwordTip: 'Please copy the password and you can use it to login to your email account.',
|
||||
cancel: 'Cancel',
|
||||
ok: 'OK',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
showPassword: 'Show Password',
|
||||
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.',
|
||||
generateName: 'Generate Fake Name',
|
||||
},
|
||||
zh: {
|
||||
title: 'Cloudflare 临时邮件',
|
||||
dark: '暗色',
|
||||
light: '亮色',
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
logoutConfirm: '确定要登出吗?',
|
||||
delteAccount: "删除账户",
|
||||
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
|
||||
accessHeader: '访问密码',
|
||||
accessTip: '请输入站点访问密码',
|
||||
settings: '设置',
|
||||
home: '主页',
|
||||
menu: '菜单',
|
||||
user: '用户',
|
||||
sendbox: '发件箱',
|
||||
sendMail: '发送邮件',
|
||||
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
|
||||
getNewEmail: '获取新邮箱',
|
||||
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
|
||||
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
|
||||
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
|
||||
yourAddress: '你的邮箱地址是',
|
||||
password: '密码',
|
||||
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
|
||||
cancel: '取消',
|
||||
ok: '确定',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
showPassword: '查看密码',
|
||||
fetchAddressError: '登录密码无效或账号不存在,也可能是网络连接异常,请稍后再尝试。',
|
||||
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
|
||||
generateName: '生成随机名字',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const showUserMenu = computed(() => !!settings.value.address)
|
||||
|
||||
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('/'); showMobileMenu.value = false; }
|
||||
},
|
||||
{
|
||||
default: () => t('home'),
|
||||
icon: () => h(NIcon, { component: Home })
|
||||
}
|
||||
),
|
||||
}),
|
||||
key: "home"
|
||||
},
|
||||
{
|
||||
@@ -159,8 +91,26 @@ 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("/user"); showMobileMenu.value = false; }
|
||||
},
|
||||
{
|
||||
default: () => t('user'),
|
||||
icon: () => h(NIcon, { component: User }),
|
||||
}
|
||||
),
|
||||
key: "user",
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
type: menuValue.value == "admin" ? "primary" : "default",
|
||||
style: "width: 100%",
|
||||
onClick: async () => { await router.push('/admin'); showMobileMenu.value = false; }
|
||||
},
|
||||
{
|
||||
default: () => "Admin",
|
||||
@@ -170,91 +120,6 @@ const menuOptions = computed(() => [
|
||||
show: !!adminAuth.value,
|
||||
key: "admin"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
},
|
||||
{
|
||||
default: () => t('user'),
|
||||
icon: () => h(NIcon, { component: User }),
|
||||
}
|
||||
),
|
||||
show: showUserMenu.value,
|
||||
key: "user",
|
||||
children: [
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
onClick: () => { router.push('/sendbox'); showMobileMenu.value = false; }
|
||||
},
|
||||
{ default: () => t('sendbox') }
|
||||
),
|
||||
key: "sendbox"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
onClick: () => { showPassword.value = true; showMobileMenu.value = false; }
|
||||
},
|
||||
{ default: () => t('showPassword') }
|
||||
),
|
||||
key: "showPassword"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
onClick: () => { router.push('/settings'); showMobileMenu.value = false; }
|
||||
},
|
||||
{ default: () => t('settings') }
|
||||
),
|
||||
show: openSettings.value.enableAutoReply,
|
||||
key: "settings"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
onClick: () => { showLogout.value = true; showMobileMenu.value = false; }
|
||||
},
|
||||
{ default: () => t('logout') }
|
||||
),
|
||||
key: "logout"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
onClick: () => { showDelteAccount.value = true; showMobileMenu.value = false; }
|
||||
},
|
||||
{ default: () => t('delteAccount') }
|
||||
),
|
||||
show: openSettings.value.enableUserDeleteEmail,
|
||||
key: "delte_account"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
@@ -314,64 +179,8 @@ const menuOptions = computed(() => [
|
||||
}
|
||||
]);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await toClipboard(settings.value.address)
|
||||
message.success(t('copied'));
|
||||
} catch (e) {
|
||||
message.error(e.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const generateNameLoading = ref(false);
|
||||
const generateName = async () => {
|
||||
try {
|
||||
generateNameLoading.value = true;
|
||||
const { faker } = await import('https://esm.sh/@faker-js/faker');
|
||||
emailName.value = faker.person
|
||||
.fullName()
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/[^a-zA-Z0-9.]/g, '')
|
||||
.toLowerCase();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
generateNameLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const newEmail = async () => {
|
||||
try {
|
||||
const res = await api.fetch(
|
||||
`/api/new_address`
|
||||
+ `?name=${emailName.value || ''}`
|
||||
+ `&domain=${emailDomain.value || ''}`
|
||||
);
|
||||
jwt.value = res["jwt"];
|
||||
await api.getSettings();
|
||||
showNewEmail.value = false;
|
||||
showPassword.value = true;
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/delete_address`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
jwt.value = '';
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getOpenSettings(message);
|
||||
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
|
||||
await api.getSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -386,7 +195,7 @@ onMounted(async () => {
|
||||
</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" />
|
||||
@@ -401,138 +210,10 @@ 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 round
|
||||
type="primary">
|
||||
<n-icon :component="SendFilled" /> {{ t('sendMail') }}
|
||||
</n-button>
|
||||
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary round type="primary">
|
||||
<n-icon :component="Copy" /> {{ t('copy') }}
|
||||
</n-button>
|
||||
</span>
|
||||
</n-alert>
|
||||
</div>
|
||||
<n-card v-else>
|
||||
<n-result status="info" :description="t('pleaseGetNewEmail')">
|
||||
<template #footer>
|
||||
<n-alert v-if="jwt" type="warning" show-icon>
|
||||
<span>{{ t('fetchAddressError') }}</span>
|
||||
</n-alert>
|
||||
<n-button @click="showLogin = true" tertiary round type="primary">
|
||||
{{ t('login') }}
|
||||
</n-button>
|
||||
<n-button @click="showNewEmail = true" tertiary round type="primary">
|
||||
{{ t('getNewEmail') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</n-card>
|
||||
</div>
|
||||
<n-modal v-model:show="showNewEmail" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('getNewEmail') }}</div>
|
||||
</template>
|
||||
<n-spin :show="generateNameLoading">
|
||||
<span>
|
||||
<p>{{ t("getNewEmailTip1") }}</p>
|
||||
<p>{{ t("getNewEmailTip2") }}</p>
|
||||
<p>{{ t("getNewEmailTip3") }}</p>
|
||||
</span>
|
||||
<n-button @click="generateName" style="margin-bottom: 10px;">
|
||||
{{ t('generateName') }}
|
||||
</n-button>
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-model:value="emailName" />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</n-spin>
|
||||
<template #action>
|
||||
<n-button @click="showNewEmail = false">
|
||||
{{ t('cancel') }}
|
||||
</n-button>
|
||||
<n-button @click="newEmail" type="primary" :loading="loading">
|
||||
{{ t('ok') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showPassword" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t("password") }}</div>
|
||||
</template>
|
||||
<span>
|
||||
<p>{{ t("passwordTip") }}</p>
|
||||
</span>
|
||||
<n-card>
|
||||
<b>{{ jwt }}</b>
|
||||
</n-card>
|
||||
<template #action>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showLogin" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('login') }}</div>
|
||||
</template>
|
||||
<AdminContact />
|
||||
<n-input v-model:value="password" type="textarea" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
<template #action>
|
||||
<n-button @click="login" :loading="loading" size="small" tertiary round type="primary">
|
||||
{{ t('login') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showLogout" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('logout') }}</div>
|
||||
</template>
|
||||
<p>{{ t('logoutConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="logout" size="small" tertiary round type="primary">
|
||||
{{ t('logout') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDelteAccount" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('delteAccount') }}</div>
|
||||
</template>
|
||||
<p>{{ t('delteAccountConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary round type="error">
|
||||
{{ t('delteAccount') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
|
||||
title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('accessHeader') }}</div>
|
||||
</template>
|
||||
:title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="auth" type="textarea" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
<n-input v-model:value="auth" type="textarea" :autosize="{ minRows: 3 }" />
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="authFunc" type="primary">
|
||||
{{ t('ok') }}
|
||||
@@ -549,11 +230,6 @@ onMounted(async () => {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mobile-menu-button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.n-alert {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
@@ -563,4 +239,16 @@ onMounted(async () => {
|
||||
.n-card {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.n-form .n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,67 @@
|
||||
<script setup>
|
||||
import MailBox from './MailBox.vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
const { settings } = useGlobalState()
|
||||
import { api } from '../api'
|
||||
|
||||
import AddressBar from './index/AddressBar.vue';
|
||||
import MailBox from '../components/MailBox.vue';
|
||||
import AutoReply from './index/AutoReply.vue';
|
||||
import SendBox from './index/SendBox.vue';
|
||||
import SendMail from './index/SendMail.vue';
|
||||
import AccountSettings from './index/AccountSettings.vue';
|
||||
|
||||
const { localeCache, settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
mailbox: 'Mail Box',
|
||||
sendbox: 'Send Box',
|
||||
sendmail: 'Send Mail',
|
||||
auto_reply: 'Auto Reply',
|
||||
accountSettings: 'Account Settings',
|
||||
},
|
||||
zh: {
|
||||
mailbox: '收件箱',
|
||||
sendbox: '发件箱',
|
||||
sendmail: '发送邮件',
|
||||
auto_reply: '自动回复',
|
||||
accountSettings: '账户设置',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
|
||||
};
|
||||
|
||||
const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MailBox v-if="settings.address" />
|
||||
<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" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendmail" :tab="t('sendmail')">
|
||||
<SendMail />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="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-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
<script setup>
|
||||
import { watch, onMounted, ref } from "vue";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { CloudDownloadRound } from '@vicons/material'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const { settings, openSettings, themeSwitch } = useGlobalState()
|
||||
const autoRefresh = ref(false)
|
||||
const autoRefreshInterval = ref(30)
|
||||
const data = ref([])
|
||||
const timer = ref(null)
|
||||
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const showAttachments = ref(false)
|
||||
const curAttachments = ref([])
|
||||
const curMail = ref(null);
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
autoRefresh: 'Auto Refresh',
|
||||
refreshAfter: 'Refresh After {msg} Seconds',
|
||||
refresh: 'Refresh',
|
||||
attachments: 'Show Attachments',
|
||||
downloadMail: 'Download Mail',
|
||||
pleaseSelectMail: "Please select a mail to view.",
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete this mail?'
|
||||
},
|
||||
zh: {
|
||||
autoRefresh: '自动刷新',
|
||||
refreshAfter: '{msg}秒后刷新',
|
||||
refresh: '刷新',
|
||||
downloadMail: '下载邮件',
|
||||
attachments: '查看附件',
|
||||
pleaseSelectMail: "请选择一封邮件查看。",
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除这封邮件吗?'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const setupAutoRefresh = async (autoRefresh) => {
|
||||
// auto refresh every 30 seconds
|
||||
autoRefreshInterval.value = 30;
|
||||
if (autoRefresh) {
|
||||
timer.value = setInterval(async () => {
|
||||
autoRefreshInterval.value--;
|
||||
if (autoRefreshInterval.value <= 0) {
|
||||
autoRefreshInterval.value = 30;
|
||||
await refresh();
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
clearInterval(timer.value)
|
||||
timer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(autoRefresh, async (autoRefresh, old) => {
|
||||
setupAutoRefresh(autoRefresh)
|
||||
})
|
||||
|
||||
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
|
||||
if (page !== oldPage || pageSize !== oldPageSize) {
|
||||
await refresh();
|
||||
}
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const { results, count: totalCount } = await api.fetch(
|
||||
`/api/mails`
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
);
|
||||
data.value = await Promise.all(results.map(async (item) => {
|
||||
return await processItem(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 getAttachments = (attachments) => {
|
||||
curAttachments.value = attachments;
|
||||
showAttachments.value = true;
|
||||
};
|
||||
|
||||
const mailItemClass = (row) => {
|
||||
return curMail.value && row.id == curMail.value.id ? (themeSwitch.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
|
||||
};
|
||||
|
||||
const deleteMail = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/mails/${curMail.value.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
message.success(t("success"));
|
||||
curMail.value = null;
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-layout v-if="settings.address">
|
||||
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25" :default-size="0.3">
|
||||
<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">
|
||||
<template #checked>
|
||||
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
|
||||
</template>
|
||||
<template #unchecked>
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
</n-switch>
|
||||
<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)"
|
||||
: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>
|
||||
<div style="word-break: break-all; font-size: small;">
|
||||
FROM: {{ row.source }}
|
||||
</div>
|
||||
</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-popconfirm v-if="openSettings.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)">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div 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 class="left" v-else>
|
||||
<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">
|
||||
<template #checked>
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
<template #unchecked>
|
||||
{{ t('autoRefresh') }}
|
||||
</template></n-switch>
|
||||
<n-button @click="refresh" size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div id="drawer-target" 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 }}
|
||||
</n-tag>
|
||||
<div style="word-break: break-all; font-size: small;">
|
||||
FROM: {{ row.source }}
|
||||
</div>
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
<n-drawer v-model:show="curMail" width="100%" :trap-focus="false" :block-scroll="false" to="#drawer-target">
|
||||
<n-drawer-content :title="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 }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
</n-tag>
|
||||
<n-popconfirm @positive-click="deleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="getAttachments(curMail.attachments)">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(curMail)">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div v-html="curMail.message" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
</n-layout>
|
||||
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
|
||||
<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>
|
||||
</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>
|
||||
<template #action>
|
||||
</template>
|
||||
</n-modal>
|
||||
</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%;
|
||||
}
|
||||
</style>
|
||||
48
frontend/src/views/User.vue
Normal file
48
frontend/src/views/User.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
|
||||
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, userTab, globalTabplacement, userSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
address_management: 'Address Management',
|
||||
user_settings: 'User Settings',
|
||||
bind_address: 'Bind Mail Address',
|
||||
},
|
||||
zh: {
|
||||
address_management: '地址管理',
|
||||
user_settings: '用户设置',
|
||||
bind_address: '绑定邮箱地址',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="user_settings" :tab="t('user_settings')">
|
||||
<UserSettingsPage />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="bind_address" :tab="t('bind_address')">
|
||||
<BindAddress />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch } from 'vue';
|
||||
import { NBadge } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { NMenu } from 'naive-ui';
|
||||
import { NButton, NMenu } from 'naive-ui';
|
||||
import { MenuFilled } from '@vicons/material'
|
||||
|
||||
const {
|
||||
@@ -19,9 +20,12 @@ const { t } = useI18n({
|
||||
en: {
|
||||
name: 'Name',
|
||||
created_at: 'Created At',
|
||||
showPass: 'Show Passwrod',
|
||||
password: 'Password',
|
||||
passwordTip: 'Please copy the password and you can use it to login to your email account.',
|
||||
updated_at: 'Update At',
|
||||
mail_count: 'Mail Count',
|
||||
send_count: 'Send Count',
|
||||
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',
|
||||
@@ -35,9 +39,12 @@ const { t } = useI18n({
|
||||
zh: {
|
||||
name: '名称',
|
||||
created_at: '创建时间',
|
||||
showPass: '显示密码',
|
||||
password: '密码',
|
||||
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
|
||||
updated_at: '更新时间',
|
||||
mail_count: '邮件数量',
|
||||
send_count: '发送数量',
|
||||
showCredential: '查看邮箱地址凭证',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
delete: '删除',
|
||||
deleteTip: '确定要删除这个邮箱吗?',
|
||||
delteAccount: '删除邮箱',
|
||||
@@ -51,8 +58,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("")
|
||||
@@ -61,16 +68,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 = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +88,8 @@ const deleteEmail = async () => {
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
showDelteAccount.value = false
|
||||
} finally {
|
||||
showDeleteAccount.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +124,62 @@ const columns = [
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
},
|
||||
{
|
||||
title: t('updated_at'),
|
||||
key: "updated_at"
|
||||
},
|
||||
{
|
||||
title: t('mail_count'),
|
||||
key: "mail_count",
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.mail_count > 0) {
|
||||
adminMailTabAddress.value = row.name;
|
||||
adminTab.value = "mails";
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: () => h(NBadge, {
|
||||
value: row.mail_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
}),
|
||||
default: () => row.mail_count > 0 ? t('viewMails') : ""
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('send_count'),
|
||||
key: "send_count",
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.send_count > 0) {
|
||||
adminSendBoxTabAddress.value = row.name;
|
||||
adminTab.value = "sendBox";
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: () => h(NBadge, {
|
||||
value: row.send_count,
|
||||
'show-zero': true,
|
||||
max: 99,
|
||||
type: "success"
|
||||
}),
|
||||
default: () => row.send_count > 0 ? t('viewSendBox') : ""
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
@@ -132,18 +196,16 @@ const columns = [
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
bordered: false,
|
||||
ghost: true,
|
||||
onClick: () => showPassword(row.id)
|
||||
text: true,
|
||||
onClick: () => showCredential(row.id)
|
||||
},
|
||||
{ default: () => t('showPass') }
|
||||
{ default: () => t('showCredential') }
|
||||
),
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
bordered: false,
|
||||
ghost: true,
|
||||
text: true,
|
||||
onClick: () => {
|
||||
adminMailTabAddress.value = row.name;
|
||||
adminTab.value = "mails";
|
||||
@@ -155,8 +217,7 @@ const columns = [
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
bordered: false,
|
||||
ghost: true,
|
||||
text: true,
|
||||
onClick: () => {
|
||||
adminSendBoxTabAddress.value = row.name;
|
||||
adminTab.value = "sendBox";
|
||||
@@ -168,11 +229,10 @@ const columns = [
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
bordered: false,
|
||||
ghost: true,
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curDeleteAddressId.value = row.id;
|
||||
showDelteAccount.value = true;
|
||||
showDeleteAccount.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('delete') }
|
||||
@@ -202,30 +262,30 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-modal v-model:show="showEmailPassword" preset="dialog" title="Dialog">
|
||||
<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 round type="error">
|
||||
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
|
||||
{{ t('delteAccount') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
91
frontend/src/views/admin/AccountSettings.vue
Normal file
91
frontend/src/views/admin/AccountSettings.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, 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',
|
||||
},
|
||||
zh: {
|
||||
save: '保存',
|
||||
successTip: '保存成功',
|
||||
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
|
||||
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
|
||||
send_address_block_list: '发送邮件地址屏蔽关键词',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const addressBlockList = ref([])
|
||||
const sendAddressBlockList = ref([])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/account_settings`)
|
||||
addressBlockList.value = res.blockList || []
|
||||
sendAddressBlockList.value = res.sendBlockList || []
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/account_settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
blockList: addressBlockList.value || [],
|
||||
sendBlockList: sendAddressBlockList.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-item-row :label="t('address_block_list')">
|
||||
<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-button @click="save" type="primary" block :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
109
frontend/src/views/admin/CreateAccount.vue
Normal file
109
frontend/src/views/admin/CreateAccount.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const {
|
||||
localeCache, loading, openSettings,
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
address: 'Address',
|
||||
enablePrefix: 'If enable Prefix',
|
||||
creatNewEmail: 'Get New Email',
|
||||
fillInAllFields: 'Please fill in all fields',
|
||||
successTip: 'Success Created',
|
||||
addressCredential: 'Mail Address Credential',
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
enablePrefix: '是否启用前缀',
|
||||
creatNewEmail: '创建新邮箱',
|
||||
fillInAllFields: '请填写完整信息',
|
||||
successTip: '创建成功',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const enablePrefix = ref(true)
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const showReultModal = ref(false)
|
||||
const result = ref("")
|
||||
|
||||
const newEmail = async () => {
|
||||
if (!emailName.value || !emailDomain.value) {
|
||||
message.error(t('fillInAllFields'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await api.fetch(`/admin/new_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
enablePrefix: enablePrefix.value,
|
||||
name: emailName.value,
|
||||
domain: emailDomain.value,
|
||||
})
|
||||
})
|
||||
result.value = res["jwt"];
|
||||
message.success(t('successTip'))
|
||||
showReultModal.value = true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (openSettings.prefix) {
|
||||
enablePrefix.value = true
|
||||
}
|
||||
emailDomain.value = openSettings.value.domains?.[0]?.value || ""
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
|
||||
<p>{{ t('addressCredential') }}</p>
|
||||
<n-card>
|
||||
<b>{{ result }}</b>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
<n-card style="max-width: 600px;">
|
||||
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
|
||||
<n-checkbox v-model:checked="enablePrefix" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('address')">
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-model:value="emailName" />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-button @click="newEmail" type="primary" block :loading="loading">
|
||||
{{ t('creatNewEmail') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,60 +4,49 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { processItem } from '../../utils/email-parser'
|
||||
import MailBox from '../../components/MailBox.vue';
|
||||
|
||||
const {
|
||||
localeCache, adminAuth, showAdminAuth,
|
||||
adminMailTabAddress
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
mails: 'Emails',
|
||||
itemCount: 'itemCount',
|
||||
addressQueryTip: 'Leave blank to query all addresses',
|
||||
keywordQueryTip: 'Leave blank to not query by keyword',
|
||||
query: 'Query',
|
||||
},
|
||||
zh: {
|
||||
mails: '邮件',
|
||||
itemCount: '总数',
|
||||
addressQueryTip: '留空查询所有地址',
|
||||
keywordQueryTip: '留空不按关键字查询',
|
||||
query: '查询',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const mailData = ref([])
|
||||
const mailCount = ref(0)
|
||||
const mailPage = ref(1)
|
||||
const mailPageSize = ref(20)
|
||||
const mailBoxKey = ref("")
|
||||
const mailKeyword = ref("")
|
||||
|
||||
watch([mailPage, mailPageSize, adminMailTabAddress], async () => {
|
||||
await fetchMailData()
|
||||
})
|
||||
watch([adminMailTabAddress, mailKeyword], () => {
|
||||
adminMailTabAddress.value = adminMailTabAddress.value.trim();
|
||||
mailKeyword.value = mailKeyword.value.trim();
|
||||
});
|
||||
|
||||
const fetchMailData = async () => {
|
||||
if (!adminMailTabAddress.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { results, count } = await api.fetch(
|
||||
`/admin/mails`
|
||||
+ `?address=${adminMailTabAddress.value}`
|
||||
+ `&limit=${mailPageSize.value}`
|
||||
+ `&offset=${(mailPage.value - 1) * mailPageSize.value}`
|
||||
);
|
||||
mailData.value = await Promise.all(results.map(async (item) => {
|
||||
return await processItem(item);
|
||||
}));
|
||||
if (count > 0) {
|
||||
mailCount.value = count;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
const queryMail = () => {
|
||||
mailBoxKey.value = Date.now();
|
||||
}
|
||||
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
return await api.fetch(
|
||||
`/admin/mails`
|
||||
+ `?limit=${limit}`
|
||||
+ `&offset=${offset}`
|
||||
+ (adminMailTabAddress.value ? `&address=${adminMailTabAddress.value}` : '')
|
||||
+ (mailKeyword.value ? `&keyword=${mailKeyword.value}` : '')
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -65,48 +54,18 @@ onMounted(async () => {
|
||||
showAdminAuth.value = true;
|
||||
return;
|
||||
}
|
||||
await fetchMailData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="adminMailTabAddress" />
|
||||
<n-button @click="fetchMailData" type="primary" ghost>
|
||||
<n-input v-model:value="adminMailTabAddress" :placeholder="t('addressQueryTip')" />
|
||||
<n-input v-model:value="mailKeyword" :placeholder="t('keywordQueryTip')" />
|
||||
<n-button @click="queryMail" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<n-list hoverable clickable>
|
||||
<div style="display: inline-block; margin-bottom: 10px;">
|
||||
<n-pagination v-model:page="mailPage" v-model:page-size="mailPageSize" :item-count="mailCount" simple>
|
||||
<template #prefix="{ itemCount }">
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-list-item v-for="row in mailData" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.subject">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-html="row.message"></div>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="false" :fetchMailData="fetchMailData" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-pagination {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +1,18 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { processItem } from '../../utils/email-parser'
|
||||
import MailBox from '../../components/MailBox.vue';
|
||||
|
||||
const {
|
||||
localeCache, adminAuth, showAdminAuth
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
const { adminAuth, showAdminAuth } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
itemCount: 'itemCount',
|
||||
refresh: 'Refresh'
|
||||
},
|
||||
zh: {
|
||||
itemCount: '总数',
|
||||
refresh: '刷新'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const mailUnknowData = ref([])
|
||||
const mailUnknowCount = ref(0)
|
||||
const mailUnknowPage = ref(1)
|
||||
const mailUnknowPageSize = ref(20)
|
||||
|
||||
watch([mailUnknowPage, mailUnknowPageSize], async () => {
|
||||
await fetchMailUnknowData()
|
||||
})
|
||||
|
||||
const fetchMailUnknowData = async () => {
|
||||
try {
|
||||
const { results, count } = await api.fetch(
|
||||
`/admin/mails_unknow`
|
||||
+ `?limit=${mailUnknowPageSize.value}`
|
||||
+ `&offset=${(mailUnknowPage.value - 1) * mailUnknowPage.value}`
|
||||
);
|
||||
mailUnknowData.value = await Promise.all(results.map(async (item) => {
|
||||
return await processItem(item);
|
||||
}));
|
||||
if (count > 0) {
|
||||
mailUnknowCount.value = count;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
const fetchMailUnknowData = async (limit, offset) => {
|
||||
return await api.fetch(
|
||||
`/admin/mails_unknow`
|
||||
+ `?limit=${limit}`
|
||||
+ `&offset=${offset}`
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -58,49 +20,11 @@ onMounted(async () => {
|
||||
showAdminAuth.value = true;
|
||||
return;
|
||||
}
|
||||
await fetchMailUnknowData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-button @click="fetchMailUnknowData" type="primary" ghost>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
<n-list hoverable clickable>
|
||||
<div style="display: inline-block; margin-bottom: 10px;">
|
||||
<n-pagination v-model:page="mailUnknowPage" v-model:page-size="mailUnknowPageSize"
|
||||
:item-count="mailUnknowCount" simple>
|
||||
<template #prefix="{ itemCount }">
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-list-item v-for="row in mailUnknowData" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.subject">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
TO: {{ row.address }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</template>
|
||||
<div v-html="row.message"></div>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
<div v-if="adminAuth">
|
||||
<MailBox :enableUserDeleteEmail="false" :fetchMailData="fetchMailUnknowData" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-pagination {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,29 +8,43 @@ import { api } from '../../api'
|
||||
|
||||
const { localeCache, adminAuth, showAdminAuth } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const cleanMailsDays = ref(30)
|
||||
const cleanUnknowMailsDays = ref(30)
|
||||
const cleanAddressDays = ref(30)
|
||||
const cleanSendBoxDays = ref(30)
|
||||
const cleanupModel = ref({
|
||||
enableMailsAutoCleanup: false,
|
||||
cleanMailsDays: 30,
|
||||
enableUnknowMailsAutoCleanup: false,
|
||||
cleanUnknowMailsDays: 30,
|
||||
enableAddressAutoCleanup: false,
|
||||
cleanAddressDays: 30,
|
||||
enableSendBoxAutoCleanup: false,
|
||||
cleanSendBoxDays: 30,
|
||||
})
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
tip: 'Please input the cleanup days',
|
||||
mailBoxTip: "Clean up {day} days ago mailbox",
|
||||
mailUnknowTip: "Clean up {day} days ago mails with unknow receiver",
|
||||
addressUnActiveTip: "Clean up {day} days ago unactive address",
|
||||
sendBoxTip: "Clean up {day} days ago sendbox",
|
||||
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: '请输入清理天数',
|
||||
mailBoxTip: "清理{day}天前的收件箱",
|
||||
mailUnknowTip: "清理{day}天前的无收件人邮件",
|
||||
addressUnActiveTip: "清理{day}天前的未活动地址",
|
||||
sendBoxTip: "清理{day}天前的发件箱",
|
||||
mailBoxLabel: '收件箱清理天数',
|
||||
mailUnknowLabel: "无收件人邮件清理天数",
|
||||
addressUnActiveLabel: "未活跃地址清理天数",
|
||||
sendBoxLabel: "发件箱清理天数",
|
||||
autoCleanup: "自动清理",
|
||||
cleanupSuccess: "清理成功",
|
||||
cleanupNow: "立即清理",
|
||||
save: "保存",
|
||||
cronTip: "启用定时清理, 需在 worker 配置 [crons] 参数, 请参考文档",
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -47,11 +61,33 @@ const cleanup = async (cleanType, cleanDays) => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch('/admin/auto_cleanup');
|
||||
if (res) Object.assign(cleanupModel.value, res);
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.fetch('/admin/auto_cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(cleanupModel.value)
|
||||
});
|
||||
message.success(t('cleanupSuccess'));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!adminAuth.value) {
|
||||
showAdminAuth.value = true;
|
||||
return;
|
||||
}
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -59,42 +95,62 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card>
|
||||
<div class="item">
|
||||
<n-input-number v-model:value="cleanMailsDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('mails', cleanMailsDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('mailBoxTip', { day: cleanMailsDays }) }}
|
||||
<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">
|
||||
{{ t('autoCleanup') }}
|
||||
</n-checkbox>
|
||||
<n-input-number v-model:value="cleanupModel.cleanMailsDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('mails', cleanupModel.cleanMailsDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('cleanupNow') }}
|
||||
</n-button>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('mailUnknowLabel')">
|
||||
<n-checkbox v-model:checked="cleanupModel.enableUnknowMailsAutoCleanup">
|
||||
{{ t('autoCleanup') }}
|
||||
</n-checkbox>
|
||||
<n-input-number v-model:value="cleanupModel.cleanUnknowMailsDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('mails_unknow', cleanupModel.cleanUnknowMailsDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('cleanupNow') }}
|
||||
</n-button>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('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-checkbox v-model:checked="cleanupModel.enableSendBoxAutoCleanup">
|
||||
{{ t('autoCleanup') }}
|
||||
</n-checkbox>
|
||||
<n-input-number v-model:value="cleanupModel.cleanSendBoxDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('sendbox', cleanupModel.cleanSendBoxDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('cleanupNow') }}
|
||||
</n-button>
|
||||
</n-form-item-row>
|
||||
<n-button @click="save" type="primary" block :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="item">
|
||||
<n-input-number v-model:value="cleanUnknowMailsDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('mails_unknow', cleanUnknowMailsDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('mailUnknowTip', { day: cleanUnknowMailsDays }) }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="item">
|
||||
<n-input-number v-model:value="cleanAddressDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('address', cleanAddressDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('addressUnActiveTip', { day: cleanAddressDays }) }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="item">
|
||||
<n-input-number v-model:value="cleanSendBoxDays" :placeholder="t('tip')" />
|
||||
<n-button @click="cleanup('sendbox', cleanSendBoxDays)">
|
||||
<template #icon>
|
||||
<n-icon :component="CleaningServicesFilled" />
|
||||
</template>
|
||||
{{ t('sendBoxTip', { day: cleanSendBoxDays }) }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -111,6 +167,10 @@ onMounted(async () => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-alert {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
margin: 10px;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, settings, adminAuth, adminSendBoxTabAddress } = useGlobalState()
|
||||
const { localeCache, adminAuth, adminSendBoxTabAddress, showAdminAuth } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
@@ -44,15 +44,12 @@ const curRow = ref({})
|
||||
const showModal = ref(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!adminSendBoxTabAddress.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { results, count: addressCount } = await api.fetch(
|
||||
`/admin/sendbox`
|
||||
+ `?address=${adminSendBoxTabAddress.value}`
|
||||
+ `&limit=${pageSize.value}`
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (adminSendBoxTabAddress.value ? `&address=${adminSendBoxTabAddress.value}` : '')
|
||||
);
|
||||
data.value = results.map((item) => {
|
||||
try {
|
||||
@@ -105,7 +102,7 @@ const columns = [
|
||||
h(NButton,
|
||||
{
|
||||
type: 'success',
|
||||
ghost: true,
|
||||
tertiary: true,
|
||||
onClick: () => {
|
||||
showModal.value = true;
|
||||
curRow.value = row;
|
||||
@@ -132,13 +129,13 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="settings.address">
|
||||
<n-modal v-model:show="showModal" preset="dialog">
|
||||
<pre>{{ curRow.raw }}</pre>
|
||||
<div>
|
||||
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
|
||||
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
|
||||
</n-modal>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="adminSendBoxTabAddress" />
|
||||
<n-button @click="fetchData" type="primary" ghost>
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
|
||||
@@ -14,6 +14,7 @@ const { t } = useI18n({
|
||||
en: {
|
||||
address: 'Address',
|
||||
success: 'Success',
|
||||
is_enabled: 'Is Enabled',
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
modify: 'Modify',
|
||||
@@ -22,12 +23,13 @@ const { t } = useI18n({
|
||||
itemCount: 'itemCount',
|
||||
modalTip: 'Please input the sender balance',
|
||||
balance: 'Balance',
|
||||
refresh: 'Refresh',
|
||||
query: 'Query',
|
||||
ok: 'OK'
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
success: '成功',
|
||||
is_enabled: '是否启用',
|
||||
enable: '启用',
|
||||
disable: '禁用',
|
||||
modify: '修改',
|
||||
@@ -36,7 +38,7 @@ const { t } = useI18n({
|
||||
itemCount: '总数',
|
||||
modalTip: '请输入发件额度',
|
||||
balance: '余额',
|
||||
refresh: '刷新',
|
||||
query: '查询',
|
||||
ok: '确定'
|
||||
}
|
||||
}
|
||||
@@ -51,6 +53,7 @@ const showModal = ref(false)
|
||||
const senderBalance = ref(0)
|
||||
const senderEnabled = ref(false)
|
||||
|
||||
const addressQuery = ref('')
|
||||
|
||||
const updateData = async () => {
|
||||
try {
|
||||
@@ -77,6 +80,7 @@ const fetchData = async () => {
|
||||
`/admin/address_sender`
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (addressQuery.value ? `&address=${addressQuery.value}` : '')
|
||||
);
|
||||
data.value = results;
|
||||
if (addressCount > 0) {
|
||||
@@ -106,7 +110,7 @@ const columns = [
|
||||
key: "balance"
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
title: t('is_enabled'),
|
||||
key: "enabled",
|
||||
render(row) {
|
||||
return h('div', [
|
||||
@@ -122,7 +126,7 @@ const columns = [
|
||||
h(NButton,
|
||||
{
|
||||
type: 'success',
|
||||
ghost: true,
|
||||
tertiary: true,
|
||||
onClick: () => {
|
||||
showModal.value = true;
|
||||
curRow.value = row;
|
||||
@@ -161,22 +165,23 @@ onMounted(async () => {
|
||||
<n-input-number v-model:value="senderBalance" :min="0" :max="1000" />
|
||||
</n-form-item>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="updateData()" size="small" tertiary round type="primary">
|
||||
<n-button :loading="loading" @click="updateData()" size="small" tertiary type="primary">
|
||||
{{ t('ok') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="addressQuery" />
|
||||
<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="fetchData" type="primary" size="small" ghost>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" />
|
||||
|
||||
@@ -14,14 +14,14 @@ 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: '发送邮件总数'
|
||||
}
|
||||
|
||||
289
frontend/src/views/admin/UserManagement.vue
Normal file
289
frontend/src/views/admin/UserManagement.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<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 { localeCache, loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
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>
|
||||
<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>
|
||||
130
frontend/src/views/admin/UserSettings.vue
Normal file
130
frontend/src/views/admin/UserSettings.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { localeCache, loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
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>
|
||||
@@ -17,7 +17,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>
|
||||
82
frontend/src/views/common/Appearance.vue
Normal file
82
frontend/src/views/common/Appearance.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
|
||||
const {
|
||||
localeCache, mailboxSplitSize, useIframeShowMail, preferShowTextMail,
|
||||
globalTabplacement, useSideMargin
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
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 :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 :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>
|
||||
212
frontend/src/views/common/Login.vue
Normal file
212
frontend/src/views/common/Login.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
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'
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
jwt, localeCache, loading, openSettings,
|
||||
showAddressCredential, userSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const tabValue = ref('signin')
|
||||
const credential = ref('')
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const cfToken = ref("")
|
||||
|
||||
const login = async () => {
|
||||
if (!credential.value) {
|
||||
message.error(t('credentialInput'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
jwt.value = credential.value;
|
||||
await api.getSettings();
|
||||
try {
|
||||
await api.bindUserAddress();
|
||||
} catch (error) {
|
||||
message.error(`${t('bindUserAddressError')}: ${error.message}`);
|
||||
}
|
||||
await router.push("/");
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
login: 'Login',
|
||||
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
|
||||
getNewEmail: 'Create New Email',
|
||||
getNewEmailTip1: 'Please input the email you want to use. only allow ., 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.',
|
||||
credential: 'Email Address Credential',
|
||||
ok: 'OK',
|
||||
generateName: 'Generate Fake Name',
|
||||
help: 'Help',
|
||||
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: '创建新邮箱',
|
||||
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
|
||||
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
|
||||
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
|
||||
credential: '邮箱地址凭据',
|
||||
ok: '确定',
|
||||
generateName: '生成随机名字',
|
||||
help: '帮助',
|
||||
credentialInput: '请输入邮箱地址凭据',
|
||||
bindUserInfo: '已登录用户, 登录未绑定邮箱或创建新邮箱地址将绑定到当前用户',
|
||||
bindUserAddressError: '绑定邮箱地址到用户时错误',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const generateNameLoading = ref(false);
|
||||
const generateName = async () => {
|
||||
try {
|
||||
generateNameLoading.value = true;
|
||||
const { faker } = await import('https://esm.sh/@faker-js/faker');
|
||||
emailName.value = faker.internet.email()
|
||||
.split('@')[0]
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/[^a-zA-Z0-9.]/g, '')
|
||||
.toLowerCase();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
generateNameLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const newEmail = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/api/new_address`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: emailName.value,
|
||||
domain: emailDomain.value,
|
||||
cf_token: cfToken.value,
|
||||
}),
|
||||
});
|
||||
jwt.value = res["jwt"];
|
||||
await api.getSettings();
|
||||
await router.push("/");
|
||||
showAddressCredential.value = true;
|
||||
try {
|
||||
await api.bindUserAddress();
|
||||
} catch (error) {
|
||||
message.error(`${t('bindUserAddressError')}: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
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('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>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableUserCreateEmail" name="register" :tab="t('getNewEmail')">
|
||||
<n-spin :show="generateNameLoading">
|
||||
<n-form>
|
||||
<span>
|
||||
<p>{{ t("getNewEmailTip1") }}</p>
|
||||
<p>{{ t("getNewEmailTip2") }}</p>
|
||||
<p>{{ t("getNewEmailTip3") }}</p>
|
||||
</span>
|
||||
<n-button @click="generateName" style="margin-bottom: 10px;">
|
||||
{{ t('generateName') }}
|
||||
</n-button>
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-model:value="emailName" />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<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">
|
||||
<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 :show-icon="false">
|
||||
<span>{{ t('pleaseGetNewEmail') }}</span>
|
||||
</n-alert>
|
||||
<AdminContact />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.n-alert {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.n-form .n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.n-form {
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
108
frontend/src/views/index/AccountSettings.vue
Normal file
108
frontend/src/views/index/AccountSettings.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import Appearance from '../common/Appearance.vue'
|
||||
|
||||
const {
|
||||
jwt, localeCache, 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',
|
||||
messages: {
|
||||
en: {
|
||||
logout: "Logout",
|
||||
delteAccount: "Delete Account",
|
||||
showAddressCredential: 'Show Address Credential',
|
||||
logoutConfirm: 'Are you sure to logout?',
|
||||
delteAccount: "Delete Account",
|
||||
delteAccountConfirm: "Are you sure to delete your account and all emails for this account?",
|
||||
},
|
||||
zh: {
|
||||
logout: '退出登录',
|
||||
delteAccount: "删除账户",
|
||||
showAddressCredential: '查看邮箱地址凭证',
|
||||
logoutConfirm: '确定要退出登录吗?',
|
||||
delteAccount: "删除账户",
|
||||
delteAccountConfirm: "确定要删除你的账户和其中的所有邮件吗?",
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const logout = async () => {
|
||||
jwt.value = '';
|
||||
await router.push('/')
|
||||
location.reload()
|
||||
}
|
||||
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/delete_address`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
jwt.value = '';
|
||||
await router.push('/')
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card>
|
||||
<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') }}
|
||||
</n-button>
|
||||
<n-button @click="showDelteAccount = true" type="error" secondary block strong>
|
||||
{{ t('delteAccount') }}
|
||||
</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>
|
||||
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
|
||||
<p>{{ t('delteAccountConfirm') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
|
||||
{{ t('delteAccount') }}
|
||||
</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>
|
||||
128
frontend/src/views/index/AddressBar.vue
Normal file
128
frontend/src/views/index/AddressBar.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
import useClipboard from 'vue-clipboard3'
|
||||
import { onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Copy, User } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import Login from '../common/Login.vue'
|
||||
|
||||
const { toClipboard } = useClipboard()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
jwt, localeCache, settings, showAddressCredential, openSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
messages: {
|
||||
en: {
|
||||
yourAddress: 'Your email address is',
|
||||
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.',
|
||||
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
|
||||
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: {
|
||||
yourAddress: '你的邮箱地址是',
|
||||
ok: '确定',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
fetchAddressError: '邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。',
|
||||
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
userLogin: '用户登录',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await toClipboard(settings.value.address)
|
||||
message.success(t('copied'));
|
||||
} catch (e) {
|
||||
message.error(e.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
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 v-if="settings.has_v1_mails" type="warning" :show-icon="false" closable>
|
||||
<span>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" href="https://mail-v1.awsl.uk">
|
||||
<b>{{ t('mailV1Alert') }} </b>
|
||||
</n-button>
|
||||
</span>
|
||||
</n-alert>
|
||||
<n-alert type="info" :show-icon="false">
|
||||
<span>
|
||||
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
|
||||
<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="false" closable>
|
||||
<span>{{ t('fetchAddressError') }}</span>
|
||||
</n-alert>
|
||||
<Login />
|
||||
<n-divider />
|
||||
<n-button @click="router.push('/user')" type="primary" block secondary strong>
|
||||
<template #icon>
|
||||
<n-icon :component="User" />
|
||||
</template>
|
||||
{{ t('userLogin') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
<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>
|
||||
@@ -2,8 +2,8 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const message = useMessage()
|
||||
const sourcePrefix = ref("")
|
||||
@@ -41,17 +41,22 @@ const { t } = useI18n({
|
||||
}
|
||||
});
|
||||
|
||||
const getSettings = async () => {
|
||||
sourcePrefix.value = settings.value.auto_reply.source_prefix || ""
|
||||
enableAutoReply.value = settings.value.auto_reply.enabled || false
|
||||
name.value = settings.value.auto_reply.name || ""
|
||||
autoReplyMessage.value = settings.value.auto_reply.message || ""
|
||||
subject.value = settings.value.auto_reply.subject || ""
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch("/api/auto_reply")
|
||||
sourcePrefix.value = res.source_prefix || ""
|
||||
enableAutoReply.value = res.enabled || false
|
||||
name.value = res.name || ""
|
||||
autoReplyMessage.value = res.message || ""
|
||||
subject.value = res.subject || ""
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
const saveData = async () => {
|
||||
try {
|
||||
await api.fetch("/api/settings", {
|
||||
await api.fetch("/api/auto_reply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
auto_reply: {
|
||||
@@ -70,7 +75,7 @@ const saveSettings = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getSettings()
|
||||
await fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -78,7 +83,7 @@ onMounted(async () => {
|
||||
<div class="center">
|
||||
<n-card v-if="settings.address" :title='t("settings")'>
|
||||
<div class="right">
|
||||
<n-button type="primary" @click="saveSettings">{{ t('save') }}</n-button>
|
||||
<n-button type="primary" @click="saveData">{{ t('save') }}</n-button>
|
||||
</div>
|
||||
<div class="left">
|
||||
<n-form-item :label="t('enableAutoReply')" label-placement="left">
|
||||
@@ -128,8 +128,8 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div v-if="settings.address">
|
||||
<n-modal v-model:show="showModal" preset="dialog">
|
||||
<pre>{{ curRow.raw }}</pre>
|
||||
<n-modal v-model:show="showModal" preset="dialog" style="width: 100%;">
|
||||
<pre style="overflow: auto;">{{ curRow.raw }}</pre>
|
||||
</n-modal>
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
|
||||
@@ -138,7 +138,7 @@ onMounted(async () => {
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
<template #suffix>
|
||||
<n-button @click="fetchData" type="primary" size="small" ghost>
|
||||
<n-button @click="fetchData" type="primary" size="small" tertiary>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</template>
|
||||
@@ -1,30 +1,19 @@
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { DomEditor } from '@wangeditor/editor'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
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 mailModel = useStorage('mailModelCache', {
|
||||
fromName: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
subject: "",
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
})
|
||||
|
||||
const { settings } = useGlobalState()
|
||||
const { settings, sendMailModel, indexTab } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: 'zh',
|
||||
@@ -81,15 +70,15 @@ const send = async () => {
|
||||
method: 'POST',
|
||||
body:
|
||||
JSON.stringify({
|
||||
from_name: mailModel.value.fromName,
|
||||
to_name: mailModel.value.toName,
|
||||
to_mail: mailModel.value.toMail,
|
||||
subject: mailModel.value.subject,
|
||||
is_html: mailModel.value.contentType != 'text',
|
||||
content: mailModel.value.content,
|
||||
from_name: sendMailModel.value.fromName,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: sendMailModel.value.toMail,
|
||||
subject: sendMailModel.value.subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content: sendMailModel.value.content,
|
||||
})
|
||||
})
|
||||
mailModel.value = {
|
||||
sendMailModel.value = {
|
||||
fromName: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
@@ -101,7 +90,7 @@ const send = async () => {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
message.success(t("successSend"));
|
||||
router.push('/sendbox');
|
||||
indexTab.value = 'sendbox'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,58 +144,59 @@ 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">
|
||||
<n-button type="primary" @click="send">{{ t('send') }}</n-button>
|
||||
</div>
|
||||
<div class="left">
|
||||
<n-form :model="mailModel">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="mailModel.fromName" />
|
||||
<n-input v-model:value="sendMailModel.fromName" />
|
||||
<n-input :value="settings.address" disabled />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="mailModel.toName" />
|
||||
<n-input v-model:value="mailModel.toMail" />
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="mailModel.subject" />
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="mailModel.contentType">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="mailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card v-if="isPreview">
|
||||
<div v-html="mailModel.content" />
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="mailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="mailModel.content"
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else type="textarea" v-model:value="mailModel.content" :autosize="{
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
170
frontend/src/views/user/AddressManagement.vue
Normal file
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'
|
||||
|
||||
const { localeCache, jwt } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
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('/');
|
||||
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>
|
||||
46
frontend/src/views/user/BindAddress.vue
Normal file
46
frontend/src/views/user/BindAddress.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<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, localeCache, userSettings, } = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
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>
|
||||
76
frontend/src/views/user/UserBar.vue
Normal file
76
frontend/src/views/user/UserBar.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<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 {
|
||||
localeCache, userSettings, userJwt, userOpenSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
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>
|
||||
248
frontend/src/views/user/UserLogin.vue
Normal file
248
frontend/src/views/user/UserLogin.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<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, localeCache, userTab, userOpenSettings } = useGlobalState()
|
||||
const message = useMessage();
|
||||
const router = useRouter();
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
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>
|
||||
83
frontend/src/views/user/UserSettings.vue
Normal file
83
frontend/src/views/user/UserSettings.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<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, localeCache, userSettings, } = useGlobalState()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const showLogout = ref(false)
|
||||
|
||||
const { t } = useI18n({
|
||||
locale: localeCache.value || 'zh',
|
||||
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>
|
||||
2
smtp_proxy_server/.env.example
Normal file
2
smtp_proxy_server/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
proxy_url=https://temp-email-api.xxx.xxx
|
||||
port=8025
|
||||
161
smtp_proxy_server/.gitignore
vendored
Normal file
161
smtp_proxy_server/.gitignore
vendored
Normal file
@@ -0,0 +1,161 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
test*
|
||||
12
smtp_proxy_server/docker-compose.yaml
Normal file
12
smtp_proxy_server/docker-compose.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
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"
|
||||
environment:
|
||||
- proxy_url=https://temp-email-api.xxx.xxx
|
||||
- port=8025
|
||||
7
smtp_proxy_server/dockerfile
Normal file
7
smtp_proxy_server/dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /requirements.txt
|
||||
RUN python3 -m pip install -r /requirements.txt
|
||||
COPY . /app
|
||||
ENTRYPOINT [ "python3", "server.py" ]
|
||||
3
smtp_proxy_server/requirements.txt
Normal file
3
smtp_proxy_server/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
aiosmtpd==1.4.5
|
||||
pydantic-settings==2.2.1
|
||||
requests==2.31.0
|
||||
147
smtp_proxy_server/server.py
Normal file
147
smtp_proxy_server/server.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import email
|
||||
import requests
|
||||
|
||||
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',
|
||||
)
|
||||
|
||||
_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):
|
||||
fail_nothandled = AuthResult(success=False, handled=False)
|
||||
if mechanism not in ("LOGIN", "PLAIN"):
|
||||
_logger.warning(f"Unsupported mechanism {mechanism}")
|
||||
return fail_nothandled
|
||||
if not isinstance(auth_data, LoginPassword):
|
||||
_logger.warning(f"Invalid auth data {auth_data}")
|
||||
return fail_nothandled
|
||||
return AuthResult(success=True, auth_data=auth_data)
|
||||
|
||||
async def handle_DATA(self, server: SMTP, session: Session, envelope: Envelope) -> str:
|
||||
_logger.info(
|
||||
f"handle_DATA from {envelope.mail_from} to {envelope.rcpt_tos}"
|
||||
)
|
||||
if not isinstance(session.auth_data, LoginPassword):
|
||||
return '530 Authentication required'
|
||||
if len(envelope.rcpt_tos) != 1:
|
||||
return '500 Only one recipient allowed'
|
||||
# Only one recipient allowed
|
||||
to_mail = envelope.rcpt_tos[0]
|
||||
# Parse email
|
||||
msg = email.message_from_string(envelope.content)
|
||||
content_list = []
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
content_type = part.get_content_type()
|
||||
payload = part.get_payload(decode=True)
|
||||
if content_type not in ["text/plain", "text/html"]:
|
||||
_logger.warning(f"Skipping {content_type}")
|
||||
continue
|
||||
if not payload:
|
||||
continue
|
||||
content_list.append({
|
||||
"type": content_type,
|
||||
"value": payload.decode()
|
||||
})
|
||||
elif msg.get_content_type() in ["text/plain", "text/html"] and msg.get_payload(decode=True):
|
||||
content_list.append({
|
||||
"type": msg.get_content_type(),
|
||||
"value": msg.get_payload(decode=True).decode()
|
||||
})
|
||||
|
||||
if not content_list:
|
||||
return '500 Invalid content'
|
||||
body = max(
|
||||
content_list,
|
||||
key=lambda x: (x["type"] == "text/html", len(x["value"]))
|
||||
)
|
||||
from_name, _ = email.utils.parseaddr(
|
||||
str(email.header.make_header(
|
||||
email.header.decode_header(msg['From'])
|
||||
))
|
||||
)
|
||||
to_mail_map = {}
|
||||
for to in str(email.header.make_header(
|
||||
email.header.decode_header(msg['To'])
|
||||
)).split(","):
|
||||
tmp_to_name, tmp_to_mail = email.utils.parseaddr(to)
|
||||
to_mail_map[tmp_to_mail] = tmp_to_name
|
||||
_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,
|
||||
"subject": str(email.header.make_header(
|
||||
email.header.decode_header(msg['Subject'])
|
||||
)),
|
||||
"is_html": body["type"] == "text/html",
|
||||
"content": body["value"],
|
||||
}
|
||||
_logger.info(f"Send mail {send_body}")
|
||||
try:
|
||||
res = requests.post(
|
||||
f"{settings.proxy_url}/external/api/send_mail",
|
||||
json=send_body, headers={
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
if res.status_code != 200:
|
||||
_logger.error(
|
||||
"Failed to send mail "
|
||||
f"code=[{res.status_code}] text=[{res.text}]"
|
||||
)
|
||||
return f'500 Internal server error code=[{res.status_code}] text=[{res.text}]'
|
||||
except Exception as e:
|
||||
_logger.error(e)
|
||||
return '500 Internal server error'
|
||||
|
||||
return '250 OK'
|
||||
|
||||
|
||||
settings = Settings()
|
||||
handler = CustomSMTPHandler()
|
||||
server = Controller(
|
||||
handler,
|
||||
hostname="",
|
||||
port=settings.port,
|
||||
auth_require_tls=False,
|
||||
decode_data=True,
|
||||
authenticator=handler.authenticator,
|
||||
auth_exclude_mechanism=["DONT"]
|
||||
)
|
||||
|
||||
|
||||
async def start():
|
||||
_logger.info(f"Starting server settings[{settings}]")
|
||||
server.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
task = loop.create_task(start())
|
||||
try:
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
_logger.info("Got KeyboardInterrupt, stopping")
|
||||
server.stop()
|
||||
@@ -126,11 +126,22 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
{ text: '开发中', link: 'github-action' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '附加功能',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '配置 SMTP 代理服务', link: 'feature/config-smtp-proxy' },
|
||||
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
|
||||
{ text: '查看邮件 API', link: 'feature/mail-api' },
|
||||
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '功能简介',
|
||||
collapsed: false,
|
||||
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
|
||||
```
|
||||
|
||||

|
||||
@@ -56,12 +58,16 @@ pnpm run deploy
|
||||
|
||||
`wrangler.toml`
|
||||
|
||||
```bash
|
||||
```toml
|
||||
name = "cloudflare_temp_email"
|
||||
main = "src/worker.js"
|
||||
compatibility_date = "2023-08-14"
|
||||
node_compat = true
|
||||
|
||||
# enable cron if you want set auto clean up
|
||||
# [triggers]
|
||||
# crons = [ "0 0 * * *" ]
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp" # The mailbox name prefix to be processed
|
||||
# If you want your site to be private, uncomment below and change your password
|
||||
@@ -73,12 +79,19 @@ PREFIX = "tmp" # The mailbox name prefix to be processed
|
||||
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # your domain name
|
||||
JWT_SECRET = "xxx" # Key used to generate jwt
|
||||
BLACK_LIST = "" # Blacklist, used to filter senders, comma separated
|
||||
# Allow users to create email addresses
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
# Allow users to delete messages
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
# Allow automatic replies to emails
|
||||
ENABLE_AUTO_REPLY = false
|
||||
# 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
|
||||
@@ -88,6 +101,11 @@ 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"
|
||||
|
||||
@@ -23,6 +23,6 @@ features:
|
||||
details: 支持 password 登录邮箱,使用访问密码可作为私人站点,支持附件功能
|
||||
- title: 使用 rust wasm 解析邮件
|
||||
details: 使用 rust wasm 解析邮件,支持邮件各种RFC标准,支持附件, 速度极快
|
||||
- title: 支持发送邮件
|
||||
details: 支持通过域名邮箱发送 txt 或者 html 邮件,支持 DKIM 签名
|
||||
- title: 支持发送邮件(UI/API/SMTP)
|
||||
details: 支持通过域名邮箱发送 txt 或者 html 邮件,支持 DKIM 签名, UI/API/SMTP 发送邮件
|
||||
---
|
||||
|
||||
BIN
vitepress-docs/docs/public/feature/admin-user-management.png
Normal file
BIN
vitepress-docs/docs/public/feature/admin-user-management.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
vitepress-docs/docs/public/feature/admin-user-page.png
Normal file
BIN
vitepress-docs/docs/public/feature/admin-user-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
vitepress-docs/docs/public/ui_install/worker-kv-bind.png
Normal file
BIN
vitepress-docs/docs/public/ui_install/worker-kv-bind.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
vitepress-docs/docs/public/ui_install/worker-kv.png
Normal file
BIN
vitepress-docs/docs/public/ui_install/worker-kv.png
Normal file
Binary file not shown.
|
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
|
||||
```
|
||||
|
||||
@@ -8,6 +8,17 @@ pnpm install
|
||||
cp wrangler.toml.template wrangler.toml
|
||||
```
|
||||
|
||||
## 创建 KV 缓存
|
||||
|
||||
> [!NOTE]
|
||||
> 如果你要启用注册用户功能,并需要发送邮件验证,则需要创建 `KV` 缓存, 不需要可跳过此步骤
|
||||
|
||||
通过命令行创建 KV 缓存,或者在 Cloudflare 控制台创建,然后复制对应配置到 `wrangler.toml` 文件中
|
||||
|
||||
```bash
|
||||
wrangler kv:namespace create DEV
|
||||
```
|
||||
|
||||
## 修改 `wrangler.toml` 配置文件
|
||||
|
||||
```toml
|
||||
@@ -20,6 +31,10 @@ compatibility_date = "2023-12-01"
|
||||
# ]
|
||||
node_compat = true
|
||||
|
||||
# 如果你想要使用定时任务清理邮件,取消下面的注释,并修改 cron 表达式
|
||||
# [triggers]
|
||||
# crons = [ "0 0 * * *" ]
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空字符串
|
||||
# 如果你想要你的网站私有,取消下面的注释,并修改密码
|
||||
@@ -31,12 +46,19 @@ PREFIX = "tmp" # 要处理的邮箱名称前缀,不需要后缀可配置为空
|
||||
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"] # 你的域名, 支持多个域名
|
||||
JWT_SECRET = "xxx" # 用于生成 jwt 的密钥, jwt 用于给用户登录以及鉴权
|
||||
BLACK_LIST = "" # 黑名单,用于过滤发件人,逗号分隔
|
||||
# 是否允许用户创建邮件, 不配置则不允许
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
# 允许用户删除邮件, 不配置则不允许
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
# 允许自动回复邮件
|
||||
ENABLE_AUTO_REPLY = false
|
||||
# 前端界面页脚文本
|
||||
# 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 的内容
|
||||
@@ -47,6 +69,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"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Admin 用户相关
|
||||
|
||||
默认不允许用户注册,可通过
|
||||
|
||||
## 用户管理页面
|
||||
|
||||

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

|
||||
41
vitepress-docs/docs/zh/guide/feature/config-smtp-proxy.md
Normal file
41
vitepress-docs/docs/zh/guide/feature/config-smtp-proxy.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 搭建 SMTP 代理服务
|
||||
|
||||
## 为什么需要 SMTP 代理服务
|
||||
|
||||
SMTP 的应用场景更加广泛
|
||||
|
||||
## 如何搭建 SMTP 代理服务
|
||||
|
||||
### Local Run
|
||||
|
||||
```bash
|
||||
cd smtp_proxy_server/
|
||||
# 复制配置文件, 并修改配置文件
|
||||
# 你的 worker 地址,proxy_url=https://temp-email-api.xxx.xxx
|
||||
# 你的 SMTP 服务端口,port=8025
|
||||
cp .env.example .env
|
||||
python3 -m venv venv
|
||||
./venv/bin/python3 -m pip install -r requirements.txt
|
||||
./venv/bin/python3 server.py
|
||||
```
|
||||
|
||||
### Docker Run
|
||||
|
||||
```bash
|
||||
cd smtp_proxy_server/
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
修改 docker-compose.yaml 中的环境变量, 注意选择合适的 `tag`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
smtp_proxy_server:
|
||||
image: ghcr.io/dreamhunter2333/cloudflare_temp_email/smtp_proxy_server:latest
|
||||
container_name: "smtp_proxy_server"
|
||||
ports:
|
||||
- "8025:8025"
|
||||
environment:
|
||||
- proxy_url=https://temp-email-api.xxx.xxx
|
||||
- port=8025
|
||||
```
|
||||
18
vitepress-docs/docs/zh/guide/feature/mail-api.md
Normal file
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.post(
|
||||
f"http://localhost:8787/api/mails?limit={limit}&offset={offset}`;",
|
||||
json=send_body, headers={
|
||||
"Authorization": f"Bearer {你的JWT密码}",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
```
|
||||
67
vitepress-docs/docs/zh/guide/feature/send-mail-api.md
Normal file
67
vitepress-docs/docs/zh/guide/feature/send-mail-api.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 发送邮件 API
|
||||
|
||||
## 通过 HTTP API 发送邮件
|
||||
|
||||
这是一个 `python` 的例子,使用 `requests` 库发送邮件。
|
||||
|
||||
```python
|
||||
send_body = {
|
||||
"from_name": "发件人名字",
|
||||
"to_name": "收件人名字",
|
||||
"to_mail": "收件人地址",
|
||||
"subject": "邮件主题",
|
||||
"is_html": False, # 根据内容设置是否为 HTML
|
||||
"content": "<邮件内容:html 或者 文本>",
|
||||
}
|
||||
|
||||
res = requests.post(
|
||||
"http://localhost:8787/api/send_mail",
|
||||
json=send_body, headers={
|
||||
"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"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## 通过 SMTP 发送邮件
|
||||
|
||||
请先参考 [配置 SMTP 代理](/zh/guide/feature/config-smtp-proxy.html)。
|
||||
|
||||
这是一个 `python` 的例子,使用 `smtplib` 库发送邮件。
|
||||
|
||||
`JWT令牌密码`: 即为邮箱登录密码,可以在 UI 界面中查看密码菜单中查看。
|
||||
|
||||
```python
|
||||
import smtplib
|
||||
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
|
||||
with smtplib.SMTP('localhost', 8025) as smtp:
|
||||
smtp.login("jwt", "此处填写你的JWT令牌密码")
|
||||
message = MIMEMultipart()
|
||||
message['From'] = "Me <me@awsl.uk>"
|
||||
message['To'] = "Admin <admin@awsl.uk>"
|
||||
message['Subject'] = "测试主题"
|
||||
message.attach(MIMEText("测试内容", 'html'))
|
||||
smtp.sendmail("me@awsl.uk", "admin@awsl.uk", message.as_string())
|
||||
```
|
||||
5
vitepress-docs/docs/zh/guide/feature/subdomain.md
Normal file
5
vitepress-docs/docs/zh/guide/feature/subdomain.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 配置子域名邮箱
|
||||
|
||||
参考
|
||||
|
||||
- [配置子域名邮箱](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/164#issuecomment-2082612710)
|
||||
@@ -1,5 +1,21 @@
|
||||
# 通过 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) 创建
|
||||
|
||||
1. 打开仓库的 `Actions` 页面,找到 `Deploy Backend Production` 和 `Deploy Frontend`,点击 `Run workflow` 选择分支手动部署
|
||||
|
||||
@@ -38,3 +38,10 @@
|
||||
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` 缓存, 不需要可跳过此步骤
|
||||
|
||||

|
||||

|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
"build": "wrangler deploy src/worker.js --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^3.52.0"
|
||||
"wrangler": "^3.53.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.2.7",
|
||||
"hono": "^4.3.0",
|
||||
"mimetext": "^3.0.24"
|
||||
}
|
||||
}
|
||||
|
||||
60
worker/pnpm-lock.yaml
generated
60
worker/pnpm-lock.yaml
generated
@@ -9,24 +9,24 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
hono:
|
||||
specifier: ^4.2.7
|
||||
version: 4.2.7
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0
|
||||
mimetext:
|
||||
specifier: ^3.0.24
|
||||
version: 3.0.24
|
||||
devDependencies:
|
||||
wrangler:
|
||||
specifier: ^3.52.0
|
||||
version: 3.52.0
|
||||
specifier: ^3.53.1
|
||||
version: 3.53.1
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/runtime-corejs3@7.24.4':
|
||||
resolution: {integrity: sha512-VOQOexSilscN24VEY810G/PqtpFvx/z6UqDIjIWbDe2368HhDLkYN5TYwaEz/+eRCUkhJ2WaNLLmQAlxzfWj4w==}
|
||||
'@babel/runtime-corejs3@7.24.5':
|
||||
resolution: {integrity: sha512-GWO0mgzNMLWaSYM4z4NVIuY0Cd1fl8cPnuetuddu5w/qGuvt5Y7oUi/kvvQGK9xgOkFJDQX2heIvTRn/OQ1XTg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.24.4':
|
||||
resolution: {integrity: sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==}
|
||||
'@babel/runtime@7.24.5':
|
||||
resolution: {integrity: sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@cloudflare/kv-asset-handler@0.3.2':
|
||||
@@ -226,8 +226,8 @@ packages:
|
||||
'@types/node-forge@1.3.11':
|
||||
resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==}
|
||||
|
||||
'@types/node@20.12.7':
|
||||
resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==}
|
||||
'@types/node@20.12.8':
|
||||
resolution: {integrity: sha512-NU0rJLJnshZWdE/097cdCBbyW1h4hEg0xpovcoAQYHl8dnEyp/NAOiE45pvc+Bd1Dt+2r94v2eGFpQJ4R7g+2w==}
|
||||
|
||||
acorn-walk@8.3.2:
|
||||
resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
|
||||
@@ -324,8 +324,8 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hono@4.2.7:
|
||||
resolution: {integrity: sha512-k1xHi86tJnRIVvqhFMBDGFKJ8r5O+bEsT4P59ZK59r0F300Xd910/r237inVfuT/VmE86RQQffX4OYNda6dLXw==}
|
||||
hono@4.3.0:
|
||||
resolution: {integrity: sha512-rf9142VLQNMVBj+BjVLISgDWDxnJGUIuX39dvqcdySwr2gTsPfsqW1twWDUjfwQNWm9hEn40MpDu9RFGUN+e8A==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
@@ -474,8 +474,8 @@ packages:
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
wrangler@3.52.0:
|
||||
resolution: {integrity: sha512-HR06jTym+yr7+CI3Ggld3nfp1OM9vSC7h4B8vwWHwhi5K0sYg8p44rxV514Gmsv9dkFHegkRP70SM3sjuuxxpQ==}
|
||||
wrangler@3.53.1:
|
||||
resolution: {integrity: sha512-bdMRQdHYdvowIwOhEMFkARIZUh56aDw7HLUZ/2JreBjj760osXE4Fc4L1TCkfRRBWgB6/LKF5LA4OcvORMYmHg==}
|
||||
engines: {node: '>=16.17.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -484,8 +484,8 @@ packages:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
|
||||
ws@8.16.0:
|
||||
resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==}
|
||||
ws@8.17.0:
|
||||
resolution: {integrity: sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
@@ -502,17 +502,17 @@ packages:
|
||||
youch@3.3.3:
|
||||
resolution: {integrity: sha512-qSFXUk3UZBLfggAW3dJKg0BMblG5biqSF8M34E06o5CSsZtH92u9Hqmj2RzGiHDi64fhe83+4tENFP2DB6t6ZA==}
|
||||
|
||||
zod@3.23.4:
|
||||
resolution: {integrity: sha512-/AtWOKbBgjzEYYQRNfoGKHObgfAZag6qUJX1VbHo2PRBgS+wfWagEY2mizjfyAPcGesrJOcx/wcl0L9WnVrHFw==}
|
||||
zod@3.23.6:
|
||||
resolution: {integrity: sha512-RTHJlZhsRbuA8Hmp/iNL7jnfc4nZishjsanDAfEY1QpDQZCahUp3xDzl+zfweE9BklxMUcgBgS1b7Lvie/ZVwA==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/runtime-corejs3@7.24.4':
|
||||
'@babel/runtime-corejs3@7.24.5':
|
||||
dependencies:
|
||||
core-js-pure: 3.37.0
|
||||
regenerator-runtime: 0.14.1
|
||||
|
||||
'@babel/runtime@7.24.4':
|
||||
'@babel/runtime@7.24.5':
|
||||
dependencies:
|
||||
regenerator-runtime: 0.14.1
|
||||
|
||||
@@ -628,9 +628,9 @@ snapshots:
|
||||
|
||||
'@types/node-forge@1.3.11':
|
||||
dependencies:
|
||||
'@types/node': 20.12.7
|
||||
'@types/node': 20.12.8
|
||||
|
||||
'@types/node@20.12.7':
|
||||
'@types/node@20.12.8':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
@@ -739,7 +739,7 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hono@4.2.7: {}
|
||||
hono@4.3.0: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
@@ -773,8 +773,8 @@ snapshots:
|
||||
|
||||
mimetext@3.0.24:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.24.4
|
||||
'@babel/runtime-corejs3': 7.24.4
|
||||
'@babel/runtime': 7.24.5
|
||||
'@babel/runtime-corejs3': 7.24.5
|
||||
js-base64: 3.7.7
|
||||
mime-types: 2.1.35
|
||||
|
||||
@@ -789,9 +789,9 @@ snapshots:
|
||||
stoppable: 1.1.0
|
||||
undici: 5.28.4
|
||||
workerd: 1.20240419.0
|
||||
ws: 8.16.0
|
||||
ws: 8.17.0
|
||||
youch: 3.3.3
|
||||
zod: 3.23.4
|
||||
zod: 3.23.6
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
@@ -881,7 +881,7 @@ snapshots:
|
||||
'@cloudflare/workerd-linux-arm64': 1.20240419.0
|
||||
'@cloudflare/workerd-windows-64': 1.20240419.0
|
||||
|
||||
wrangler@3.52.0:
|
||||
wrangler@3.53.1:
|
||||
dependencies:
|
||||
'@cloudflare/kv-asset-handler': 0.3.2
|
||||
'@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19)
|
||||
@@ -904,7 +904,7 @@ snapshots:
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
ws@8.16.0: {}
|
||||
ws@8.17.0: {}
|
||||
|
||||
xxhash-wasm@1.0.2: {}
|
||||
|
||||
@@ -914,4 +914,4 @@ snapshots:
|
||||
mustache: 4.2.0
|
||||
stacktracey: 2.1.8
|
||||
|
||||
zod@3.23.4: {}
|
||||
zod@3.23.6: {}
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import { Hono } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { getSendbox } from './send_mail_api'
|
||||
import { sendAdminInternalMail } from './utils'
|
||||
|
||||
const api = new Hono()
|
||||
|
||||
api.get('/admin/address', async (c) => {
|
||||
const { limit, offset, query } = c.req.query();
|
||||
if (!limit || limit < 0 || limit > 100) {
|
||||
return c.text("Invalid limit", 400)
|
||||
}
|
||||
if (!offset || offset < 0) {
|
||||
return c.text("Invalid offset", 400)
|
||||
}
|
||||
if (query) {
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT * FROM address where concat('${c.env.PREFIX}', name) like ? order by id desc limit ? offset ? `
|
||||
).bind(`%${query}%`, limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: addressCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address where concat('${c.env.PREFIX}', name) like ?`
|
||||
).bind(`%${query}%`).first();
|
||||
count = addressCount;
|
||||
}
|
||||
return c.json({
|
||||
results: results.map((r) => {
|
||||
r.name = c.env.PREFIX + r.name;
|
||||
return r;
|
||||
}),
|
||||
count: count
|
||||
})
|
||||
}
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT * FROM address order by id desc limit ? offset ? `
|
||||
).bind(limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: addressCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address`
|
||||
).first();
|
||||
count = addressCount;
|
||||
}
|
||||
return c.json({
|
||||
results: results.map((r) => {
|
||||
r.name = c.env.PREFIX + r.name;
|
||||
return r;
|
||||
}),
|
||||
count: count
|
||||
})
|
||||
})
|
||||
|
||||
api.delete('/admin/delete_address/:id', async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE id = ? `
|
||||
).bind(id).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to delete address", 500)
|
||||
}
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM mails WHERE address IN
|
||||
(select concat('${c.env.PREFIX}', name) from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text("Failed to delete mails", 500)
|
||||
}
|
||||
const { success: sendAccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address_id = ? `
|
||||
).bind(id).run();
|
||||
return c.json({
|
||||
success: success && mailSuccess && sendAccess
|
||||
})
|
||||
})
|
||||
|
||||
api.get('/admin/show_password/:id', async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const name = await c.env.DB.prepare(
|
||||
`SELECT name FROM address WHERE id = ? `
|
||||
).bind(id).first("name");
|
||||
// compute address
|
||||
const emailAddress = c.env.PREFIX + name
|
||||
const jwt = await Jwt.sign({
|
||||
address: emailAddress,
|
||||
address_id: id
|
||||
}, c.env.JWT_SECRET)
|
||||
return c.json({
|
||||
password: jwt
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
api.get('/admin/mails', async (c) => {
|
||||
const { address, limit, offset } = c.req.query();
|
||||
if (!limit || limit < 0 || limit > 100) {
|
||||
return c.text("Invalid limit", 400)
|
||||
}
|
||||
if (!offset || offset < 0) {
|
||||
return c.text("Invalid offset", 400)
|
||||
}
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails where address = ? order by id desc limit ? offset ?`
|
||||
).bind(address, limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: mailCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM raw_mails where address = ? `
|
||||
).bind(address).first();
|
||||
count = mailCount;
|
||||
}
|
||||
return c.json({
|
||||
results: results,
|
||||
count: count
|
||||
})
|
||||
});
|
||||
|
||||
api.get('/admin/mails_unknow', async (c) => {
|
||||
const { limit, offset } = c.req.query();
|
||||
if (!limit || limit < 0 || limit > 100) {
|
||||
return c.text("Invalid limit", 400)
|
||||
}
|
||||
if (!offset || offset < 0) {
|
||||
return c.text("Invalid offset", 400)
|
||||
}
|
||||
const { results } = await c.env.DB.prepare(`
|
||||
SELECT * FROM raw_mails
|
||||
where address NOT IN(select concat('${c.env.PREFIX}', name) from address)
|
||||
order by id desc limit ? offset ? `
|
||||
).bind(limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: mailCount } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM raw_mails
|
||||
where address NOT IN
|
||||
(select concat('${c.env.PREFIX}', name) from address)`
|
||||
).first();
|
||||
count = mailCount;
|
||||
}
|
||||
return c.json({
|
||||
results: results,
|
||||
count: count
|
||||
})
|
||||
});
|
||||
|
||||
api.get('/admin/address_sender', async (c) => {
|
||||
const { limit, offset } = c.req.query();
|
||||
if (!limit || limit < 0 || limit > 100) {
|
||||
return c.text("Invalid limit", 400)
|
||||
}
|
||||
if (!offset || offset < 0) {
|
||||
return c.text("Invalid offset", 400)
|
||||
}
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT * FROM address_sender order by id desc limit ? offset ? `
|
||||
).bind(limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: addressCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address_sender`
|
||||
).first();
|
||||
count = addressCount;
|
||||
}
|
||||
return c.json({
|
||||
results: results,
|
||||
count: count
|
||||
})
|
||||
})
|
||||
|
||||
api.post('/admin/address_sender', async (c) => {
|
||||
let { address, address_id, balance, enabled } = await c.req.json();
|
||||
if (!address_id) {
|
||||
return c.text("Invalid address_id", 400)
|
||||
}
|
||||
enabled = enabled ? 1 : 0;
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
|
||||
).bind(enabled, balance, address_id).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to update address sender", 500)
|
||||
}
|
||||
await sendAdminInternalMail(
|
||||
c, address, "Account Send Access Updated",
|
||||
`You send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}`
|
||||
);
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
})
|
||||
|
||||
api.get('/admin/sendbox', async (c) => {
|
||||
const { address, limit, offset } = c.req.query();
|
||||
return getSendbox(c, address, limit, offset);
|
||||
})
|
||||
|
||||
api.get('/admin/statistics', async (c) => {
|
||||
const { count: mailCountV1 } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM mails`
|
||||
).first();
|
||||
const { count: mailCount } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM raw_mails`
|
||||
).first();
|
||||
const { count: addressCount } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM address`
|
||||
).first();
|
||||
const { count: activeUserCount7days } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
|
||||
).first();
|
||||
const { count: sendMailCount } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM sendbox`
|
||||
).first();
|
||||
return c.json({
|
||||
mailCount: (mailCountV1 || 0) + (mailCount || 0),
|
||||
userCount: addressCount,
|
||||
activeUserCount7days: activeUserCount7days,
|
||||
sendMailCount: sendMailCount
|
||||
})
|
||||
});
|
||||
|
||||
api.post('/admin/cleanup', async (c) => {
|
||||
const { cleanType, cleanDays } = await c.req.json();
|
||||
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
|
||||
return c.text("Invalid cleanType or cleanDays", 400)
|
||||
}
|
||||
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
|
||||
switch (cleanType) {
|
||||
case "mails":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "mails_unknow":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE address NOT IN
|
||||
(select concat('${c.env.PREFIX}', name) from address) AND created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "address":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM address WHERE updated_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "sendbox":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM sendbox WHERE created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
default:
|
||||
return c.text("Invalid cleanType", 400)
|
||||
}
|
||||
return c.json({
|
||||
success: true
|
||||
})
|
||||
})
|
||||
|
||||
export { api }
|
||||
135
worker/src/admin_api/admin_user_api.js
Normal file
135
worker/src/admin_api/admin_user_api.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import { CONSTANTS } from '../constants';
|
||||
import { getJsonSetting, saveSetting, checkUserPassword, getDomains } from '../utils';
|
||||
import { UserSettings, GeoData, UserInfo } from "../models";
|
||||
|
||||
export default {
|
||||
getSetting: async (c) => {
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value);
|
||||
return c.json(settings)
|
||||
},
|
||||
saveSetting: async (c) => {
|
||||
const value = await c.req.json();
|
||||
const settings = new UserSettings(value);
|
||||
if (settings.enableMailVerify && !c.env.KV) {
|
||||
return c.text("Please enable KV first if you want to enable mail verify", 403)
|
||||
}
|
||||
if (settings.enableMailVerify) {
|
||||
const mailDomain = settings.verifyMailSender.split("@")[1];
|
||||
const domains = getDomains(c);
|
||||
if (!domains.includes(mailDomain)) {
|
||||
return c.text(`VerifyMailSender(${settings.verifyMailSender}) domain must in ${JSON.stringify(domains, null, 2)}`, 400)
|
||||
}
|
||||
}
|
||||
if (settings.maxAddressCount < 0) {
|
||||
return c.text("Invalid maxAddressCount", 400)
|
||||
}
|
||||
await saveSetting(c, CONSTANTS.USER_SETTINGS_KEY, JSON.stringify(settings));
|
||||
return c.json({ success: true })
|
||||
},
|
||||
getUsers: async (c) => {
|
||||
const { limit, offset, query } = c.req.query();
|
||||
if (!limit || limit < 0 || limit > 100) {
|
||||
return c.text("Invalid limit", 400)
|
||||
}
|
||||
if (!offset || offset < 0) {
|
||||
return c.text("Invalid offset", 400)
|
||||
}
|
||||
if (query) {
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT u.id, u.user_email, u.created_at, u.updated_at,`
|
||||
+ ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count`
|
||||
+ ` FROM users u`
|
||||
+ ` where u.user_email like ?`
|
||||
+ ` order by u.id desc limit ? offset ?`
|
||||
).bind(`%${query}%`, limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: userCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM users where user_email like ?`
|
||||
).bind(`%${query}%`).first();
|
||||
count = userCount;
|
||||
}
|
||||
return c.json({
|
||||
results: results,
|
||||
count: count
|
||||
})
|
||||
}
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT u.id, u.user_email, u.created_at, u.updated_at,`
|
||||
+ ` (SELECT COUNT(*) FROM users_address WHERE user_id = u.id) AS address_count`
|
||||
+ ` FROM users u`
|
||||
+ ` order by u.id desc limit ? offset ?`
|
||||
).bind(limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: userCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM users`
|
||||
).first();
|
||||
count = userCount;
|
||||
}
|
||||
return c.json({
|
||||
results: results,
|
||||
count: count
|
||||
})
|
||||
},
|
||||
createUser: async (c) => {
|
||||
const { email, password } = await c.req.json();
|
||||
if (!email || !password) {
|
||||
return c.text("Invalid email or password", 400)
|
||||
}
|
||||
// geo data
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
const geoData = new GeoData(reqIp, c.req.raw.cf);
|
||||
const userInfo = new UserInfo(geoData);
|
||||
try {
|
||||
checkUserPassword(password);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO users (user_email, password, user_info)`
|
||||
+ ` VALUES (?, ?, ?)`
|
||||
).bind(
|
||||
email, password, JSON.stringify(userInfo)
|
||||
).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to register", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("UNIQUE")) {
|
||||
return c.text("User already exists", 400)
|
||||
}
|
||||
return c.text(`Failed to register: ${e.message}`, 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
deleteUser: async (c) => {
|
||||
const { user_id } = c.req.param();
|
||||
if (!user_id) return c.text("Invalid user_id", 400);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM users WHERE id = ?`
|
||||
).bind(user_id).run();
|
||||
const { success: addressSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM users_address WHERE user_id = ?`
|
||||
).bind(user_id).run();
|
||||
if (!success || !addressSuccess) {
|
||||
return c.text("Failed to delete user", 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
resetPassword: async (c) => {
|
||||
const { user_id } = c.req.param();
|
||||
const { password } = await c.req.json();
|
||||
if (!user_id) return c.text("Invalid user_id", 400);
|
||||
try {
|
||||
checkUserPassword(password);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE users SET password = ? WHERE id = ?`
|
||||
).bind(password, user_id).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to reset password", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
return c.text(`Failed to reset password: ${e.message}`, 500)
|
||||
}
|
||||
return c.json({ success: true });
|
||||
},
|
||||
}
|
||||
28
worker/src/admin_api/cleanup_api.js
Normal file
28
worker/src/admin_api/cleanup_api.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cleanup } from '../common';
|
||||
import { CONSTANTS } from '../constants';
|
||||
import { getJsonSetting, saveSetting } from '../utils';
|
||||
import { CleanupSettings } from '../models';
|
||||
|
||||
export default {
|
||||
cleanup: async (c) => {
|
||||
const { cleanType, cleanDays } = await c.req.json();
|
||||
try {
|
||||
await cleanup(c, cleanType, cleanDays);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return c.text(`Failed to cleanup ${error.message}`, 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
getCleanup: async (c) => {
|
||||
const value = await getJsonSetting(c, CONSTANTS.AUTO_CLEANUP_KEY);
|
||||
const cleanupSetting = new CleanupSettings(value);
|
||||
return c.json(cleanupSetting)
|
||||
},
|
||||
saveCleanup: async (c) => {
|
||||
const value = await c.req.json();
|
||||
const cleanupSetting = new CleanupSettings(value);
|
||||
await saveSetting(c, CONSTANTS.AUTO_CLEANUP_KEY, JSON.stringify(cleanupSetting));
|
||||
return c.json({ success: true })
|
||||
}
|
||||
}
|
||||
291
worker/src/admin_api/index.js
Normal file
291
worker/src/admin_api/index.js
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Hono } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { sendAdminInternalMail, getJsonSetting, saveSetting } from '../utils'
|
||||
import { newAddress, handleListQuery } from '../common'
|
||||
import { CONSTANTS } from '../constants'
|
||||
import cleanup_api from './cleanup_api'
|
||||
import admin_user_api from './admin_user_api'
|
||||
|
||||
const api = new Hono()
|
||||
|
||||
api.get('/admin/address', async (c) => {
|
||||
const { limit, offset, query } = c.req.query();
|
||||
if (!limit || limit < 0 || limit > 100) {
|
||||
return c.text("Invalid limit", 400)
|
||||
}
|
||||
if (!offset || offset < 0) {
|
||||
return c.text("Invalid offset", 400)
|
||||
}
|
||||
if (query) {
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT a.*,`
|
||||
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
|
||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||
+ ` FROM address a`
|
||||
+ ` where name like ?`
|
||||
+ ` order by id desc limit ? offset ?`
|
||||
).bind(`%${query}%`, limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: addressCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address where name like ?`
|
||||
).bind(`%${query}%`).first();
|
||||
count = addressCount;
|
||||
}
|
||||
return c.json({
|
||||
results: results,
|
||||
count: count
|
||||
})
|
||||
}
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT a.*,`
|
||||
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
|
||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||
+ ` FROM address a`
|
||||
+ ` order by id desc limit ? offset ?`
|
||||
).bind(limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: addressCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM address`
|
||||
).first();
|
||||
count = addressCount;
|
||||
}
|
||||
return c.json({
|
||||
results: results,
|
||||
count: count
|
||||
})
|
||||
})
|
||||
|
||||
api.post('/admin/new_address', async (c) => {
|
||||
let { name, domain, enablePrefix } = await c.req.json();
|
||||
if (!name) {
|
||||
return c.text("Please provide a name", 400)
|
||||
}
|
||||
return newAddress(c, name, domain, enablePrefix);
|
||||
})
|
||||
|
||||
api.delete('/admin/delete_address/:id', async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE id = ? `
|
||||
).bind(id).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to delete address", 500)
|
||||
}
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text("Failed to delete mails", 500)
|
||||
}
|
||||
const { success: sendAccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
const { success: usersAddressSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM users_address WHERE address_id = ?`
|
||||
).bind(id).run();
|
||||
return c.json({
|
||||
success: success && mailSuccess && sendAccess && usersAddressSuccess
|
||||
})
|
||||
})
|
||||
|
||||
api.get('/admin/show_password/:id', async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const name = await c.env.DB.prepare(
|
||||
`SELECT name FROM address WHERE id = ? `
|
||||
).bind(id).first("name");
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: id
|
||||
}, c.env.JWT_SECRET)
|
||||
return c.json({
|
||||
jwt: jwt
|
||||
})
|
||||
})
|
||||
|
||||
api.get('/admin/mails', async (c) => {
|
||||
const { address, limit, offset, keyword } = c.req.query();
|
||||
if (address && keyword) {
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM raw_mails where address = ? and raw like ? `,
|
||||
`SELECT count(*) as count FROM raw_mails where address = ? and raw like ? `,
|
||||
[address, `%${keyword}%`], limit, offset
|
||||
);
|
||||
} else if (keyword) {
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM raw_mails where raw like ? `,
|
||||
`SELECT count(*) as count FROM raw_mails where raw like ? `,
|
||||
[`%${keyword}%`], limit, offset
|
||||
);
|
||||
} else if (address) {
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM raw_mails where address = ? `,
|
||||
`SELECT count(*) as count FROM raw_mails where address = ? `,
|
||||
[address], limit, offset
|
||||
);
|
||||
} else {
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM raw_mails `,
|
||||
`SELECT count(*) as count FROM raw_mails `,
|
||||
[], limit, offset
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
api.get('/admin/mails_unknow', async (c) => {
|
||||
const { limit, offset } = c.req.query();
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM raw_mails where address NOT IN (select name from address) `,
|
||||
`SELECT count(*) as count FROM raw_mails`
|
||||
+ ` where address NOT IN (select name from address) `,
|
||||
[], limit, offset
|
||||
);
|
||||
});
|
||||
|
||||
api.get('/admin/address_sender', async (c) => {
|
||||
const { address, limit, offset } = c.req.query();
|
||||
if (address) {
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM address_sender where address = ? `,
|
||||
`SELECT count(*) as count FROM address_sender where address = ? `,
|
||||
[address], limit, offset
|
||||
);
|
||||
}
|
||||
return await handleListQuery(c,
|
||||
`SELECT * FROM address_sender `,
|
||||
`SELECT count(*) as count FROM address_sender `,
|
||||
[], limit, offset
|
||||
);
|
||||
})
|
||||
|
||||
api.post('/admin/address_sender', async (c) => {
|
||||
let { address, address_id, balance, enabled } = await c.req.json();
|
||||
if (!address_id) {
|
||||
return c.text("Invalid address_id", 400)
|
||||
}
|
||||
enabled = enabled ? 1 : 0;
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`UPDATE address_sender SET enabled = ?, balance = ? WHERE id = ? `
|
||||
).bind(enabled, balance, address_id).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to update address sender", 500)
|
||||
}
|
||||
await sendAdminInternalMail(
|
||||
c, address, "Account Send Access Updated",
|
||||
`You send access has been ${enabled ? "enabled" : "disabled"}, balance: ${balance}`
|
||||
);
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
})
|
||||
|
||||
api.get('/admin/sendbox', async (c) => {
|
||||
const { address, limit, offset } = c.req.query();
|
||||
if (!limit || limit < 0 || limit > 100) {
|
||||
return c.text("Invalid limit", 400)
|
||||
}
|
||||
if (!offset || offset < 0) {
|
||||
return c.text("Invalid offset", 400)
|
||||
}
|
||||
if (!address) {
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT * FROM sendbox order by id desc limit ? offset ?`
|
||||
).bind(limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: mailCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM sendbox`
|
||||
).first();
|
||||
count = mailCount;
|
||||
}
|
||||
return c.json({
|
||||
results: results,
|
||||
count: count
|
||||
})
|
||||
}
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT * FROM sendbox where address = ? order by id desc limit ? offset ?`
|
||||
).bind(address, limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: mailCount } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM sendbox where address = ?`
|
||||
).bind(address).first();
|
||||
count = mailCount;
|
||||
}
|
||||
return c.json({
|
||||
results: results,
|
||||
count: count
|
||||
})
|
||||
})
|
||||
|
||||
api.get('/admin/statistics', async (c) => {
|
||||
const { count: mailCount } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM raw_mails`
|
||||
).first();
|
||||
const { count: addressCount } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM address`
|
||||
).first();
|
||||
const { count: activeUserCount7days } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM address where updated_at > datetime('now', '-7 day')`
|
||||
).first();
|
||||
const { count: sendMailCount } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM sendbox`
|
||||
).first();
|
||||
return c.json({
|
||||
mailCount: mailCount,
|
||||
userCount: addressCount,
|
||||
activeUserCount7days: activeUserCount7days,
|
||||
sendMailCount: sendMailCount
|
||||
})
|
||||
});
|
||||
|
||||
api.get('/admin/account_settings', async (c) => {
|
||||
try {
|
||||
/** @type {Array<string>|undefined|null} */
|
||||
const blockList = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
|
||||
/** @type {Array<string>|undefined|null} */
|
||||
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
|
||||
return c.json({
|
||||
blockList: blockList || [],
|
||||
sendBlockList: sendBlockList || []
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return c.json({})
|
||||
}
|
||||
})
|
||||
|
||||
api.post('/admin/account_settings', async (c) => {
|
||||
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
|
||||
const { blockList, sendBlockList } = await c.req.json();
|
||||
if (!blockList || !sendBlockList) {
|
||||
return c.text("Invalid blockList or sendBlockList", 400)
|
||||
}
|
||||
await saveSetting(
|
||||
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
|
||||
JSON.stringify(blockList)
|
||||
);
|
||||
await saveSetting(
|
||||
c, CONSTANTS.SEND_BLOCK_LIST_KEY,
|
||||
JSON.stringify(sendBlockList)
|
||||
);
|
||||
return c.json({
|
||||
success: true
|
||||
})
|
||||
})
|
||||
|
||||
api.post('/admin/cleanup', cleanup_api.cleanup)
|
||||
api.get('/admin/auto_cleanup', cleanup_api.getCleanup)
|
||||
api.post('/admin/auto_cleanup', cleanup_api.saveCleanup)
|
||||
api.get('/admin/user_settings', admin_user_api.getSetting)
|
||||
api.post('/admin/user_settings', admin_user_api.saveSetting)
|
||||
api.get('/admin/users', admin_user_api.getUsers)
|
||||
api.delete('/admin/users/:user_id', admin_user_api.deleteUser)
|
||||
api.post('/admin/users', admin_user_api.createUser)
|
||||
api.post('/admin/users/:user_id/reset_password', admin_user_api.resetPassword)
|
||||
|
||||
export { api }
|
||||
117
worker/src/common.js
Normal file
117
worker/src/common.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { getDomains, getStringValue } from './utils';
|
||||
|
||||
export const newAddress = async (c, name, domain, enablePrefix) => {
|
||||
// remove special characters
|
||||
name = name.replace(/[^a-zA-Z0-9.]/g, '')
|
||||
// check name length
|
||||
if (name.length < 0) {
|
||||
return c.text("Name too short", 400)
|
||||
}
|
||||
if (name.length > 100) {
|
||||
return c.text("Name too long (max 100)", 400)
|
||||
}
|
||||
// check domain, generate random domain
|
||||
const domains = getDomains(c);
|
||||
if (!domain || !domains.includes(domain)) {
|
||||
domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
}
|
||||
// create address
|
||||
if (enablePrefix) {
|
||||
name = getStringValue(c.env.PREFIX) + name + "@" + domain;
|
||||
} else {
|
||||
name = name + "@" + domain;
|
||||
}
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name) VALUES(?)`
|
||||
).bind(name).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to create address", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("UNIQUE")) {
|
||||
return c.text("Address already exists, please retry a new address", 400)
|
||||
}
|
||||
return c.text("Failed to create address", 500)
|
||||
}
|
||||
let address_id = 0;
|
||||
try {
|
||||
address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(name).first("id");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET)
|
||||
return c.json({
|
||||
jwt: jwt
|
||||
})
|
||||
}
|
||||
|
||||
export const cleanup = async (c, cleanType, cleanDays) => {
|
||||
if (!cleanType || !cleanDays || cleanDays < 0 || cleanDays > 30) {
|
||||
throw new Error("Invalid cleanType or cleanDays")
|
||||
}
|
||||
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
|
||||
switch (cleanType) {
|
||||
case "mails":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "mails_unknow":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE address NOT IN
|
||||
(select name from address) AND created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "address":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM address WHERE updated_at < datetime('now', '-${cleanDays} day')`
|
||||
+ ` AND id NOT IN (SELECT address_id FROM users_address)`
|
||||
).run();
|
||||
break;
|
||||
case "sendbox":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM sendbox WHERE created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid cleanType")
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} c context
|
||||
* @param {*} query @type {string} query
|
||||
* @param {*} countQuery @type {string} countQuery
|
||||
* @param {*} limit @type {number} limit
|
||||
* @param {*} offset @type {number} offset
|
||||
* @returns {Promise} Promise
|
||||
*/
|
||||
export const handleListQuery = async (
|
||||
c, query, countQuery, params, limit, offset
|
||||
) => {
|
||||
if (!limit || limit < 0 || limit > 100) {
|
||||
return c.text("Invalid limit", 400)
|
||||
}
|
||||
if (!offset || offset < 0) {
|
||||
return c.text("Invalid offset", 400)
|
||||
}
|
||||
const resultsQuery = `${query} order by id desc limit ? offset ?`;
|
||||
const { results } = await c.env.DB.prepare(resultsQuery).bind(
|
||||
...params, limit, offset
|
||||
).all();
|
||||
const count = offset == 0 ? await c.env.DB.prepare(
|
||||
countQuery
|
||||
).bind(...params).first("count") : 0;
|
||||
return c.json({ results, count });
|
||||
}
|
||||
6
worker/src/constants.js
Normal file
6
worker/src/constants.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export const CONSTANTS = {
|
||||
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
|
||||
SEND_BLOCK_LIST_KEY: 'send_block_list',
|
||||
AUTO_CLEANUP_KEY: 'auto_cleanup',
|
||||
USER_SETTINGS_KEY: 'user_settings',
|
||||
}
|
||||
@@ -37,15 +37,14 @@ api.get('/admin/v1/mails_unknow', async (c) => {
|
||||
}
|
||||
const { results } = await c.env.DB.prepare(`
|
||||
SELECT id, source, subject, message FROM mails
|
||||
where address NOT IN(select concat('${c.env.PREFIX}', name) from address)
|
||||
where address NOT IN(select name from address)
|
||||
order by id desc limit ? offset ? `
|
||||
).bind(limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
const { count: mailCount } = await c.env.DB.prepare(`
|
||||
SELECT count(*) as count FROM mails
|
||||
where address NOT IN
|
||||
(select concat('${c.env.PREFIX}', name) from address)`
|
||||
where address NOT IN (select name from address)`
|
||||
).first();
|
||||
count = mailCount;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createMimeMessage } from "mimetext";
|
||||
import { EmailMessage } from "cloudflare:email";
|
||||
import { getBooleanValue } from "./utils";
|
||||
|
||||
async function email(message, env, ctx) {
|
||||
if (env.BLACK_LIST && env.BLACK_LIST.split(",").some(word => message.from.includes(word))) {
|
||||
@@ -7,54 +7,49 @@ async function email(message, env, ctx) {
|
||||
console.log(`Reject message from ${message.from} to ${message.to}`);
|
||||
return;
|
||||
}
|
||||
if (!env.PREFIX || (message.to && message.to.startsWith(env.PREFIX))) {
|
||||
const rawEmail = await new Response(message.raw).text();
|
||||
const message_id = message.headers.get("Message-ID");
|
||||
// save email
|
||||
const { success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, message.to, rawEmail, message_id
|
||||
).run();
|
||||
if (!success) {
|
||||
message.setReject(`Failed save message to ${message.to}`);
|
||||
console.log(`Failed save message from ${message.from} to ${message.to}`);
|
||||
}
|
||||
const rawEmail = await new Response(message.raw).text();
|
||||
const message_id = message.headers.get("Message-ID");
|
||||
// save email
|
||||
const { success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, message.to, rawEmail, message_id
|
||||
).run();
|
||||
if (!success) {
|
||||
message.setReject(`Failed save message to ${message.to}`);
|
||||
console.log(`Failed save message from ${message.from} to ${message.to}`);
|
||||
}
|
||||
|
||||
// auto reply email
|
||||
if (env.ENABLE_AUTO_REPLY) {
|
||||
try {
|
||||
const results = await env.DB.prepare(
|
||||
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
|
||||
).bind(message.to).first();
|
||||
if (results && results.source_prefix && message.from.startsWith(results.source_prefix)) {
|
||||
const msg = createMimeMessage();
|
||||
msg.setHeader("In-Reply-To", message.headers.get("Message-ID"));
|
||||
msg.setSender({
|
||||
name: results.name || results.address,
|
||||
addr: results.address
|
||||
});
|
||||
msg.setRecipient(message.from);
|
||||
msg.setSubject(results.subject || "Auto-reply");
|
||||
msg.addMessage({
|
||||
contentType: 'text/plain',
|
||||
data: results.message || "This is an auto-reply message, please reconact later."
|
||||
});
|
||||
|
||||
const replyMessage = new EmailMessage(
|
||||
message.to,
|
||||
message.from,
|
||||
msg.asRaw()
|
||||
);
|
||||
await message.reply(replyMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("reply email error", error);
|
||||
// auto reply email
|
||||
if (getBooleanValue(env.ENABLE_AUTO_REPLY)) {
|
||||
try {
|
||||
const results = await env.DB.prepare(
|
||||
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
|
||||
).bind(message.to).first();
|
||||
if (results && results.source_prefix && message.from.startsWith(results.source_prefix)) {
|
||||
const msg = createMimeMessage();
|
||||
msg.setHeader("In-Reply-To", message.headers.get("Message-ID"));
|
||||
msg.setSender({
|
||||
name: results.name || results.address,
|
||||
addr: results.address
|
||||
});
|
||||
msg.setRecipient(message.from);
|
||||
msg.setSubject(results.subject || "Auto-reply");
|
||||
msg.addMessage({
|
||||
contentType: 'text/plain',
|
||||
data: results.message || "This is an auto-reply message, please reconact later."
|
||||
});
|
||||
const { EmailMessage } = await import('cloudflare:email');
|
||||
const replyMessage = new EmailMessage(
|
||||
message.to,
|
||||
message.from,
|
||||
msg.asRaw()
|
||||
);
|
||||
await message.reply(replyMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("reply email error", error);
|
||||
}
|
||||
} else {
|
||||
message.setReject(`Unknown address ${message.to}`);
|
||||
console.log(`Unknown address ${message.to}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
worker/src/mails_api/auto_reply.js
Normal file
52
worker/src/mails_api/auto_reply.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getBooleanValue } from "../utils";
|
||||
|
||||
|
||||
export default {
|
||||
getAutoReply: async (c) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_AUTO_REPLY)) {
|
||||
return c.text("Auto reply is disabled", 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const results = await c.env.DB.prepare(
|
||||
`SELECT * FROM auto_reply_mails where address = ? `
|
||||
).bind(address).first();
|
||||
if (!results) {
|
||||
return c.json({});
|
||||
}
|
||||
return c.json({
|
||||
subject: results.subject,
|
||||
message: results.message,
|
||||
enabled: results.enabled == 1,
|
||||
source_prefix: results.source_prefix,
|
||||
name: results.name,
|
||||
})
|
||||
},
|
||||
saveAutoReply: async (c) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_AUTO_REPLY)) {
|
||||
return c.text("Auto reply is disabled", 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload");
|
||||
const { auto_reply } = await c.req.json();
|
||||
const { name, subject, source_prefix, message, enabled } = auto_reply;
|
||||
if ((!subject || !message) && enabled) {
|
||||
return c.text("Invalid subject or message", 400)
|
||||
}
|
||||
else if (subject.length > 255 || message.length > 255) {
|
||||
return c.text("Subject or message too long", 400)
|
||||
}
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT OR REPLACE INTO auto_reply_mails`
|
||||
+ ` (name, address, source_prefix, subject, message, enabled)`
|
||||
+ ` VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).bind(
|
||||
name || '', address, source_prefix || '',
|
||||
subject || '', message || '', enabled ? 1 : 0
|
||||
).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to auto_reply settings", 500)
|
||||
}
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
}
|
||||
}
|
||||
10
worker/src/mails_api/index.js
Normal file
10
worker/src/mails_api/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import auto_reply from './auto_reply'
|
||||
|
||||
const api = new Hono()
|
||||
|
||||
api.get('/api/auto_reply', auto_reply.getAutoReply)
|
||||
api.post('/api/auto_reply', auto_reply.saveAutoReply)
|
||||
|
||||
export { api }
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Hono } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { CONSTANTS } from '../constants'
|
||||
import { getJsonSetting, getDomains } from '../utils';
|
||||
import { GeoData } from '../models'
|
||||
|
||||
const api = new Hono()
|
||||
|
||||
@@ -26,34 +30,43 @@ api.post('/api/requset_send_mail_access', async (c) => {
|
||||
return c.json({ status: "ok" })
|
||||
})
|
||||
|
||||
|
||||
api.post('/api/send_mail', async (c) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
export const sendMail = async (c, address, reqJson) => {
|
||||
if (!address) {
|
||||
throw new Error("No address")
|
||||
}
|
||||
// check domain
|
||||
const mailDomain = address.split("@")[1];
|
||||
const domains = getDomains(c);
|
||||
if (!domains.includes(mailDomain)) {
|
||||
throw new Error("Invalid domain")
|
||||
}
|
||||
// check permission
|
||||
const balance = await c.env.DB.prepare(
|
||||
`SELECT balance FROM address_sender
|
||||
where address = ? and enabled = 1`
|
||||
).bind(address).first("balance");
|
||||
if (!balance || balance <= 0) {
|
||||
return c.text("No balance", 400);
|
||||
throw new Error("No balance")
|
||||
}
|
||||
let {
|
||||
from_name, to_mail, to_name,
|
||||
subject, content, is_html
|
||||
} = await c.req.json();
|
||||
if (!address) {
|
||||
return c.text("No address", 400)
|
||||
}
|
||||
} = reqJson;
|
||||
if (!to_mail) {
|
||||
return c.text("Invalid to mail", 400)
|
||||
throw new Error("Invalid to mail")
|
||||
}
|
||||
// check SEND_BLOCK_LIST_KEY
|
||||
const sendBlockList = await getJsonSetting(c, CONSTANTS.SEND_BLOCK_LIST_KEY);
|
||||
if (sendBlockList && sendBlockList.some((item) => to_mail.includes(item))) {
|
||||
throw new Error("to_mail address is blocked")
|
||||
}
|
||||
from_name = from_name || address;
|
||||
to_name = to_name || to_mail;
|
||||
if (!subject) {
|
||||
return c.text("Invalid subject", 400)
|
||||
throw new Error("Invalid subject")
|
||||
}
|
||||
if (!content) {
|
||||
return c.text("Invalid content", 400)
|
||||
throw new Error("Invalid content")
|
||||
}
|
||||
let dmikBody = {}
|
||||
if (c.env.DKIM_SELECTOR && c.env.DKIM_PRIVATE_KEY && address.includes("@")) {
|
||||
@@ -94,7 +107,7 @@ api.post('/api/send_mail', async (c) => {
|
||||
const respText = await resp.text();
|
||||
console.log(resp.status + " " + resp.statusText + ": " + respText);
|
||||
if (resp.status >= 300) {
|
||||
return c.text("Failed to send mail", 500)
|
||||
throw new Error(`Mailchannels error: ${resp.status} ${respText}`);
|
||||
}
|
||||
// update balance
|
||||
try {
|
||||
@@ -113,6 +126,9 @@ api.post('/api/send_mail', async (c) => {
|
||||
if (body?.personalizations?.[0]?.dkim_private_key) {
|
||||
delete body.personalizations[0].dkim_private_key;
|
||||
}
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
const geoData = new GeoData(reqIp, c.req.raw.cf);
|
||||
body.geoData = geoData;
|
||||
const { success: success2 } = await c.env.DB.prepare(
|
||||
`INSERT INTO sendbox (address, raw) VALUES (?, ?)`
|
||||
).bind(address, JSON.stringify(body)).run();
|
||||
@@ -122,7 +138,33 @@ api.post('/api/send_mail', async (c) => {
|
||||
} catch (e) {
|
||||
console.warn(`Failed to save to sendbox for ${address}`);
|
||||
}
|
||||
return c.json({ status: "ok" });
|
||||
}
|
||||
|
||||
api.post('/api/send_mail', async (c) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
const reqJson = await c.req.json();
|
||||
try {
|
||||
await sendMail(c, address, reqJson);
|
||||
} catch (e) {
|
||||
console.error("Failed to send mail", e);
|
||||
return c.text(`Failed to send mail ${e.message}`, 400)
|
||||
}
|
||||
return c.json({ status: "ok" })
|
||||
})
|
||||
|
||||
api.post('/external/api/send_mail', async (c) => {
|
||||
const { token } = await c.req.json();
|
||||
try {
|
||||
const { address } = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
|
||||
if (!address) {
|
||||
return c.text("No address", 400)
|
||||
}
|
||||
const reqJson = await c.req.json();
|
||||
return await sendMail(c, address, reqJson);
|
||||
} catch (e) {
|
||||
console.error("Failed to send mail", e);
|
||||
return c.text(`Failed to send mail ${e.message}`, 400)
|
||||
}
|
||||
})
|
||||
|
||||
const getSendbox = async (c, address, limit, offset) => {
|
||||
92
worker/src/models/index.js
Normal file
92
worker/src/models/index.js
Normal file
@@ -0,0 +1,92 @@
|
||||
export class UserSettings {
|
||||
/** @param {UserSettings|undefined|null} data */
|
||||
constructor(data) {
|
||||
if (data === null) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
enable, enableMailVerify, verifyMailSender,
|
||||
enableMailAllowList, mailAllowList, maxAddressCount
|
||||
} = data || {};
|
||||
/** @type {boolean|undefined} */
|
||||
this.enable = enable;
|
||||
/** @type {boolean|undefined} */
|
||||
this.enableMailVerify = enableMailVerify;
|
||||
/** @type {string|undefined} */
|
||||
this.verifyMailSender = verifyMailSender;
|
||||
/** @type {boolean|undefined} */
|
||||
this.enableMailAllowList = enableMailAllowList;
|
||||
/** @type {Array<string>|undefined} */
|
||||
this.mailAllowList = mailAllowList;
|
||||
/** @type {number|undefined} */
|
||||
this.maxAddressCount = maxAddressCount || 5;
|
||||
}
|
||||
}
|
||||
|
||||
export class CleanupSettings {
|
||||
/** @param {CleanupSettings|undefined|null} data */
|
||||
constructor(data) {
|
||||
const {
|
||||
enableMailsAutoCleanup, cleanMailsDays,
|
||||
enableUnknowMailsAutoCleanup, cleanUnknowMailsDays,
|
||||
enableAddressAutoCleanup, cleanAddressDays,
|
||||
enableSendBoxAutoCleanup, cleanSendBoxDays
|
||||
} = data || {};
|
||||
/** @type {boolean|undefined} */
|
||||
this.enableMailsAutoCleanup = enableMailsAutoCleanup;
|
||||
/** @type {number|undefined} */
|
||||
this.cleanMailsDays = cleanMailsDays;
|
||||
/** @type {boolean|undefined} */
|
||||
this.enableUnknowMailsAutoCleanup = enableUnknowMailsAutoCleanup;
|
||||
/** @type {number|undefined} */
|
||||
this.cleanUnknowMailsDays = cleanUnknowMailsDays;
|
||||
/** @type {boolean|undefined} */
|
||||
this.enableAddressAutoCleanup = enableAddressAutoCleanup;
|
||||
/** @type {number|undefined} */
|
||||
this.cleanAddressDays = cleanAddressDays;
|
||||
/** @type {boolean|undefined} */
|
||||
this.enableSendBoxAutoCleanup = enableSendBoxAutoCleanup;
|
||||
/** @type {number|undefined} */
|
||||
this.cleanSendBoxDays = cleanSendBoxDays;
|
||||
}
|
||||
}
|
||||
|
||||
export class GeoData {
|
||||
/** @param {string} ip @param {GeoData|undefined|null} data */
|
||||
constructor(ip, data) {
|
||||
const {
|
||||
country, city, timezone, postalCode, region,
|
||||
latitude, longitude, regionCode, asOrganization
|
||||
} = data || {};
|
||||
/** @type {string} */
|
||||
this.ip = ip;
|
||||
/** @type {string|undefined} */
|
||||
this.country = country;
|
||||
/** @type {string|undefined} */
|
||||
this.city = city;
|
||||
/** @type {string|undefined} */
|
||||
this.timezone = timezone;
|
||||
/** @type {string|undefined} */
|
||||
this.postalCode = postalCode;
|
||||
/** @type {string|undefined} */
|
||||
this.region = region;
|
||||
/** @type {number|undefined} */
|
||||
this.latitude = latitude;
|
||||
/** @type {number|undefined} */
|
||||
this.longitude = longitude;
|
||||
/** @type {string|undefined} */
|
||||
this.regionCode = regionCode;
|
||||
/** @type {string|undefined} */
|
||||
this.asOrganization = asOrganization;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserInfo {
|
||||
/** @param {GeoData} geoData @param {string} userEmail */
|
||||
constructor(geoData, userEmail) {
|
||||
/** @type {geoData} */
|
||||
this.geoData = geoData;
|
||||
/** @type {string} */
|
||||
this.userEmail = userEmail;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Hono } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { getDomains, getPasswords } from './utils';
|
||||
import {
|
||||
getDomains, getPasswords, getBooleanValue, getJsonSetting,
|
||||
checkCfTurnstile
|
||||
} from './utils';
|
||||
import { newAddress } from './common'
|
||||
import { CONSTANTS } from './constants'
|
||||
|
||||
const api = new Hono()
|
||||
|
||||
@@ -18,7 +22,7 @@ api.get('/api/mails', async (c) => {
|
||||
return c.text("Invalid offset", 400)
|
||||
}
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT id, source, raw, created_at FROM raw_mails where address = ? order by id desc limit ? offset ?`
|
||||
`SELECT * FROM raw_mails where address = ? order by id desc limit ? offset ?`
|
||||
).bind(address, limit, offset).all();
|
||||
let count = 0;
|
||||
if (offset == 0) {
|
||||
@@ -34,7 +38,7 @@ api.get('/api/mails', async (c) => {
|
||||
})
|
||||
|
||||
api.delete('/api/mails/:id', async (c) => {
|
||||
if (c.env.ENABLE_USER_DELETE_EMAIL) {
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text("User delete email is disabled", 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
@@ -61,43 +65,26 @@ api.get('/api/settings', async (c) => {
|
||||
return c.text("Invalid address", 400)
|
||||
}
|
||||
}
|
||||
if (address.startsWith(c.env.PREFIX)) {
|
||||
// check address id
|
||||
try {
|
||||
if (!address_id) {
|
||||
const db_address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(address.substring(c.env.PREFIX.length)).first("id");
|
||||
if (!db_address_id) {
|
||||
return c.text("Invalid address", 400)
|
||||
}
|
||||
// check address id
|
||||
try {
|
||||
if (!address_id) {
|
||||
const db_address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(address).first("id");
|
||||
if (!db_address_id) {
|
||||
return c.text("Invalid address", 400)
|
||||
}
|
||||
} catch (error) {
|
||||
return c.text("Invalid address", 400)
|
||||
}
|
||||
// update address updated_at
|
||||
try {
|
||||
c.env.DB.prepare(
|
||||
`UPDATE address SET updated_at = datetime('now') where name = ?`
|
||||
).bind(address.substring(c.env.PREFIX.length)).run();
|
||||
} catch (e) {
|
||||
console.warn("Failed to update address")
|
||||
}
|
||||
} catch (error) {
|
||||
return c.text("Invalid address", 400)
|
||||
}
|
||||
let auto_reply = {};
|
||||
if (c.env.ENABLE_AUTO_REPLY) {
|
||||
const results = await c.env.DB.prepare(
|
||||
`SELECT * FROM auto_reply_mails where address = ? `
|
||||
).bind(address).first();
|
||||
if (results) {
|
||||
auto_reply = {
|
||||
subject: results.subject,
|
||||
message: results.message,
|
||||
enabled: results.enabled == 1,
|
||||
source_prefix: results.source_prefix,
|
||||
name: results.name,
|
||||
}
|
||||
}
|
||||
// update address updated_at
|
||||
try {
|
||||
c.env.DB.prepare(
|
||||
`UPDATE address SET updated_at = datetime('now') where name = ?`
|
||||
).bind(address).run();
|
||||
} catch (e) {
|
||||
console.warn("Failed to update address")
|
||||
}
|
||||
const { count: mailCountV1 } = await c.env.DB.prepare(
|
||||
`SELECT count(*) as count FROM mails where address = ?`
|
||||
@@ -107,41 +94,12 @@ api.get('/api/settings', async (c) => {
|
||||
where address = ? and enabled = 1`
|
||||
).bind(address).first("balance");
|
||||
return c.json({
|
||||
auto_reply: auto_reply,
|
||||
address: address,
|
||||
has_v1_mails: mailCountV1 && mailCountV1 > 0,
|
||||
send_balance: balance || 0,
|
||||
});
|
||||
})
|
||||
|
||||
api.post('/api/settings', async (c) => {
|
||||
const { address } = c.get("jwtPayload")
|
||||
if (!c.env.ENABLE_AUTO_REPLY) {
|
||||
return c.text("Auto reply is disabled", 403)
|
||||
}
|
||||
const { auto_reply } = await c.req.json();
|
||||
const { name, subject, source_prefix, message, enabled } = auto_reply;
|
||||
if ((!subject || !message) && enabled) {
|
||||
return c.text("Invalid subject or message", 400)
|
||||
}
|
||||
else if (subject.length > 255 || message.length > 255) {
|
||||
return c.text("Subject or message too long", 400)
|
||||
}
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT OR REPLACE INTO
|
||||
auto_reply_mails
|
||||
(name, address, source_prefix, subject, message, enabled)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?)`
|
||||
).bind(name || '', address, source_prefix || '', subject || '', message || '', enabled ? 1 : 0).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to save settings", 500)
|
||||
}
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
})
|
||||
|
||||
api.get('/open_api/settings', async (c) => {
|
||||
// check header x-custom-auth
|
||||
let needAuth = false;
|
||||
@@ -155,73 +113,48 @@ api.get('/open_api/settings', async (c) => {
|
||||
"domains": getDomains(c),
|
||||
"needAuth": needAuth,
|
||||
"adminContact": c.env.ADMIN_CONTACT,
|
||||
"enableUserDeleteEmail": c.env.ENABLE_USER_DELETE_EMAIL,
|
||||
"enableAutoReply": c.env.ENABLE_AUTO_REPLY,
|
||||
"enableUserCreateEmail": getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL),
|
||||
"enableUserDeleteEmail": getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL),
|
||||
"enableAutoReply": getBooleanValue(c.env.ENABLE_AUTO_REPLY),
|
||||
"copyright": c.env.COPYRIGHT,
|
||||
"cfTurnstileSiteKey": c.env.CF_TURNSTILE_SITE_KEY,
|
||||
});
|
||||
})
|
||||
|
||||
api.get('/api/new_address', async (c) => {
|
||||
let { name, domain } = c.req.query();
|
||||
api.post('/api/new_address', async (c) => {
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_CREATE_EMAIL)) {
|
||||
return c.text("New address is disabled", 403)
|
||||
}
|
||||
let { name, domain, cf_token } = await c.req.json();
|
||||
// check cf turnstile
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text("Failed to check cf turnstile", 500)
|
||||
}
|
||||
// if no name, generate random name
|
||||
if (!name) {
|
||||
name = Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
// remove special characters
|
||||
name = name.replace(/[^a-zA-Z0-9.]/g, '')
|
||||
// check name length
|
||||
if (name.length < 0) {
|
||||
return c.text("Name too short", 400)
|
||||
}
|
||||
if (name.length > 100) {
|
||||
return c.text("Name too long (max 100)", 400)
|
||||
}
|
||||
// check domain, generate random domain
|
||||
const domains = getDomains(c);
|
||||
if (!domain || !domains.includes(domain)) {
|
||||
domain = domains[Math.floor(Math.random() * domains.length)];
|
||||
}
|
||||
// create address
|
||||
const emailAddress = c.env.PREFIX + name + "@" + domain
|
||||
// check name block list
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name) VALUES(?)`
|
||||
).bind(name + "@" + domain).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to create address", 500)
|
||||
const value = await getJsonSetting(c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY);
|
||||
const blockList = value || [];
|
||||
if (blockList.some((item) => name.includes(item))) {
|
||||
return c.text(`Name [${name}] is blocked`, 400)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("UNIQUE")) {
|
||||
return c.text("Please retry a new address", 400)
|
||||
}
|
||||
return c.text("Failed to create address", 500)
|
||||
}
|
||||
let address_id = 0;
|
||||
try {
|
||||
address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(name + "@" + domain).first("id");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.error(error);
|
||||
}
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
address: emailAddress,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET)
|
||||
return c.json({
|
||||
jwt: jwt
|
||||
})
|
||||
return newAddress(c, name, domain, true);
|
||||
})
|
||||
|
||||
api.delete('/api/delete_address', async (c) => {
|
||||
if (c.env.ENABLE_USER_DELETE_EMAIL) {
|
||||
if (!getBooleanValue(c.env.ENABLE_USER_DELETE_EMAIL)) {
|
||||
return c.text("User delete email is disabled", 403)
|
||||
}
|
||||
const { address } = c.get("jwtPayload")
|
||||
const { address, address_id } = c.get("jwtPayload")
|
||||
let name = address;
|
||||
if (address.startsWith(c.env.PREFIX)) {
|
||||
name = address.substring(c.env.PREFIX.length);
|
||||
}
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE name = ? `
|
||||
).bind(name).run();
|
||||
@@ -229,7 +162,7 @@ api.delete('/api/delete_address', async (c) => {
|
||||
return c.text("Failed to delete address", 500)
|
||||
}
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM mails WHERE address = ? `
|
||||
`DELETE FROM raw_mails WHERE address = ? `
|
||||
).bind(address).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text("Failed to delete mails", 500)
|
||||
@@ -237,8 +170,11 @@ api.delete('/api/delete_address', async (c) => {
|
||||
const { success: sendAccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address = ? `
|
||||
).bind(address).run();
|
||||
const { success: addressSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM users_address WHERE address_id = ?`
|
||||
).bind(address_id).run();
|
||||
return c.json({
|
||||
success: success && mailSuccess && sendAccess
|
||||
success: success && mailSuccess && sendAccess && addressSuccess
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
42
worker/src/scheduled.js
Normal file
42
worker/src/scheduled.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cleanup } from './common'
|
||||
import { CONSTANTS } from './constants'
|
||||
import { getJsonSetting } from './utils';
|
||||
import { CleanupSettings } from './models';
|
||||
|
||||
export async function scheduled(event, env, ctx) {
|
||||
console.log("Scheduled event: ", event);
|
||||
const value = await getJsonSetting(
|
||||
{ env: env, },
|
||||
CONSTANTS.AUTO_CLEANUP_KEY
|
||||
);
|
||||
const autoCleanupSetting = new CleanupSettings(value);
|
||||
console.log("autoCleanupSetting:", JSON.stringify(autoCleanupSetting));
|
||||
if (autoCleanupSetting.enableMailsAutoCleanup && autoCleanupSetting.cleanMailsDays > 0) {
|
||||
await cleanup(
|
||||
{ env: env, },
|
||||
"mails",
|
||||
autoCleanupSetting.cleanMailsDays
|
||||
);
|
||||
}
|
||||
if (autoCleanupSetting.enableUnknowMailsAutoCleanup && autoCleanupSetting.cleanUnknowMailsDays > 0) {
|
||||
await cleanup(
|
||||
{ env: env, },
|
||||
"mails_unknow",
|
||||
autoCleanupSetting.cleanUnknowMailsDays
|
||||
);
|
||||
}
|
||||
if (autoCleanupSetting.enableAddressAutoCleanup && autoCleanupSetting.cleanAddressDays > 0) {
|
||||
await cleanup(
|
||||
{ env: env, },
|
||||
"address",
|
||||
autoCleanupSetting.cleanAddressDays
|
||||
);
|
||||
}
|
||||
if (autoCleanupSetting.enableSendBoxAutoCleanup && autoCleanupSetting.cleanSendBoxDays > 0) {
|
||||
await cleanup(
|
||||
{ env: env, },
|
||||
"sendbox",
|
||||
autoCleanupSetting.cleanSendBoxDays
|
||||
);
|
||||
}
|
||||
}
|
||||
139
worker/src/user_api/bind_address.js
Normal file
139
worker/src/user_api/bind_address.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { UserSettings } from "../models";
|
||||
import { getJsonSetting } from "../utils"
|
||||
import { CONSTANTS } from "../constants";
|
||||
|
||||
export default {
|
||||
bind: async (c) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { address_id } = c.get("jwtPayload");
|
||||
if (!address_id || !user_id) {
|
||||
return c.text("No address or user token", 400)
|
||||
}
|
||||
// check if address exists
|
||||
const db_address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where id = ?`
|
||||
).bind(address_id).first("id");
|
||||
if (!db_address_id) {
|
||||
return c.text("Address not found", 400)
|
||||
}
|
||||
// check if user exists
|
||||
const db_user_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM users where id = ?`
|
||||
).bind(user_id).first("id");
|
||||
if (!db_user_id) {
|
||||
return c.text("User not found", 400)
|
||||
}
|
||||
// check if binded
|
||||
const db_user_address_id = await c.env.DB.prepare(
|
||||
`SELECT user_id FROM users_address where user_id = ? and address_id = ?`
|
||||
).bind(user_id, address_id).first("user_id");
|
||||
if (db_user_address_id) return c.json({ success: true })
|
||||
// check if binded address count
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value);
|
||||
if (settings.maxAddressCount > 0) {
|
||||
const { count } = await c.env.DB.prepare(
|
||||
`SELECT COUNT(*) as count FROM users_address where user_id = ?`
|
||||
).bind(user_id).first();
|
||||
if (count >= settings.maxAddressCount) {
|
||||
return c.text("Max address count reached", 400)
|
||||
}
|
||||
}
|
||||
// bind
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO users_address (user_id, address_id) VALUES (?, ?)`
|
||||
).bind(user_id, address_id).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to bind", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("UNIQUE")) {
|
||||
return c.text("Address already binded, please unbind first", 400)
|
||||
}
|
||||
return c.text("Failed to bind", 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
unbind: async (c) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { address_id } = await c.req.json();
|
||||
if (!address_id || !user_id) {
|
||||
return c.text("Invalid address or user token", 400)
|
||||
}
|
||||
// check if address exists
|
||||
const db_address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where id = ?`
|
||||
).bind(address_id).first("id");
|
||||
if (!db_address_id) {
|
||||
return c.text("Address not found", 400)
|
||||
}
|
||||
// check if user exists
|
||||
const db_user_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM users where id = ?`
|
||||
).bind(user_id).first("id");
|
||||
if (!db_user_id) {
|
||||
return c.text("User not found", 400)
|
||||
}
|
||||
// unbind
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM users_address where user_id = ? and address_id = ?`
|
||||
).bind(user_id, address_id).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to unbind", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
return c.text("Invalid address token", 400)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
getBindedAddresses: async (c) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
if (!user_id) {
|
||||
return c.text("No user token", 400)
|
||||
}
|
||||
// select binded address
|
||||
const { results } = await c.env.DB.prepare(
|
||||
`SELECT a.*,`
|
||||
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
|
||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||
+ ` FROM address a `
|
||||
+ ` JOIN users_address ua `
|
||||
+ ` ON ua.address_id = a.id `
|
||||
+ ` WHERE ua.user_id = ?`
|
||||
+ ` ORDER BY a.id DESC`
|
||||
).bind(user_id).all();
|
||||
return c.json({
|
||||
results: results,
|
||||
})
|
||||
},
|
||||
getBindedAddressJwt: async (c) => {
|
||||
const { address_id } = c.req.param();
|
||||
// check binded
|
||||
const { user_id } = c.get("userPayload");
|
||||
if (!address_id || !user_id) {
|
||||
return c.text("Invalid address or user token", 400)
|
||||
}
|
||||
// check users_address if address binded
|
||||
const db_user_id = await c.env.DB.prepare(
|
||||
`SELECT user_id FROM users_address WHERE address_id = ? and user_id = ?`
|
||||
).bind(address_id, user_id).first("user_id");
|
||||
if (!db_user_id) {
|
||||
return c.text("Address not binded", 400)
|
||||
}
|
||||
// generate jwt
|
||||
const name = await c.env.DB.prepare(
|
||||
`SELECT name FROM address WHERE id = ? `
|
||||
).bind(address_id).first("name");
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET)
|
||||
return c.json({
|
||||
jwt: jwt
|
||||
})
|
||||
},
|
||||
}
|
||||
19
worker/src/user_api/index.js
Normal file
19
worker/src/user_api/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import settings from './settings';
|
||||
import user from './user';
|
||||
import bind_address from './bind_address';
|
||||
|
||||
const api = new Hono();
|
||||
|
||||
api.get('/user_api/open_settings', settings.openSettings);
|
||||
api.get('/user_api/settings', settings.settings);
|
||||
api.post('/user_api/login', user.login);
|
||||
api.post('/user_api/verify_code', user.verifyCode);
|
||||
api.post('/user_api/register', user.register);
|
||||
api.get('/user_api/bind_address', bind_address.getBindedAddresses);
|
||||
api.post('/user_api/bind_address', bind_address.bind);
|
||||
api.get('/user_api/bind_address_jwt/:address_id', bind_address.getBindedAddressJwt);
|
||||
api.post('/user_api/unbind_address', bind_address.unbind);
|
||||
|
||||
export { api }
|
||||
25
worker/src/user_api/settings.js
Normal file
25
worker/src/user_api/settings.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { UserSettings } from "../models";
|
||||
import { getJsonSetting } from "../utils"
|
||||
import { CONSTANTS } from "../constants";
|
||||
|
||||
export default {
|
||||
openSettings: async (c) => {
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value);
|
||||
return c.json({
|
||||
enable: settings.enable,
|
||||
enableMailVerify: settings.enableMailVerify,
|
||||
})
|
||||
},
|
||||
settings: async (c) => {
|
||||
const user = c.get("userPayload");
|
||||
// check if user exists
|
||||
const db_user_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM users where id = ?`
|
||||
).bind(user.user_id).first("id");
|
||||
if (!db_user_id) {
|
||||
return c.text("User not found", 400);
|
||||
}
|
||||
return c.json(user);
|
||||
},
|
||||
}
|
||||
144
worker/src/user_api/user.js
Normal file
144
worker/src/user_api/user.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { checkCfTurnstile, getJsonSetting, checkUserPassword } from "../utils"
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { GeoData, UserInfo, UserSettings } from "../models";
|
||||
import { sendMail } from "../mails_api/send_mail_api";
|
||||
|
||||
export default {
|
||||
verifyCode: async (c) => {
|
||||
const { email, cf_token } = await c.req.json();
|
||||
// check cf turnstile
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text("Failed to check cf turnstile", 500)
|
||||
}
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value)
|
||||
// check mail domain allow list
|
||||
const mailDomain = email.split("@")[1];
|
||||
if (settings.enableMailAllowList
|
||||
&& settings.mailAllowList
|
||||
&& !settings.mailAllowList.includes(mailDomain)
|
||||
) {
|
||||
return c.text(`Mail domain must in ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400)
|
||||
}
|
||||
// check if code exists in KV
|
||||
const tmpcode = await c.env.KV.get(`temp-mail:${email}`)
|
||||
if (tmpcode) {
|
||||
return c.text("Code already sent, please wait", 400)
|
||||
}
|
||||
// generate code 6 digits and convert to string
|
||||
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
// send code to email
|
||||
try {
|
||||
await sendMail(c, settings.verifyMailSender, {
|
||||
to_mail: email,
|
||||
subject: "Temp Mail Verify code",
|
||||
content: `Your verify code is ${code}`,
|
||||
})
|
||||
} catch (e) {
|
||||
return c.text(`Failed to send verify code: ${e.message}`, 500)
|
||||
}
|
||||
// save to KV
|
||||
await c.env.KV.put(`temp-mail:${email}`, code, { expirationTtl: 300 });
|
||||
return c.json({
|
||||
success: true,
|
||||
expirationTtl: 300
|
||||
})
|
||||
},
|
||||
register: async (c) => {
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value)
|
||||
// check enable
|
||||
if (!settings.enable) {
|
||||
return c.text("User registration is disabled");
|
||||
}
|
||||
// check request
|
||||
const { email, password, code } = await c.req.json();
|
||||
if (!email || !password) {
|
||||
return c.text("Invalid email or password", 400)
|
||||
}
|
||||
checkUserPassword(password);
|
||||
if (settings.enableMailVerify && !code) {
|
||||
return c.text("Need verify code", 400)
|
||||
}
|
||||
// check mail domain allow list
|
||||
const mailDomain = email.split("@")[1];
|
||||
if (settings.enableMailAllowList
|
||||
&& !settings.mailAllowList.includes(mailDomain)
|
||||
) {
|
||||
return c.text(`Mail domain must in ${JSON.stringify(settings.mailAllowList, null, 2)}`, 400)
|
||||
}
|
||||
// check code
|
||||
if (settings.enableMailVerify) {
|
||||
const verifyCode = await c.env.KV.get(`temp-mail:${email}`)
|
||||
if (verifyCode != code) {
|
||||
return c.text("Invalid verify code", 400)
|
||||
}
|
||||
}
|
||||
// geo data
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
const geoData = new GeoData(reqIp, c.req.raw.cf);
|
||||
const userInfo = new UserInfo(geoData);
|
||||
// if not enable mail verify, do not on conflict update
|
||||
if (!settings.enableMailVerify) {
|
||||
try {
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO users (user_email, password, user_info)`
|
||||
+ ` VALUES (?, ?, ?)`
|
||||
).bind(
|
||||
email, password, JSON.stringify(userInfo)
|
||||
).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to register", 500)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.message && e.message.includes("UNIQUE")) {
|
||||
return c.text("User already exists, please login", 400)
|
||||
}
|
||||
return c.text(`Failed to register: ${e.message}`, 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
}
|
||||
// if enable mail verify, on conflict update
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO users (user_email, password, user_info)`
|
||||
+ ` VALUES (?, ?, ?)`
|
||||
+ ` ON CONFLICT(user_email) DO UPDATE SET password = ?, user_info = ?, updated_at = datetime('now')`
|
||||
).bind(
|
||||
email, password, JSON.stringify(userInfo),
|
||||
password, JSON.stringify(userInfo)
|
||||
).run();
|
||||
if (!success) {
|
||||
return c.text("Failed to register", 500)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
},
|
||||
login: async (c) => {
|
||||
const { email, password } = await c.req.json();
|
||||
if (!email || !password) return c.text("Invalid email or password", 400);
|
||||
const { id: user_id, password: dbPassword } = await c.env.DB.prepare(
|
||||
`SELECT id, password FROM users where user_email = ?`
|
||||
).bind(email).first() || {};
|
||||
if (!dbPassword) {
|
||||
return c.text("User not found", 400)
|
||||
}
|
||||
// TODO: need check password use random salt
|
||||
if (dbPassword != password) {
|
||||
return c.text("Invalid password", 400)
|
||||
}
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
user_email: email,
|
||||
user_id: user_id,
|
||||
// 30 days expire in seconds
|
||||
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
}, c.env.JWT_SECRET)
|
||||
return c.json({
|
||||
jwt: jwt
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,56 @@
|
||||
import { createMimeMessage } from "mimetext";
|
||||
|
||||
export const getJsonSetting = async (c, key) => {
|
||||
const value = await getSetting(c, key);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
console.error(`GetJsonSetting: Failed to parse ${key}`, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const getSetting = async (c, key) => {
|
||||
try {
|
||||
const value = await c.env.DB.prepare(
|
||||
`SELECT value FROM settings where key = ?`
|
||||
).bind(key).first("value");
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(`GetSetting: Failed to get ${key}`, error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const saveSetting = async (c, key, value) => {
|
||||
await c.env.DB.prepare(
|
||||
`INSERT or REPLACE INTO settings (key, value) VALUES (?, ?)`
|
||||
+ ` ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = datetime('now')`
|
||||
).bind(key, value, value).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
export const getStringValue = (value) => {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export const getBooleanValue = (value) => {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value === "true";
|
||||
}
|
||||
console.error("Invalid boolean value", value);
|
||||
return false;
|
||||
}
|
||||
|
||||
export const getDomains = (c) => {
|
||||
if (!c.env.DOMAINS) {
|
||||
return [];
|
||||
@@ -78,3 +129,34 @@ export const sendAdminInternalMail = async (c, toMail, subject, text) => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkCfTurnstile = async (c, token) => {
|
||||
if (!c.env.CF_TURNSTILE_SITE_KEY) {
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error("Captcha token is required");
|
||||
}
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
let formData = new FormData();
|
||||
formData.append('secret', c.env.CF_TURNSTILE_SECRET_KEY);
|
||||
formData.append('response', token);
|
||||
formData.append('remoteip', reqIp);
|
||||
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
const result = await fetch(url, {
|
||||
body: formData,
|
||||
method: 'POST',
|
||||
});
|
||||
const captchaRes = await result.json();
|
||||
if (!captchaRes.success) {
|
||||
console.log("Captcha failed", captchaRes);
|
||||
throw new Error("Captcha failed");
|
||||
}
|
||||
}
|
||||
|
||||
export const checkUserPassword = (password) => {
|
||||
if (!password || password.length < 1 || password.length > 100) {
|
||||
throw new Error("Invalid password")
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors';
|
||||
import { jwt } from 'hono/jwt'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { api } from './router';
|
||||
import { api as MailsApi } from './mails_api'
|
||||
import { api as userApi } from './user_api';
|
||||
import { api as adminApi } from './admin_api';
|
||||
import { api as apiV1 } from './api_v1';
|
||||
import { api as apiSendMail } from './send_mail_api'
|
||||
import { api as apiV1 } from './deprecated';
|
||||
import { api as apiSendMail } from './mails_api/send_mail_api'
|
||||
|
||||
import { email } from './email';
|
||||
import { scheduled } from './scheduled';
|
||||
import { getAdminPasswords, getPasswords } from './utils';
|
||||
|
||||
const app = new Hono()
|
||||
//cors
|
||||
app.use('/*', cors());
|
||||
app.use('/api/*', async (c, next) => {
|
||||
// check header x-custom-auth
|
||||
const passwords = getPasswords(c);
|
||||
if (passwords && passwords.length > 0) {
|
||||
const auth = c.req.raw.headers.get("x-custom-auth");
|
||||
if (!auth || !passwords.includes(auth)) {
|
||||
return c.text("Need Password", 401)
|
||||
}
|
||||
}
|
||||
if (c.req.path.startsWith("/api/new_address") || c.req.path.startsWith("/api/send_mail")) {
|
||||
// rate limit
|
||||
app.use('/*', async (c, next) => {
|
||||
if (
|
||||
c.req.path.startsWith("/api/new_address")
|
||||
|| c.req.path.startsWith("/api/send_mail")
|
||||
|| c.req.path.startsWith("/external/api/send_mail")
|
||||
|| c.req.path.startsWith("/user_api/register")
|
||||
|| c.req.path.startsWith("/user_api/verify_code")
|
||||
) {
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
if (reqIp && c.env.RATE_LIMITER) {
|
||||
const { success } = await c.env.RATE_LIMITER.limit(
|
||||
@@ -31,13 +36,57 @@ app.use('/api/*', async (c, next) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
await next()
|
||||
});
|
||||
// api auth
|
||||
app.use('/api/*', async (c, next) => {
|
||||
// check header x-custom-auth
|
||||
const passwords = getPasswords(c);
|
||||
if (passwords && passwords.length > 0) {
|
||||
const auth = c.req.raw.headers.get("x-custom-auth");
|
||||
if (!auth || !passwords.includes(auth)) {
|
||||
return c.text("Need Password", 401)
|
||||
}
|
||||
}
|
||||
if (c.req.path.startsWith("/api/new_address")) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
return jwt({ secret: c.env.JWT_SECRET })(c, next);
|
||||
return jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
|
||||
});
|
||||
|
||||
// user_api auth
|
||||
app.use('/user_api/*', async (c, next) => {
|
||||
if (
|
||||
c.req.path.startsWith("/user_api/open_settings")
|
||||
|| c.req.path.startsWith("/user_api/register")
|
||||
|| c.req.path.startsWith("/user_api/login")
|
||||
|| c.req.path.startsWith("/user_api/verify_code")
|
||||
) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = c.req.raw.headers.get("x-user-token");
|
||||
const payload = await Jwt.verify(token, c.env.JWT_SECRET, "HS256");
|
||||
// check expired
|
||||
if (!payload.exp) return c.text("Invalid Token", 401);
|
||||
// exp is in seconds
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
return c.text("Token Expired", 401)
|
||||
}
|
||||
c.set("userPayload", payload);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return c.text("Need User Token", 401)
|
||||
}
|
||||
if (c.req.path.startsWith('/user_api/bind_address')
|
||||
&& c.req.method === 'POST'
|
||||
) {
|
||||
return jwt({ secret: c.env.JWT_SECRET, alg: "HS256" })(c, next);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
// admin auth
|
||||
app.use('/admin/*', async (c, next) => {
|
||||
// check header x-admin-auth
|
||||
const adminPasswords = getAdminPasswords(c);
|
||||
@@ -53,6 +102,8 @@ app.use('/admin/*', async (c, next) => {
|
||||
|
||||
|
||||
app.route('/', api)
|
||||
app.route('/', MailsApi)
|
||||
app.route('/', userApi)
|
||||
app.route('/', adminApi)
|
||||
app.route('/', apiV1)
|
||||
app.route('/', apiSendMail)
|
||||
@@ -65,4 +116,5 @@ app.all('/*', async c => c.text("Not Found", 404))
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
email: email,
|
||||
scheduled: scheduled,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ node_compat = true
|
||||
# { pattern = "temp-email-api.xxxxx.xyz", custom_domain = true },
|
||||
# ]
|
||||
|
||||
# enable cron if you want set auto clean up
|
||||
# [triggers]
|
||||
# crons = [ "0 0 * * *" ]
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
# IF YOU WANT TO MAKE YOUR SITE PRIVATE, UNCOMMENT THE FOLLOWING LINES
|
||||
@@ -18,12 +22,19 @@ PREFIX = "tmp"
|
||||
DOMAINS = ["xxx.xxx1" , "xxx.xxx2"]
|
||||
JWT_SECRET = "xxx"
|
||||
BLACK_LIST = ""
|
||||
# Allow users to create email addresses
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
# Allow users to delete messages
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
# Allow automatic replies to emails
|
||||
ENABLE_AUTO_REPLY = false
|
||||
# Footer text
|
||||
# COPYRIGHT = "Dream Hunter"
|
||||
# default send balance, if not set, it will be 0
|
||||
# DEFAULT_SEND_BALANCE = 1
|
||||
# Turnstile verification
|
||||
# CF_TURNSTILE_SITE_KEY = ""
|
||||
# CF_TURNSTILE_SECRET_KEY = ""
|
||||
# dkim config
|
||||
# DKIM_SELECTOR = ""
|
||||
# DKIM_PRIVATE_KEY = ""
|
||||
@@ -33,6 +44,11 @@ binding = "DB"
|
||||
database_name = "xxx"
|
||||
database_id = "xxx"
|
||||
|
||||
# kv config for send email verification code
|
||||
# [[kv_namespaces]]
|
||||
# binding = "KV"
|
||||
# id = "xxxx"
|
||||
|
||||
# ratelimit config for /api/new_address
|
||||
# [[unsafe.bindings]]
|
||||
# name = "RATE_LIMITER"
|
||||
|
||||
Reference in New Issue
Block a user