新增支持API添加用户,查询邮件

This commit is contained in:
eoao
2025-08-10 11:37:46 +08:00
parent 021c3b23f5
commit ba6b02635b
38 changed files with 894 additions and 713 deletions
+10 -95
View File
@@ -13,9 +13,10 @@
## Project Showcase
[**👉 Online Demo**](https://skymail.ink)
- [Online Demo](https://skymail.ink)<br>
- [Deployment Guide](https://doc.skymail.ink/en/)<br>
- [Beginners Guide UI Deployment](https://doc.skymail.ink/en/guide/via-ui.html)
[**👉 Beginners Guide UI Deployment**](https://doc.skymail.ink)
| ![](/doc/demo/demo1.png) | ![](/doc/demo/demo2.png) |
|--------------------------|---------------------|
@@ -25,6 +26,8 @@
## Features
- **💰 Low-Cost Usage**: No server required — deploy to Cloudflare Workers to reduce costs.
- **💻 Responsive Design**: Automatically adapts to both desktop and most mobile browsers.
- **📧 Email Sending**: Integrated with Resend for bulk email sending, embedded images, attachments, and status tracking.
@@ -37,6 +40,8 @@
- **🔔 Email Push**: Forward received emails to Telegram bots or other email providers.
- **📡 Open API**: Supports batch user creation via API and multi-condition email queries
- **📈 Data Visualization**: Use Echarts to visualize system data, including user email growth.
- **⭐ Starred Emails**: Mark important emails for quick access.
@@ -67,108 +72,18 @@
- **File Storage**: [Cloudflare R2](https://developers.cloudflare.com/r2/)
## Setup Guide
### System Requirements
Nodejs v18.20 +
Cloudflare account (with a bound domain)
**Clone the project to your local machine:**
``` shell
git clone https://github.com/eoao/cloud-mail
cd cloud-mail/mail-worker
```
**Install Dependencies:**
```shell
npm i
```
**Configure the Project**
mail-worker/wrangler.toml
```toml
[[d1_databases]]
binding = "db" # Default binding name for D1 database, cannot be changed
database_name = "" # Database name
database_id = "" # Database ID
[[kv_namespaces]]
binding = "kv" # Default binding name for KV storage, cannot be changed
id = "" # KV namespace ID
[[r2_buckets]]
binding = "r2" # Default binding name for R2 storage, cannot be changed
bucket_name = "" # R2 bucket name
[assets]
binding = "assets" # Static asset binding name, cannot be changed
directory = "./dist" # Directory for frontend Vue project build, default: dist
[vars]
orm_log = false
domain = [] # Configure email domains, example: ["example1.com", "example2.com"]
admin = "" # Admin email, example: "admin@example.com"
jwt_secret = "" # JWT secret for login tokens, choose a random string
```
**Deploy Remotely**
1. Create KV, D1 database, and R2 object storage in Cloudflare Console.
2. In the project directory `mail-worker/wrangler.toml`, configure the environment variables and database IDs/names.
3. Run the deployment command:
```shell
npm run deploy
```
4. In Cloudflare → Account Home → Your Domain → Email → Email Routing → Route Rules → Catch-all Address, edit and route to the worker.
5. In your browser, visit `https://your-project-domain/api/init/your-jwt-secret` to initialize or update the D1 and KV databases.
6. After deployment, log in to the site with the admin account to configure R2 domains, Turnstile keys, and more.
**Run Locally**
1. Run locally. Databases and object storage will automatically be set up, no manual creation needed. Data is stored in the `mail-worker/.wrangler` folder.
```shell
npm run dev
```
2. In your browser, visit `http://127.0.0.1:8787/api/init/your-jwt-secret` to initialize D1 and KV databases.
3. For local testing, you can set the R2 domain to `http://127.0.0.1:8787/api/file`.
**Email Sending**
1. Register on Resend, then click on “Domains” to add and verify your domain. Wait for verification.
2. Go to "API Keys" to create an API key, then copy the token and paste it in the project website settings.
3. Go to "Webhooks" and add a callback URL `https://your-project-domain/api/webhooks`.
Select the following events: ✅ (email.bounced, email.complained, email.delivered, email.delivery_delayed).
**Project Update**
After the update, run `https://your-project-domain/api/init/your-jwt-secret` to synchronize the database schema.
## Support
<a href="https://support.skymail.ink">
<a href="https://doc.skymail.ink/support.html">
<img width="170px" src="./doc/images/support.png" alt="">
</a><br><br>
**Special Sponsors**
[DartNode](https://dartnode.com)Providing cloud computing service resource support
[DartNode](https://dartnode.com)Providing cloud computing service resource support.
[![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
+22 -120
View File
@@ -6,7 +6,7 @@
<h1>Cloud Mail</h1>
</div>
<div align="center">
<h4>使用Vue3开发的响应式简约邮箱服务,支持邮件发送附件收发,可以部署到Cloudflare平台实现免费白嫖🎉</h4>
<h4>使用Vue3开发的响应式邮箱服务,支持邮件发送,无需服务器可部署到Cloudflare平台 🎉</h4>
</div>
<div align="center">
<span>简体中文 | <a href="/README-en.md" style="margin-left: 5px">English </a></span>
@@ -14,13 +14,13 @@
## 项目简介
只需要一个域名,就可以创建多个不同的邮箱,类似各大邮箱平台 QQ邮箱,谷歌邮箱等,本项目使用Cloud flare部署,Resend推送邮件,无需服务器费用,搭建属于自己的邮箱服务
只需要一个域名,就可以创建多个不同的邮箱,类似各大邮箱平台 QQ邮箱,谷歌邮箱等,本项目使用Cloudflare部署,Resend推送邮件,无需服务器费用,搭建自己的邮箱服务
## 项目展示
## 项目展示
[**👉 在线演示**](https://skymail.ink)
[**👉 小白保姆教程-界面部署**](https://doc.skymail.ink)
- [在线演示](https://skymail.ink)<br>
- [部署文档](https://doc.skymail.ink)<br>
- [小白保姆教程-界面部署](https://doc.skymail.ink/guide/via-ui.html)
| ![](/doc/demo/demo1.png) | ![](/doc/demo/demo2.png) |
|--------------------------|---------------------|
@@ -33,31 +33,33 @@
## 功能介绍
- **💰免费白嫖**:无需服务器,部署到Cloudflare Workers 免费使用,不要钱
- **💰 低成本使用**:无需服务器,部署到 Cloudflare Workers 降低使用成本
- **💻响应式设计**:响应式布局自动适配PC和大部分手机端浏览器
- **💻 响应式设计**:响应式布局自动适配PC和大部分手机端浏览器
- **📧邮件发送**:集成resend发送邮件,支持群发,内嵌图片和附件发送,发送状态查看
- **📧 邮件发送**:集成resend发送邮件,支持群发,内嵌图片和附件发送,发送状态查看
- **🛡️管理员功能**:可以对用户,邮件进行管理,RABC权限控制对功能及使用资源限制
- **🛡️ 管理员功能**:可以对用户,邮件进行管理,RABC权限控制对功能及使用资源限制
- **🔀多号模式**:开启后一个用户可以添加多个邮箱,默认一用户一邮箱,类似各大邮箱平台
- **🔀 多号模式**:开启后一个用户可以添加多个邮箱,默认一用户一邮箱,类似各大邮箱平台
- **📦附件收发**:支持收发附件,使用R2对象存储保存和下载文件
- **📦 附件收发**:支持收发附件,使用R2对象存储保存和下载文件
- **🔔邮件推送**:接收邮件后可以转发到TG机器人或其他服务商邮箱
- **🔔 邮件推送**:接收邮件后可以转发到TG机器人或其他服务商邮箱
- **📈数据可视化**:使用echarts对系统数据详情,用户邮件增长可视化显示
- **📡 开放API**:支持使用API批量生成用户,多条件查询邮件
- **⭐星标邮件**:标记重要邮件,以便快速查阅
- **📈 数据可视化**:使用echarts对系统数据详情,用户邮件增长可视化显示
- **🎨个性化设置**:可以自定义网站标题,登录背景,透明度
- **⭐ 星标邮件**:标记重要邮件,以便快速查阅
- **⚙️功能设置**:可以对注册,邮件发送,添加等功能关闭和开启,设为私人站点
- **🎨 个性化设置**:可以自定义网站标题,登录背景,透明度
- **🤖人机验证**:集成Turnstile人机验证,防止人机批量注册
- **⚙️ 功能设置**:可以对注册,邮件发送,添加等功能关闭和开启,设为私人站点
- **📜更多功能**:正在开发中...
- **🤖 人机验证**:集成Turnstile人机验证,防止人机批量注册
- **📜 更多功能**:正在开发中...
@@ -80,110 +82,10 @@
- **文件存储**[Cloudflare R2](https://developers.cloudflare.com/r2/)
## 使用教程
### 环境要求
Nodejs v18.20 +
Cloudflare 账号 (需要绑定域名)
**克隆项目到本地**
``` shell
git clone https://github.com/eoao/cloud-mail #拉取代码
cd cloud-mail/mail-worker #进入worker目录
```
**安装依赖**
```shell
npm i
```
**项目配置**
mail-worker/wrangler.toml
```toml
[[d1_databases]]
binding = "db" #d1数据库绑定名默认不可修改
database_name = "" #d1数据库名字
database_id = "" #d1数据库id
[[kv_namespaces]]
binding = "kv" #kv绑定名默认不可修改
id = "" #kv数据库id
[[r2_buckets]]
binding = "r2" #r2对象存储绑定名默认不可修改
bucket_name = "" #r2对象存储桶的名字
[assets]
binding = "assets" #静态资源绑定名默认不可修改
directory = "./dist" #前端vue项目打包的静态资源存放位置,默认dist
[vars]
orm_log = false
domain = [] #邮件域名可以配置多个示例: ["example1.com","example2.com"]
admin = "" #管理员的邮箱 示例: "admin@example.com"
jwt_secret = "" #登录身份令牌的密钥,随便填一串字符串
```
**远程部署**
1. 在 Cloudflare 控制台创建KVD1数据库,R2对象存储
2. 在项目目录 `mail-worker/wrangler.toml` 配置文件中配置对应环境变量,以及创建的数据库id和名称
3. 执行远程部署命令
```shell
npm run deploy
```
4. 在Cloudflare→账户主页→你的域名→电子邮件→电子邮件路由→路由规则→Catch-all地址,编辑发送到worker
5. 浏览器输入 `https://你的项目域名/api/init/你的jwt_secret` 初始化或更新 d1和kv数据库
6. 部署完成登录网站,使用管理员账号可以在设置页面添加配置 R2域名 Turnstile密钥 等
[👉 使用 Github Action 部署](/doc/github-action.md)
**本地运行**
1. 本地运行,数据库,对象存储会自动安装,无需创建,数据库数据保存在 `mail-worker/.wrangler` 文件夹
```shell
npm run dev
```
2. 浏览器输入 `http://127.0.0.1:8787/api/init/你的jwt_secret` 初始化d1和kv数据库
3. 本地运行项目设置页面r2域名可设置为 `http://127.0.0.1:8787/api/file`
**邮件发送**
1. 在 resend 官网注册后,点击左侧 Domains 添加并验证你的域名,等待验证完成
2. 点击左侧 Api Keys 创建立api key 复制token回到项目网站设置页面添加 resend token
3. 点击左侧 Webhooks 添加回调地址 `https://你的项目域名/api/webhooks`
勾选✅ (email.bounced email.complained email.delivered email.delivery_delayed)
**项目更新**
更新后需要执行 `https://你的项目域名/api/init/你的jwt_secret` 来同步数据库结构
## 赞助
<a href="https://support.skymail.ink" >
<a href="https://doc.skymail.ink/support.html" >
<img width="170px" src="./doc/images/support.png" alt="">
</a><br><br>
+1 -1
View File
@@ -1,3 +1,3 @@
NODE_ENV = 'remote'
VITE_APP_TITLE = '远程环境'
VITE_BASE_URL = 'https://mornglow.top/api'
VITE_BASE_URL = ''
+5 -2
View File
@@ -133,6 +133,7 @@ const en = {
loginSwitch: 'Sign in',
websiteSetting: 'Website',
websiteReg: 'Sign Up',
loginDomain: 'Sign-In Box Domain',
multipleEmail: 'Multiple Accounts',
multipleEmailDesc: 'Enable this feature to allow users to add multiple accounts.',
physicallyWipeData: 'Physically Wipe Data',
@@ -262,7 +263,7 @@ const en = {
rulesVerifyTitle: 'Trigger After {count} Daily Uses per IP',
botVerifyMsg: 'Please verify that you are human',
noticeTitle: 'Notice',
noticePopup: 'Sign-in Popup',
noticePopup: 'Sign-In Popup',
icon: 'Icon',
position: 'Position',
offset: 'Offset',
@@ -278,7 +279,9 @@ const en = {
popUp: 'Pop Up',
noRecipientTitle: 'No Recipient',
noRecipientDesc: 'Emails can be received even without a registered email address.',
preview: 'Preview'
preview: 'Preview',
help: 'Help',
document: 'Document'
}
export default en
+7 -4
View File
@@ -18,7 +18,7 @@ const zh = {
deleteUserBtn: '删除账户',
changePassword: '修改密码',
newPassword: '新的密码',
confirmPassword: '旧的密码',
confirmPassword: '确认密码',
add: '添加',
manage: '管理',
rename: '改名',
@@ -133,6 +133,7 @@ const zh = {
loginSwitch: '去登录',
websiteSetting: '网站设置',
websiteReg: '用户注册',
loginDomain: '登录框域名',
multipleEmail: '多号模式',
multipleEmailDesc: '开启后账号栏出现一个用户可以添加多个邮箱',
physicallyWipeData: '物理清空数据',
@@ -246,7 +247,7 @@ const zh = {
totalUserAccount: '{msg} 个',
sendBanned: '已禁用',
wrote: '来信',
support: '助',
support: '助',
supportDesc: '请我喝杯奶茶',
featDesc: '功能说明',
emailInterception: '邮件拦截',
@@ -278,7 +279,9 @@ const zh = {
popUp: '弹出',
noRecipientTitle: '无人收件',
noRecipientDesc: '即使没有注册的邮箱也能收到邮件',
preview: '预览'
preview: '预览',
help: '帮助',
document: '项目文档'
}
export default zh
export default zh
+102 -78
View File
@@ -1,28 +1,33 @@
<template>
<div class="account-box">
<div class="head-opt" >
<Icon v-perm="'account:add'" class="icon add" icon="ion:add-outline" width="23" height="23" @click="add" />
<Icon class="icon refresh" icon="ion:reload" width="18" height="18" @click="refresh" />
<div class="head-opt">
<Icon v-perm="'account:add'" class="icon add" icon="ion:add-outline" width="23" height="23" @click="add"/>
<Icon class="icon refresh" icon="ion:reload" width="18" height="18" @click="refresh"/>
</div>
<el-scrollbar class="scrollbar">
<div v-infinite-scroll="getAccountList" :infinite-scroll-distance="600" :infinite-scroll-immediate="false">
<el-card class="item" :class="itemBg(item.accountId)" v-for="item in accounts" :key="item.accountId" @click="changeAccount(item)">
<div class="account" >
<div v-infinite-scroll="getAccountList" :infinite-scroll-distance="600" :infinite-scroll-immediate="false">
<el-card class="item" :class="itemBg(item.accountId)" v-for="item in accounts" :key="item.accountId"
@click="changeAccount(item)">
<div class="account">
{{ item.email }}
</div>
<div class="opt">
<div class="send-email" @click.stop>
<Icon icon="eva:email-fill" width="22" height="22" color="#fccb1a" />
<Icon icon="eva:email-fill" width="22" height="22" color="#fccb1a"/>
</div>
<div class="settings" @click.stop>
<Icon icon="fluent-color:clipboard-24" width="22" height="22" @click.stop="copyAccount(item.email)"/>
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399" v-if="showNullSetting(item)" />
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399"
v-if="showNullSetting(item)"/>
<el-dropdown v-else>
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399" />
<template #dropdown >
<el-dropdown-menu >
<el-dropdown-item v-if="hasPerm('email:send')" @click="openSetName(item)">{{$t('rename')}}</el-dropdown-item>
<el-dropdown-item v-if="item.accountId !== userStore.user.accountId && hasPerm('account:delete')" @click="remove(item)">{{$t('delete')}}</el-dropdown-item>
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399"/>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="hasPerm('email:send')" @click="openSetName(item)">{{ $t('rename') }}
</el-dropdown-item>
<el-dropdown-item v-if="item.accountId !== userStore.user.accountId && hasPerm('account:delete')"
@click="remove(item)">{{ $t('delete') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -32,13 +37,13 @@
<!-- Initial Loading Skeleton -->
<template v-if="loading">
<el-skeleton v-for="i in 3" :key="i" animated>
<el-skeleton v-for="i in 3" :key="i" animated>
<template #template>
<el-card class="item">
<el-skeleton-item variant="p" style="width: 70%; height: 20px; margin-bottom: 20px" />
<el-skeleton-item variant="p" style="width: 70%; height: 20px; margin-bottom: 20px"/>
<div style="display: flex; justify-content: space-between">
<el-skeleton-item variant="text" style="width: 20px" />
<el-skeleton-item variant="text" style="width: 20px" />
<el-skeleton-item variant="text" style="width: 20px"/>
<el-skeleton-item variant="text" style="width: 20px"/>
</div>
</el-card>
</template>
@@ -50,10 +55,10 @@
<el-skeleton animated>
<template #template>
<el-card class="item">
<el-skeleton-item variant="p" style="width: 70%; height: 20px; margin-bottom: 20px" />
<el-skeleton-item variant="p" style="width: 70%; height: 20px; margin-bottom: 20px"/>
<div style="display: flex; justify-content: space-between">
<el-skeleton-item variant="text" style="width: 20px" />
<el-skeleton-item variant="text" style="width: 20px" />
<el-skeleton-item variant="text" style="width: 20px"/>
<el-skeleton-item variant="text" style="width: 20px"/>
</div>
</el-card>
</template>
@@ -61,43 +66,43 @@
</template>
<div class="noLoading" v-if="noLoading && accounts.length > 0">
<div>{{$t('noMoreData')}}</div>
<div>{{ $t('noMoreData') }}</div>
</div>
<div class="empty" v-if="noLoading && accounts.length === 0">
<el-empty :description="$t('noMessagesFound')" />
<el-empty :description="$t('noMessagesFound')"/>
</div>
</div>
</el-scrollbar>
<el-dialog v-model="showAdd" :title="$t('addAccount')" >
<div class="container">
<el-input v-model="addForm.email" ref="addRef" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append>
<div @click.stop="openSelect">
<el-select
ref="mySelect"
v-model="addForm.suffix"
:placeholder="$t('select')"
class="select"
>
<el-option
v-for="item in domainList"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<div style="color: #333">
<span >{{addForm.suffix}}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20" />
</div>
<el-dialog v-model="showAdd" :title="$t('addAccount')">
<div class="container">
<el-input v-model="addForm.email" ref="addRef" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append>
<div @click.stop="openSelect">
<el-select
ref="mySelect"
v-model="addForm.suffix"
:placeholder="$t('select')"
class="select"
>
<el-option
v-for="item in domainList"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<div style="color: #333">
<span>{{ addForm.suffix }}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
</div>
</template>
</el-input>
<el-button class="btn" type="primary" @click="submit" :loading="addLoading"
>{{$t('add')}}
</el-button>
</div>
</div>
</template>
</el-input>
<el-button class="btn" type="primary" @click="submit" :loading="addLoading"
>{{ $t('add') }}
</el-button>
</div>
<div
class="add-email-turnstile"
:class="verifyShow ? 'turnstile-show' : 'turnstile-hide'"
@@ -105,15 +110,15 @@
data-callback="onTurnstileSuccess"
data-error-callback="onTurnstileError"
>
<span style="font-size: 12px;color: #F56C6C" v-if="botJsError">人机验证模块加载失败,请刷新浏览器</span>
<span style="font-size: 12px;color: #F56C6C" v-if="botJsError">{{ $t('verifyModuleFailed') }}</span>
</div>
</el-dialog>
<el-dialog v-model="setNameShow" :title="$t('changeUserName')" >
<el-dialog v-model="setNameShow" :title="$t('changeUserName')">
<div class="container">
<el-input v-model="accountName" type="text" :placeholder="$t('username')" autocomplete="off">
</el-input>
<el-button class="btn" type="primary" @click="setName" :loading="setNameLoading"
>{{$t('save')}}
>{{ $t('save') }}
</el-button>
</div>
</el-dialog>
@@ -127,10 +132,10 @@ import {isEmail} from "@/utils/verify-utils.js";
import {useSettingStore} from "@/store/setting.js";
import {useAccountStore} from "@/store/account.js";
import {useUserStore} from "@/store/user.js";
import { hasPerm } from "@/perm/perm.js"
import {hasPerm} from "@/perm/perm.js"
import {useI18n} from "vue-i18n";
const { t } = useI18n();
const {t} = useI18n();
const userStore = useUserStore();
const accountStore = useAccountStore();
const settingStore = useSettingStore();
@@ -150,6 +155,7 @@ let account = null
let turnstileId = null
const botJsError = ref(false)
let verifyToken = ''
let verifyErrorCount = 0
const addForm = reactive({
email: '',
suffix: settingStore.domainList[0]
@@ -175,14 +181,20 @@ const openSelect = () => {
}
window.onTurnstileError = (e) => {
console.log('人机验加载失败')
nextTick(() => {
if (!turnstileId) {
turnstileId = window.turnstile.render('.register-turnstile')
} else {
window.turnstile.reset(turnstileId);
}
})
if (verifyErrorCount >= 4) {
return
}
verifyErrorCount++
console.warn('人机验加载失败', e)
setTimeout(() => {
nextTick(() => {
if (!turnstileId) {
turnstileId = window.turnstile.render('.add-email-turnstile')
} else {
window.turnstile.reset(turnstileId);
}
})
}, 1500)
};
window.onTurnstileSuccess = (token) => {
@@ -208,7 +220,7 @@ function setName() {
}
setNameLoading.value = true
accountSetName(account.accountId,name).then(() => {
accountSetName(account.accountId, name).then(() => {
account.name = name
setNameShow.value = false
@@ -221,12 +233,12 @@ function setName() {
type: "success",
plain: true
})
}).finally(()=> {
}).finally(() => {
setNameLoading.value = false
})
}
function openSetName (accountItem) {
function openSetName(accountItem) {
accountName.value = accountItem.name
account = accountItem
setNameShow.value = true
@@ -241,7 +253,7 @@ function itemBg(accountId) {
}
function remove(account) {
ElMessageBox.confirm(t('delConfirm',{msg: account.email}), {
ElMessageBox.confirm(t('delConfirm', {msg: account.email}), {
confirmButtonText: t('confirm'),
cancelButtonText: t('cancel'),
type: 'warning'
@@ -272,6 +284,7 @@ function refresh() {
accounts.splice(0, accounts.length)
getAccountList()
}
function changeAccount(account) {
accountStore.currentAccountId = account.accountId
accountStore.currentAccount = account
@@ -281,7 +294,7 @@ function add() {
showAdd.value = true
setTimeout(() => {
addRef.value.focus()
},100)
}, 100)
}
async function copyAccount(account) {
@@ -308,11 +321,11 @@ function getAccountList() {
if (accounts.length === 0) {
loading.value = true
}else {
} else {
followLoading.value = true
}
accountList(queryParams.accountId,queryParams.size).then(list => {
accountList(queryParams.accountId, queryParams.size).then(list => {
if (list.length < queryParams.size) {
noLoading.value = true
}
@@ -331,9 +344,9 @@ function getAccountList() {
}
function submit() {
function submit() {
if (!addForm.email){
if (!addForm.email) {
ElMessage({
message: t('emptyEmailMsg'),
type: "error",
@@ -342,7 +355,7 @@ function submit() {
return
}
if (!isEmail(addForm.email+addForm.suffix)) {
if (!isEmail(addForm.email + addForm.suffix)) {
ElMessage({
message: t('notEmailMsg'),
type: "error",
@@ -376,7 +389,7 @@ function submit() {
}
addLoading.value = true
accountAdd(addForm.email+addForm.suffix,verifyToken).then(account => {
accountAdd(addForm.email + addForm.suffix, verifyToken).then(account => {
addLoading.value = false
showAdd.value = false
addForm.email = ''
@@ -418,6 +431,7 @@ path[fill="#ffdda1"] {
background-color: #FFF;
height: 100%;
overflow: hidden;
.head-opt {
display: flex;
align-items: center;
@@ -425,9 +439,11 @@ path[fill="#ffdda1"] {
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
padding-left: 10px;
padding-right: 10px;
.icon{
.icon {
cursor: pointer;
}
.refresh {
margin-left: 10px;
}
@@ -440,6 +456,7 @@ path[fill="#ffdda1"] {
margin-left: 5px;
}
}
.scrollbar {
width: 100%;
height: calc(100% - 38px);
@@ -447,12 +464,14 @@ path[fill="#ffdda1"] {
@media (max-width: 767px) {
height: calc(100% - 98px);
}
.empty {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.noLoading {
display: flex;
justify-content: center;
@@ -461,6 +480,7 @@ path[fill="#ffdda1"] {
color: gray;
}
}
.btn {
width: 100%;
margin-top: 15px;
@@ -474,6 +494,7 @@ path[fill="#ffdda1"] {
margin-left: 10px;
margin-right: 10px;
cursor: pointer;
.account {
font-weight: 600;
margin-bottom: 20px;
@@ -488,11 +509,13 @@ path[fill="#ffdda1"] {
justify-content: space-between;
font-size: 12px;
color: #888;
.settings {
display: flex;
align-items: center;
gap: 10px;
}
.send-email {
display: flex;
align-items: center;
@@ -503,8 +526,9 @@ path[fill="#ffdda1"] {
padding: 0;
}
}
.item:first-child{
margin-top: 10px ;
.item:first-child {
margin-top: 10px;
}
.item-choose {
@@ -560,4 +584,4 @@ path[fill="#ffdda1"] {
position: fixed;
}
</style>
</style>
+6 -1
View File
@@ -1,5 +1,6 @@
import {createRouter, createWebHistory} from 'vue-router'
import {useUiStore} from "@/store/ui.js";
import {replace} from "lodash-es";
const routes = [
{
@@ -81,7 +82,7 @@ router.beforeEach(async (to, from, next) => {
if (!token && to.name !== 'login') {
return next({
name: 'login'
name: 'login',
})
}
@@ -89,6 +90,10 @@ router.beforeEach(async (to, from, next) => {
return next()
}
if (token && to.name === 'login') {
next(from.path)
}
next()
})
+72 -42
View File
@@ -1,14 +1,14 @@
<template>
<div v-if="analysisLoading" class="analysis-loading">
<loading />
<loading/>
</div>
<el-scrollbar v-else style="height: 100%;">
<el-scrollbar v-else style="height: 100%;">
<div class="analysis" :key="boxKey">
<div class="number">
<div class="number-item">
<div class="top">
<div class="left">
<div>{{$t('totalReceived')}}</div>
<div>{{ $t('totalReceived') }}</div>
<div>
<el-statistic :formatter="value => Math.round(value)" :value="receiveData"/>
</div>
@@ -20,14 +20,14 @@
</div>
</div>
<div class="delete-ratio">
<div>{{$t('active')}} <span class="normal">{{numberCount.normalReceiveTotal}}</span></div>
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delReceiveTotal}}</span></div>
<div>{{ $t('active') }} <span class="normal">{{ numberCount.normalReceiveTotal }}</span></div>
<div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delReceiveTotal }}</span></div>
</div>
</div>
<div class="number-item">
<div class="top">
<div class="left">
<div>{{$t('totalSent')}}</div>
<div>{{ $t('totalSent') }}</div>
<div>
<el-statistic :formatter="value => Math.round(value)" :value="sendData"/>
</div>
@@ -39,14 +39,14 @@
</div>
</div>
<div class="delete-ratio">
<div>{{$t('active')}} <span class="normal">{{numberCount.normalSendTotal}}</span></div>
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delSendTotal}}</span></div>
<div>{{ $t('active') }} <span class="normal">{{ numberCount.normalSendTotal }}</span></div>
<div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delSendTotal }}</span></div>
</div>
</div>
<div class="number-item">
<div class="top">
<div class="left">
<div>{{$t('totalMailboxes')}}</div>
<div>{{ $t('totalMailboxes') }}</div>
<div>
<el-statistic :formatter="value => Math.round(value)" :value="accountData"/>
</div>
@@ -58,14 +58,14 @@
</div>
</div>
<div class="delete-ratio">
<div>{{$t('active')}} <span class="normal">{{numberCount.normalAccountTotal}}</span></div>
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delAccountTotal}}</span></div>
<div>{{ $t('active') }} <span class="normal">{{ numberCount.normalAccountTotal }}</span></div>
<div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delAccountTotal }}</span></div>
</div>
</div>
<div class="number-item">
<div class="top">
<div class="left">
<div>{{$t('totalUsers')}}</div>
<div>{{ $t('totalUsers') }}</div>
<div>
<el-statistic :formatter="value => Math.round(value)" :value="userData"/>
</div>
@@ -77,19 +77,19 @@
</div>
</div>
<div class="delete-ratio">
<div>{{$t('active')}} <span class="normal">{{numberCount.normalUserTotal}}</span></div>
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delUserTotal}}</span></div>
<div>{{ $t('active') }} <span class="normal">{{ numberCount.normalUserTotal }}</span></div>
<div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delUserTotal }}</span></div>
</div>
</div>
</div>
<div class="picture">
<div class="picture-item">
<div class="title" style="display: flex;justify-content: space-between;">
<span>{{$t('emailSource')}}</span>
<span>{{ $t('emailSource') }}</span>
<span class="source-button" v-if="false">
<el-radio-group v-model="checkedSourceType" >
<el-radio-button label="发件人" value="sender" />
<el-radio-button label="邮箱" value="email" />
<el-radio-group v-model="checkedSourceType">
<el-radio-button label="发件人" value="sender"/>
<el-radio-button label="邮箱" value="email"/>
</el-radio-group>
</span>
</div>
@@ -98,7 +98,7 @@
</div>
</div>
<div class="picture-item">
<div class="title">{{$t('userGrowth')}}</div>
<div class="title">{{ $t('userGrowth') }}</div>
<div class="increase-line">
</div>
@@ -106,11 +106,11 @@
</div>
<div class="picture-cs">
<div class="picture-cs-item">
<div class="title">{{$t('emailGrowth')}}</div>
<div class="title">{{ $t('emailGrowth') }}</div>
<div class="email-column"></div>
</div>
<div class="picture-cs-item">
<div class="title">{{$t('sentToday')}}</div>
<div class="title">{{ $t('sentToday') }}</div>
<div class="send-count"></div>
</div>
</div>
@@ -129,13 +129,13 @@ import {useUiStore} from "@/store/ui.js";
import {debounce} from "lodash-es";
import loading from "@/components/loading/index.vue";
import {useRoute} from "vue-router";
import { useI18n } from 'vue-i18n';
import {useI18n} from 'vue-i18n';
defineOptions({
name: 'analysis'
})
const { t } = useI18n();
const {t} = useI18n();
const route = useRoute();
const uiStore = useUiStore()
const checkedSourceType = ref('sender')
@@ -218,13 +218,12 @@ onMounted(() => {
}
})
userLineData.xdata = data.userDayCount.map(item => dayjs(item.date).format("M.D"))
userLineData.xdata = data.userDayCount.map(item => dayjs(item.date).format("M.D"));
userLineData.sdata = data.userDayCount.map(item => item.total)
emailColumnData.daysData = data.emailDayCount.receiveDayCount.map(item => dayjs(item.date).format("M.D"))
emailColumnData.receiveData = data.emailDayCount.receiveDayCount.map(item => item.total)
emailColumnData.sendData = data.emailDayCount.sendDayCount.map(item => item.total)
daySendTotal = data.daySendTotal
analysisLoading.value = false
initPicture();
@@ -234,8 +233,8 @@ onMounted(() => {
})
function initPicture() {
if(route.name !== 'analysis') return
boxKey.value ++
if (route.name !== 'analysis') return
boxKey.value++
setTimeout(() => {
createSenderPie()
createIncreaseLine()
@@ -282,7 +281,7 @@ function setStyle() {
const measureCtx = document.createElement('canvas').getContext('2d');
measureCtx.font = '12px sans-serif';
function truncateTextByWidth(text,maxWidth = 140) {
function truncateTextByWidth(text, maxWidth = 140) {
let width = measureCtx.measureText(text).width;
if (width <= maxWidth) return text;
@@ -311,22 +310,22 @@ function createSenderPie() {
return `${params.marker} ${params.name} ${params.value} (${params.percent}%)`;
}
},
legend: {
type: 'scroll',
orient: 'vertical',
left: '10',
top: '20',
formatter: function (name) {
return truncateTextByWidth(name)
}
},
legend: {
type: 'scroll',
orient: 'vertical',
left: '10',
top: '20',
formatter: function (name) {
return truncateTextByWidth(name)
}
},
series: [
{
data: senderData.value,
name: '',
type: 'pie',
radius: ['40%', '65%'],
center: [ senderPieLeft, '45%'],
center: [senderPieLeft, '45%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 4,
@@ -378,7 +377,7 @@ function createIncreaseLine() {
formatter: function (params) {
let result = ''
params.forEach(item => {
result = `${item.marker} ${t('growthTotalUsers')} ${item.value}`;
result = `${item.marker} ${t('growthTotalUsers')} ${item.value}`;
});
return result;
},
@@ -438,7 +437,7 @@ function createIncreaseLine() {
},
boundaryGap: [0, 0.1],
max: (params) => {
if (params.max < 8 ) {
if (params.max < 8) {
return 10
}
},
@@ -493,6 +492,21 @@ function createIncreaseLine() {
]
};
increaseLine.setOption(option);
let max = increaseLine.getModel().getComponent('yAxis', 0).axis.scale.getExtent()[1];
let left = 35
if (max > 99) left = 42
if (max > 999) left = 51
if (max > 9999) left = 58
if (max > 99999) left = 66
increaseLine.setOption({
grid: {
left: left
}
});
}
function createEmailColumnChart() {
@@ -537,7 +551,7 @@ function createEmailColumnChart() {
},
yAxis: {
max: (params) => {
if (params.max < 8 ) {
if (params.max < 8) {
return 10
}
},
@@ -584,7 +598,7 @@ function createEmailColumnChart() {
}
function createSendGauge() {
if(sendGauge) {
if (sendGauge) {
sendGauge.dispose()
}
sendGauge = echarts.init(document.querySelector(".send-count"));
@@ -649,6 +663,7 @@ function createSendGauge() {
margin-top: 10px;
font-size: 28px;
}
.percentage-label {
display: block;
margin-top: 10px;
@@ -663,6 +678,7 @@ function createSendGauge() {
align-items: center;
justify-content: center;
}
.analysis {
height: 100%;
padding: 20px 20px 30px;
@@ -674,12 +690,14 @@ function createSendGauge() {
padding: 15px 15px 30px;
gap: 15px
}
.title {
margin-top: 10px;
margin-left: 15px;
font-size: 18px;
font-weight: 500;
}
.number {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
@@ -691,11 +709,13 @@ function createSendGauge() {
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
.number-item {
background: var(--el-bg-color);
border-radius: 8px;
border: 1px solid var(--el-border-color);
padding: 21px 20px;
.top {
display: grid;
justify-content: space-between;
@@ -719,6 +739,7 @@ function createSendGauge() {
.right {
display: grid;
align-items: center;
.count-icon {
top: 3px;
position: relative;
@@ -740,6 +761,7 @@ function createSendGauge() {
justify-content: start;
gap: 20px;
padding-top: 5px;
.normal {
width: fit-content;
color: var(--el-color-success);
@@ -768,24 +790,29 @@ function createSendGauge() {
@media (max-width: 1024px) {
gap: 15px;
}
.picture-item {
background: #fff;
border-radius: 8px;
border: 1px solid var(--el-border-color);
.source-button {
padding-right: 15px;
display: flex;
align-items: start;
:deep(.el-radio-button__inner) {
padding: 6px 10px;
}
}
.sender-pie {
height: 350px;
@media (max-width: 767px) {
height: 200px;
}
}
.increase-line {
height: 350px;
@media (max-width: 767px) {
@@ -803,16 +830,19 @@ function createSendGauge() {
grid-template-columns: 1fr;
gap: 15px;
}
.picture-cs-item {
background: #fff;
border-radius: 8px;
border: 1px solid var(--el-border-color);
.send-count {
height: 350px;
@media (max-width: 767px) {
height: 320px;
}
}
.email-column {
height: 350px;
@media (max-width: 767px) {
+113 -95
View File
@@ -9,89 +9,96 @@
</div>
<div v-else :style="background"></div>
<div class="form-wrapper">
<div class="container">
<span class="form-title">{{settingStore.settings.title}}</span>
<span class="form-desc" v-if="show === 'login'">{{$t('loginTitle')}}</span>
<span class="form-desc" v-else>{{$t('regTitle')}}</span>
<div v-show="show === 'login'">
<el-input class="email-input" v-model="form.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append>
<div @click.stop="openSelect">
<el-select
v-if="show === 'login'"
ref="mySelect"
v-model="suffix"
:placeholder="$t('select')"
class="select"
>
<el-option
v-for="item in domainList"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<div style="color: #333">
<span>{{ suffix }}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
</div>
<div class="container">
<span class="form-title">{{ settingStore.settings.title }}</span>
<span class="form-desc" v-if="show === 'login'">{{ $t('loginTitle') }}</span>
<span class="form-desc" v-else>{{ $t('regTitle') }}</span>
<div v-show="show === 'login'">
<el-input :class="settingStore.settings.loginDomain === 0 ? 'email-input' : ''" v-model="form.email"
type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append v-if="settingStore.settings.loginDomain === 0">
<div @click.stop="openSelect">
<el-select
v-if="show === 'login'"
ref="mySelect"
v-model="suffix"
:placeholder="$t('select')"
class="select"
>
<el-option
v-for="item in domainList"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<div style="color: #333">
<span>{{ suffix }}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
</div>
</template>
</el-input>
<el-input v-model="form.password" :placeholder="$t('password')" type="password" autocomplete="off">
</el-input>
<el-button class="btn" type="primary" @click="submit" :loading="loginLoading"
>{{$t('loginBtn')}}
</el-button>
</div>
<div v-show="show !== 'login'">
<el-input class="email-input" v-model="registerForm.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append>
<div @click.stop="openSelect">
<el-select
v-if="show !== 'login'"
ref="mySelect"
v-model="suffix"
:placeholder="$t('select')"
class="select"
>
<el-option
v-for="item in domainList"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<div style="color: #333">
<span>{{ suffix }}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
</div>
</div>
</template>
</el-input>
<el-input v-model="registerForm.password" :placeholder="$t('password')" type="password" autocomplete="off" />
<el-input v-model="registerForm.confirmPassword" :placeholder="$t('confirmPwd')" type="password" autocomplete="off" />
<el-input v-if="settingStore.settings.regKey === 0" v-model="registerForm.code" :placeholder="$t('regKey')" type="text" autocomplete="off" />
<el-input v-if="settingStore.settings.regKey === 2" v-model="registerForm.code" :placeholder="$t('regKeyOptional')" type="text" autocomplete="off" />
<div v-show="verifyShow"
class="register-turnstile"
:data-sitekey="settingStore.settings.siteKey"
data-callback="onTurnstileSuccess"
data-error-callback="onTurnstileError"
data-after-interactive-callback="loadAfter"
data-before-interactive-callback="loadBefore"
>
<span style="font-size: 12px;color: #F56C6C" v-if="botJsError">{{$t('verifyModuleFailed')}}</span>
</div>
<el-button class="btn" type="primary" @click="submitRegister" :loading="registerLoading"
>{{$t('regBtn')}}
</el-button>
</div>
<template v-if="settingStore.settings.register === 0">
<div class="switch" @click="show = 'register'" v-if="show === 'login'">{{$t('noAccount')}} <span>{{$t('regSwitch')}}</span></div>
<div class="switch" @click="show = 'login'" v-else>{{$t('hasAccount')}} <span>{{$t('loginSwitch')}}</span></div>
</template>
</div>
</template>
</el-input>
<el-input v-model="form.password" :placeholder="$t('password')" type="password" autocomplete="off">
</el-input>
<el-button class="btn" type="primary" @click="submit" :loading="loginLoading"
>{{ $t('loginBtn') }}
</el-button>
</div>
<div v-show="show !== 'login'">
<el-input class="email-input" v-model="registerForm.email" type="text" :placeholder="$t('emailAccount')"
autocomplete="off">
<template #append>
<div @click.stop="openSelect">
<el-select
v-if="show !== 'login'"
ref="mySelect"
v-model="suffix"
:placeholder="$t('select')"
class="select"
>
<el-option
v-for="item in domainList"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<div style="color: #333">
<span>{{ suffix }}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
</div>
</div>
</template>
</el-input>
<el-input v-model="registerForm.password" :placeholder="$t('password')" type="password" autocomplete="off"/>
<el-input v-model="registerForm.confirmPassword" :placeholder="$t('confirmPwd')" type="password"
autocomplete="off"/>
<el-input v-if="settingStore.settings.regKey === 0" v-model="registerForm.code" :placeholder="$t('regKey')"
type="text" autocomplete="off"/>
<el-input v-if="settingStore.settings.regKey === 2" v-model="registerForm.code"
:placeholder="$t('regKeyOptional')" type="text" autocomplete="off"/>
<div v-show="verifyShow"
class="register-turnstile"
:data-sitekey="settingStore.settings.siteKey"
data-callback="onTurnstileSuccess"
data-error-callback="onTurnstileError"
data-after-interactive-callback="loadAfter"
data-before-interactive-callback="loadBefore"
>
<span style="font-size: 12px;color: #F56C6C" v-if="botJsError">{{ $t('verifyModuleFailed') }}</span>
</div>
<el-button class="btn" type="primary" @click="submitRegister" :loading="registerLoading"
>{{ $t('regBtn') }}
</el-button>
</div>
<template v-if="settingStore.settings.register === 0">
<div class="switch" @click="show = 'register'" v-if="show === 'login'">{{ $t('noAccount') }}
<span>{{ $t('regSwitch') }}</span></div>
<div class="switch" @click="show = 'login'" v-else>{{ $t('hasAccount') }} <span>{{ $t('loginSwitch') }}</span>
</div>
</template>
</div>
</div>
</div>
</template>
@@ -112,7 +119,7 @@ import {loginUserInfo} from "@/request/my.js";
import {permsToRouter} from "@/perm/perm.js";
import {useI18n} from "vue-i18n";
const { t } = useI18n();
const {t} = useI18n();
const accountStore = useAccountStore();
const userStore = useUserStore();
const uiStore = useUiStore();
@@ -139,20 +146,27 @@ const verifyShow = ref(false)
let verifyToken = ''
let turnstileId = null
let botJsError = ref(false)
let verifyErrorCount = 0
window.onTurnstileSuccess = (token) => {
verifyToken = token;
};
window.onTurnstileError = (e) => {
console.log('人机验加载失败')
nextTick(() => {
if (!turnstileId) {
turnstileId = window.turnstile.render('.register-turnstile')
} else {
window.turnstile.reset(turnstileId);
}
})
if (verifyErrorCount >= 4) {
return
}
verifyErrorCount++
console.warn('人机验加载失败', e)
setTimeout(() => {
nextTick(() => {
if (!turnstileId) {
turnstileId = window.turnstile.render('.register-turnstile')
} else {
window.turnstile.reset(turnstileId);
}
})
}, 1500)
};
window.loadAfter = (e) => {
@@ -193,7 +207,9 @@ const submit = () => {
return
}
if (!isEmail(form.email + suffix.value)) {
let email = form.email + (settingStore.settings.loginDomain === 0 ? suffix.value : '');
if (!isEmail(email)) {
ElMessage({
message: t('notEmailMsg'),
type: 'error',
@@ -212,7 +228,7 @@ const submit = () => {
}
loginLoading.value = true
login(form.email + suffix.value, form.password).then(async data => {
login(email, form.password).then(async data => {
localStorage.setItem('token', data.token)
const user = await loginUserInfo();
accountStore.currentAccountId = user.accountId;
@@ -277,7 +293,7 @@ function submitRegister() {
return
}
if(settingStore.settings.regKey === 0) {
if (settingStore.settings.regKey === 0) {
if (!registerForm.code) {
@@ -414,6 +430,7 @@ function submitRegister() {
margin-right: 18px;
margin-left: 18px;
}
.btn {
height: 36px;
width: 100%;
@@ -434,6 +451,7 @@ function submitRegister() {
.switch {
margin-top: 20px;
text-align: center;
span {
color: #006be6;
cursor: pointer;
@@ -444,7 +462,7 @@ function submitRegister() {
border-radius: 6px;
}
.email-input :deep(.el-input__wrapper){
.email-input :deep(.el-input__wrapper) {
border-radius: 6px 0 0 6px;
}
@@ -452,6 +470,7 @@ function submitRegister() {
height: 38px;
width: 100%;
margin-bottom: 18px;
:deep(.el-input__inner) {
height: 36px;
}
@@ -497,7 +516,6 @@ function submitRegister() {
}
#login-box {
background: linear-gradient(to bottom, #2980b9, #6dd5fa, #fff);
color: #333;
+3 -3
View File
@@ -41,8 +41,8 @@
</div>
<el-dialog v-model="pwdShow" :title="$t('changePassword')" width="340">
<div class="update-pwd">
<el-input type="password" :placeholder="$t('newPassword')" v-model="form.password"/>
<el-input type="password" :placeholder="$t('confirmPassword')" v-model="form.newPwd"/>
<el-input type="password" :placeholder="$t('newPassword')" v-model="form.password" autocomplete="off"/>
<el-input type="password" :placeholder="$t('confirmPassword')" v-model="form.newPwd" autocomplete="off"/>
<el-button type="primary" :loading="setPwdLoading" @click="submitPwd">{{$t('save')}}</el-button>
</div>
</el-dialog>
@@ -256,4 +256,4 @@ function submitPwd() {
gap: 20px;
}
}
</style>
</style>
+22 -10
View File
@@ -17,6 +17,13 @@
v-model="setting.register"/>
</div>
</div>
<div class="setting-item">
<div><span>{{$t('loginDomain')}}</span></div>
<div>
<el-switch @change="change" :before-change="beforeChange" :active-value="0" :inactive-value="1"
v-model="setting.loginDomain"/>
</div>
</div>
<div class="setting-item">
<div><span>{{$t('regKey')}}</span></div>
<div>
@@ -326,7 +333,7 @@
<div class="card-content">
<div class="concerning-item">
<span>{{$t('version')}} :</span>
<span>v1.6.0</span>
<span>v1.7.0</span>
</div>
<div class="concerning-item">
<span>{{$t('community')}} : </span>
@@ -345,13 +352,22 @@
</div>
<div class="concerning-item">
<span>{{$t('support')}} : </span>
<el-button @click="jump('https://support.skymail.ink')" >
<el-button @click="jump('https://doc.skymail.ink/support.html')" >
{{t('supportDesc')}}
<template #icon>
<Icon color="#79D6B5" icon="simple-icons:buymeacoffee" width="20" height="20" />
</template>
</el-button>
</div>
<div class="concerning-item">
<span>{{$t('help')}} : </span>
<el-button @click="jump('https://doc.skymail.ink')" >
{{t('document')}}
<template #icon>
<Icon color="#79D6B5" icon="fluent-color:document-32" width="18" height="18" />
</template>
</el-button>
</div>
</div>
</div>
</div>
@@ -1150,8 +1166,8 @@ function editSetting(settingForm, refreshStatus = true) {
}
.background {
width: 230px;
height: 120px;
width: 250px;
height: 140px;
border-radius: 4px;
border: 1px solid #e4e7ed;
@media (max-width: 500px) {
@@ -1178,11 +1194,7 @@ function editSetting(settingForm, refreshStatus = true) {
overflow: hidden;
}
@media (min-width: 885px) {
.about {
height: 210px;
}
}
.card-title {
font-size: 15px;
@@ -1501,4 +1513,4 @@ form .el-button {
<style>
.el-popper.is-dark {
}
</style>
</style>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" href="/assets/favicon-C5dAZutX.svg" type="image/svg+xml">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script type="module" crossorigin src="/assets/index-DQO7jFFS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BP2DuLPL.css">
<script type="module" crossorigin src="/assets/index-ONNky_gH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-NqVTnf-N.css">
</head>
<body>
<div id="loading-first">
+18
View File
@@ -0,0 +1,18 @@
import app from '../hono/hono';
import result from '../model/result';
import publicService from '../service/public-service';
app.post('/public/genToken', async (c) => {
const data = await publicService.genToken(c, await c.req.json());
return c.json(result.ok(data));
});
app.post('/public/emailList', async (c) => {
const list = await publicService.emailList(c, await c.req.json());
return c.json(result.ok(list));
});
app.post('/public/addUser', async (c) => {
await publicService.addUser(c, await c.req.json());
return c.json(result.ok());
});
+2 -1
View File
@@ -1,7 +1,8 @@
const KvConst = {
AUTH_INFO: 'auth-uid:',
SETTING: 'setting:',
SEND_DAY_COUNT: 'send_day_count:'
SEND_DAY_COUNT: 'send_day_count:',
PUBLIC_KEY: "public_key:"
}
export default KvConst;
+2 -1
View File
@@ -33,6 +33,7 @@ export const setting = sqliteTable('setting', {
noticeOffset: integer('notice_offset').default(0).notNull(),
noticeWidth: integer('notice_width').default(400).notNull(),
notice: integer('notice').default(0).notNull(),
noRecipient: integer('no_recipient').default(1).notNull()
noRecipient: integer('no_recipient').default(1).notNull(),
loginDomain: integer('login_domain').default(0).notNull()
});
export default setting
+1
View File
@@ -17,4 +17,5 @@ import '../api/all-email-api'
import '../api/init-api'
import '../api/analysis-api'
import '../api/reg-key-api'
import '../api/public-api'
export default app;
+3
View File
@@ -59,6 +59,9 @@ const en = {
noDomainPermRegKey: "Registration code not valid for this domain",
noDomainPermSend: "No permission to send from this domain email",
JWTMismatch: 'JWT secret mismatch',
publicTokenFail: 'Token validation failed',
notAdmin: 'The entered email is not an administrator email',
emailExistDatabase: 'Email already exists in the database',
perms: {
"邮件": "Email",
"邮件发送": "Send Email",
+1 -1
View File
@@ -21,7 +21,7 @@ const resources = {
};
i18next.init({
fallbackLng: 'en',
fallbackLng: 'zh',
resources,
});
+4 -1
View File
@@ -34,7 +34,7 @@ const zh = {
noRegKeyTotal: '注册码使用次数已耗尽',
regKeyExpire: '注册码已过期',
emailAndPwdEmpty: '邮箱和密码不能为空',
notExistUser: '邮箱不存在',
notExistUser: '输入的邮箱不存在',
isDelUser: '该邮箱已被注销',
isBanUser: '该邮箱已被禁用',
regKeyUseCount: '使用次数不能为空',
@@ -59,6 +59,9 @@ const zh = {
noDomainPermRegKey: '你的注册码没有权限注册该域名邮箱',
noDomainPermSend: '你没有权限使用该域名邮箱发送邮件',
JWTMismatch: 'jwt_secret 不匹配',
publicTokenFail: 'token验证失败',
notAdmin: '输入的邮箱不是管理员邮箱',
emailExistDatabase: '有邮箱已存在数据库中',
perms: {
"邮件": "邮件",
"邮件发送": "邮件发送",
+5
View File
@@ -20,10 +20,15 @@ const init = {
await this.v1_4DB(c);
await this.v1_5DB(c);
await this.v1_6DB(c);
await this.v1_7DB(c);
await settingService.refresh(c);
return c.text(t('initSuccess'));
},
async v1_7DB(c) {
c.env.db.prepare(`ALTER TABLE setting ADD COLUMN login_domain INTEGER NOT NULL DEFAULT 0;`).run();
},
async v1_6DB(c) {
const noticeContent = '<div style="color: teal;margin-bottom: 5px;">欢迎使用 Cloud Mail 🎉 </div >\n' +
+12 -1
View File
@@ -14,7 +14,8 @@ const exclude = [
'/file',
'/setting/websiteConfig',
'/webhooks',
'/init'
'/init',
'/public/genToken'
];
const requirePerms = [
@@ -95,6 +96,16 @@ app.use('*', async (c, next) => {
return await next();
}
if (path.startsWith('/public')) {
const userPublicToken = await c.env.kv.get(KvConst.PUBLIC_KEY);
const publicToken = c.req.header(constant.TOKEN_HEADER);
if (publicToken !== userPublicToken) {
throw new BizError(t('publicTokenFail'), 401);
}
return await next();
}
const jwt = c.req.header(constant.TOKEN_HEADER);
@@ -152,6 +152,10 @@ const accountService = {
await orm(c).insert(account).values({ ...params }).returning();
},
async insertList(c, list) {
await orm(c).insert(account).values(list).run();
},
async physicsDeleteAll(c) {
const accountIdsRow = await orm(c).select({accountId: account.accountId}).from(account).where(eq(account.isDel,isDel.DELETE)).limit(99);
if (accountIdsRow.length === 0) {
+6 -2
View File
@@ -119,10 +119,10 @@ const loginService = {
const userId = await userService.insert(c, { email, regKeyId,password: hash, salt, type: type || defType });
await userService.updateUserInfo(c, userId, true);
await accountService.insert(c, { userId: userId, email, name: emailUtils.getName(email) });
await userService.updateUserInfo(c, userId, true);
if (regKey !== settingConst.regKey.CLOSE && type) {
await regKeyService.reduceCount(c, code, 1);
}
@@ -136,6 +136,10 @@ const loginService = {
},
async registerVerify() {
},
async handleOpenRegKey(c, regKey, code) {
if (!code) {
+195
View File
@@ -0,0 +1,195 @@
import BizError from '../error/biz-error';
import orm from '../entity/orm';
import { v4 as uuidv4 } from 'uuid';
import { and, asc, desc, eq, sql } from 'drizzle-orm';
import saltHashUtils from '../utils/crypto-utils';
import cryptoUtils from '../utils/crypto-utils';
import emailUtils from '../utils/email-utils';
import roleService from './role-service';
import verifyUtils from '../utils/verify-utils';
import { t } from '../i18n/i18n';
import reqUtils from '../utils/req-utils';
import dayjs from 'dayjs';
import { isDel, roleConst } from '../const/entity-const';
import email from '../entity/email';
import userService from './user-service';
import KvConst from '../const/kv-const';
const publicService = {
async emailList(c, params) {
let { toEmail, content, subject, sendName, sendEmail, timeSort, num, size, type , isDel } = params
const query = orm(c).select({
emailId: email.emailId,
sendEmail: email.sendEmail,
sendName: email.name,
subject: email.subject,
toEmail: email.toEmail,
toName: email.toName,
type: email.type,
createTime: email.createTime,
content: email.content,
text: email.text,
isDel: email.isDel,
}).from(email)
if (!size) {
size = 20
}
if (!num) {
num = 1
}
size = Number(size);
num = Number(num);
num = (num - 1) * size;
let conditions = []
if (toEmail) {
conditions.push(sql`${email.toEmail} COLLATE NOCASE LIKE ${toEmail}`)
}
if (sendEmail) {
conditions.push(sql`${email.sendEmail} COLLATE NOCASE LIKE ${sendEmail}`)
}
if (sendName) {
conditions.push(sql`${email.name} COLLATE NOCASE LIKE ${sendName}`)
}
if (subject) {
conditions.push(sql`${email.subject} COLLATE NOCASE LIKE ${subject}`)
}
if (content) {
conditions.push(sql`${email.content} COLLATE NOCASE LIKE ${content}`)
}
if (type || type === 0) {
conditions.push(eq(email.type, type))
}
if (isDel || isDel === 0) {
conditions.push(eq(email.isDel, isDel))
}
if (conditions.length === 1) {
query.where(...conditions)
} else if (conditions.length > 1) {
query.where(and(...conditions))
}
if (timeSort === 'asc') {
query.orderBy(asc(email.emailId));
} else {
query.orderBy(desc(email.emailId));
}
return query.limit(size).offset(num);
},
async addUser(c, params) {
const { list } = params;
if (list.length === 0) return;
for (const emailRow of list) {
if (!verifyUtils.isEmail(emailRow.email)) {
throw new BizError(t('notEmail'));
}
if (!c.env.domain.includes(emailUtils.getDomain(emailRow.email))) {
throw new BizError(t('notEmailDomain'));
}
const { salt, hash } = await saltHashUtils.hashPassword(
emailRow.password || cryptoUtils.genRandomPwd()
);
emailRow.salt = salt;
emailRow.hash = hash;
}
const activeIp = reqUtils.getIp(c);
const { os, browser, device } = reqUtils.getUserAgent(c);
const activeTime = dayjs().format('YYYY-MM-DD HH:mm:ss');
const roleList = await roleService.roleSelectUse(c);
const defRole = roleList.find(roleRow => roleRow.isDefault === roleConst.isDefault.OPEN);
const userList = [];
for (const emailRow of list) {
let { email, hash, salt, roleName } = emailRow;
let type = defRole.roleId;
if (roleName) {
const roleRow = roleList.find(role => role.name === roleName);
type = roleRow ? roleRow.roleId : type;
}
const userSql = `INSERT INTO user (email, password, salt, type, os, browser, active_ip, create_ip, device, active_time, create_time)
VALUES ('${email}', '${hash}', '${salt}', '${type}', '${os}', '${browser}', '${activeIp}', '${activeIp}', '${device}', '${activeTime}', '${activeTime}')`
const accountSql = `INSERT INTO account (email, name, user_id)
VALUES ('${email}', '${emailUtils.getName(email)}', 0);`;
userList.push(c.env.db.prepare(userSql));
userList.push(c.env.db.prepare(accountSql));
}
userList.push(c.env.db.prepare(`UPDATE account SET user_id = (SELECT user_id FROM user WHERE user.email = account.email) WHERE user_id = 0;`))
try {
await c.env.db.batch(userList);
} catch (e) {
if(e.message.includes('SQLITE_CONSTRAINT')) {
throw new BizError(t('emailExistDatabase'))
} else {
throw e
}
}
},
async genToken(c, params) {
await this.verifyUser(c, params)
const uuid = uuidv4();
await c.env.kv.put(KvConst.PUBLIC_KEY, uuid, { expirationTtl: 60 * 60 * 24 * 7 });
return {token: uuid}
},
async verifyUser(c, params) {
const { email, password } = params
const userRow = await userService.selectByEmailIncludeDel(c, email);
if (email !== c.env.admin) {
throw new BizError(t('notAdmin'));
}
if (!userRow || userRow.isDel === isDel.DELETE) {
throw new BizError(t('notExistUser'));
}
if (!await cryptoUtils.verifyPassword(password, userRow.salt, userRow.password)) {
throw new BizError(t('IncorrectPwd'));
}
}
}
export default publicService
+5 -1
View File
@@ -116,7 +116,7 @@ const roleService = {
},
roleSelectUse(c) {
return orm(c).select({ name: role.name, roleId: role.roleId }).from(role).orderBy(asc(role.sort)).all();
return orm(c).select({ name: role.name, roleId: role.roleId, isDefault: role.isDefault }).from(role).orderBy(asc(role.sort)).all();
},
async selectDefaultRole(c) {
@@ -170,6 +170,10 @@ const roleService = {
})
return availIndex > -1
},
selectByName(c, roleName) {
return orm(c).select().from(role).where(eq(role.name, roleName)).get();
}
};
@@ -148,6 +148,7 @@ const settingService = {
noticeWidth: settingRow.noticeWidth,
noticeOffset: settingRow.noticeOffset,
notice: settingRow.notice,
loginDomain: settingRow.loginDomain
};
}
};
+8 -35
View File
@@ -8,7 +8,6 @@ import kvConst from '../const/kv-const';
import KvConst from '../const/kv-const';
import cryptoUtils from '../utils/crypto-utils';
import emailService from './email-service';
import { UAParser } from 'ua-parser-js';
import dayjs from 'dayjs';
import permService from './perm-service';
import roleService from './role-service';
@@ -16,6 +15,7 @@ import emailUtils from '../utils/email-utils';
import saltHashUtils from '../utils/crypto-utils';
import constant from '../const/constant';
import { t } from '../i18n/i18n'
import reqUtils from '../utils/req-utils';
const userService = {
@@ -216,49 +216,22 @@ const userService = {
async updateUserInfo(c, userId, recordCreateIp = false) {
const ua = c.req.header('user-agent') || '';
console.log(ua);
const parser = new UAParser(ua);
const { browser, device, os } = parser.getResult();
let browserInfo = null;
let osInfo = null;
if (browser.name) {
browserInfo = browser.name + ' ' + browser.version;
}
const activeIp = reqUtils.getIp(c);
if (os.name) {
osInfo = os.name + os.version;
}
let deviceInfo = 'Desktop';
const hasVendor = !!device?.vendor;
const hasModel = !!device?.model;
if (hasVendor || hasModel) {
const vendor = device.vendor || '';
const model = device.model || '';
const type = device.type || '';
const namePart = [vendor, model].filter(Boolean).join(' ');
const typePart = type ? ` (${type})` : '';
deviceInfo = (namePart + typePart).trim();
}
const userIp = c.req.header('cf-connecting-ip') || '';
const {os, browser, device} = reqUtils.getUserAgent(c);
const params = {
os: osInfo,
browser: browserInfo,
device: deviceInfo,
activeIp: userIp,
os,
browser,
device,
activeIp,
activeTime: dayjs().format('YYYY-MM-DD HH:mm:ss')
};
if (recordCreateIp) {
params.createIp = userIp;
params.createIp = activeIp;
}
await orm(c)
@@ -2,13 +2,13 @@ import orm from '../entity/orm';
import verifyRecord from '../entity/verify-record';
import { eq, sql, and } from 'drizzle-orm';
import dayjs from 'dayjs';
import ipUtils from '../utils/ip-utils';
import reqUtils from '../utils/req-utils';
import { verifyRecordType } from '../const/entity-const';
const verifyRecordService = {
async selectListByIP(c) {
const ip = ipUtils.getIp(c)
const ip = reqUtils.getIp(c)
return orm(c).select().from(verifyRecord).where(eq(verifyRecord.ip, ip)).all();
},
@@ -18,7 +18,7 @@ const verifyRecordService = {
async isOpenRegVerify(c, regVerifyCount) {
const ip = ipUtils.getIp(c)
const ip = reqUtils.getIp(c)
const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.REG))).get();
@@ -35,7 +35,7 @@ const verifyRecordService = {
async isOpenAddVerify(c, addVerifyCount) {
const ip = ipUtils.getIp(c)
const ip = reqUtils.getIp(c)
const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.ADD))).get();
@@ -53,7 +53,7 @@ const verifyRecordService = {
async increaseRegCount(c) {
const ip = ipUtils.getIp(c)
const ip = reqUtils.getIp(c)
const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.REG))).get();
const now = dayjs().format('YYYY-MM-DD HH:mm:ss');
@@ -70,7 +70,7 @@ const verifyRecordService = {
async increaseAddCount(c) {
const ip = ipUtils.getIp(c)
const ip = reqUtils.getIp(c)
const row = await orm(c).select().from(verifyRecord).where(and(eq(verifyRecord.ip, ip),eq(verifyRecord.type,verifyRecordType.ADD))).get();
const now = dayjs().format('YYYY-MM-DD HH:mm:ss');
+9
View File
@@ -25,6 +25,15 @@ const saltHashUtils = {
async verifyPassword(inputPassword, salt, storedHash) {
const hash = await this.genHashPassword(inputPassword, salt);
return hash === storedHash;
},
genRandomPwd(length = 8) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
};
-9
View File
@@ -1,9 +0,0 @@
const ipUtils = {
getIp(c) {
return c.req.header('CF-Connecting-IP') ||
c.req.header('X-Forwarded-For') ||
'Unknown';
}
}
export default ipUtils
+45
View File
@@ -0,0 +1,45 @@
import { UAParser } from 'ua-parser-js';
const reqUtils = {
getIp(c) {
return c.req.header('CF-Connecting-IP') ||
c.req.header('X-Forwarded-For') ||
'Unknown';
},
getUserAgent(c) {
const ua = c.req.header('user-agent') || '';
const parser = new UAParser(ua);
const { browser, device, os } = parser.getResult();
let browserInfo = null;
let osInfo = null;
if (browser.name) {
browserInfo = browser.name + ' ' + browser.version;
}
if (os.name) {
osInfo = os.name + os.version;
}
let deviceInfo = 'Desktop';
const hasVendor = !!device?.vendor;
const hasModel = !!device?.model;
if (hasVendor || hasModel) {
const vendor = device.vendor || '';
const model = device.model || '';
const type = device.type || '';
const namePart = [vendor, model].filter(Boolean).join(' ');
const typePart = type ? ` (${type})` : '';
deviceInfo = (namePart + typePart).trim();
}
return {browser: browserInfo || '', device: deviceInfo || '', os: osInfo || ''}
}
}
export default reqUtils
+1 -1
View File
@@ -1,6 +1,6 @@
name = "cloud-mail"
main = "src/index.js"
compatibility_date = "2025-07-29"
compatibility_date = "2025-06-04"
keep_vars = true
[observability]
+1 -1
View File
@@ -1,6 +1,6 @@
name = "cloud-mail-dev"
main = "src/index.js"
compatibility_date = "2025-07-29"
compatibility_date = "2025-06-04"
keep_vars = true
[observability]
+1 -1
View File
@@ -1,6 +1,6 @@
name = "cloud-mail-test"
main = "src/index.js"
compatibility_date = "2025-07-29"
compatibility_date = "2025-06-04"
keep_vars = true
[observability]
+1 -1
View File
@@ -1,6 +1,6 @@
name = "cloud-mail"
main = "src/index.js"
compatibility_date = "2025-07-29"
compatibility_date = "2025-06-04"
keep_vars = true
[observability]