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

This commit is contained in:
eoao
2025-08-10 11:37:46 +08:00
parent 2437ff4599
commit 46127a8d67
38 changed files with 894 additions and 713 deletions

View File

@@ -13,9 +13,10 @@
## Project Showcase ## 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) | | ![](/doc/demo/demo1.png) | ![](/doc/demo/demo2.png) |
|--------------------------|---------------------| |--------------------------|---------------------|
@@ -25,6 +26,8 @@
## Features ## 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. - **💻 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. - **📧 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. - **🔔 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. - **📈 Data Visualization**: Use Echarts to visualize system data, including user email growth.
- **⭐ Starred Emails**: Mark important emails for quick access. - **⭐ Starred Emails**: Mark important emails for quick access.
@@ -67,108 +72,18 @@
- **File Storage**: [Cloudflare R2](https://developers.cloudflare.com/r2/) - **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 ## Support
<a href="https://support.skymail.ink">
<a href="https://doc.skymail.ink/support.html">
<img width="170px" src="./doc/images/support.png" alt=""> <img width="170px" src="./doc/images/support.png" alt="">
</a><br><br> </a><br><br>
**Special Sponsors** **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") [![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")

142
README.md
View File

@@ -6,7 +6,7 @@
<h1>Cloud Mail</h1> <h1>Cloud Mail</h1>
</div> </div>
<div align="center"> <div align="center">
<h4>使用Vue3开发的响应式简约邮箱服务,支持邮件发送附件收发,可以部署到Cloudflare平台实现免费白嫖🎉</h4> <h4>使用Vue3开发的响应式邮箱服务支持邮件发送,无需服务器可部署到Cloudflare平台 🎉</h4>
</div> </div>
<div align="center"> <div align="center">
<span>简体中文 | <a href="/README-en.md" style="margin-left: 5px">English </a></span> <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://skymail.ink)<br>
- [部署文档](https://doc.skymail.ink)<br>
[**👉 小白保姆教程-界面部署**](https://doc.skymail.ink) - [小白保姆教程-界面部署](https://doc.skymail.ink/guide/via-ui.html)
| ![](/doc/demo/demo1.png) | ![](/doc/demo/demo2.png) | | ![](/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/) - **文件存储**[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=""> <img width="170px" src="./doc/images/support.png" alt="">
</a><br><br> </a><br><br>

View File

@@ -1,3 +1,3 @@
NODE_ENV = 'remote' NODE_ENV = 'remote'
VITE_APP_TITLE = '远程环境' VITE_APP_TITLE = '远程环境'
VITE_BASE_URL = 'https://mornglow.top/api' VITE_BASE_URL = ''

View File

@@ -133,6 +133,7 @@ const en = {
loginSwitch: 'Sign in', loginSwitch: 'Sign in',
websiteSetting: 'Website', websiteSetting: 'Website',
websiteReg: 'Sign Up', websiteReg: 'Sign Up',
loginDomain: 'Sign-In Box Domain',
multipleEmail: 'Multiple Accounts', multipleEmail: 'Multiple Accounts',
multipleEmailDesc: 'Enable this feature to allow users to add multiple accounts.', multipleEmailDesc: 'Enable this feature to allow users to add multiple accounts.',
physicallyWipeData: 'Physically Wipe Data', physicallyWipeData: 'Physically Wipe Data',
@@ -262,7 +263,7 @@ const en = {
rulesVerifyTitle: 'Trigger After {count} Daily Uses per IP', rulesVerifyTitle: 'Trigger After {count} Daily Uses per IP',
botVerifyMsg: 'Please verify that you are human', botVerifyMsg: 'Please verify that you are human',
noticeTitle: 'Notice', noticeTitle: 'Notice',
noticePopup: 'Sign-in Popup', noticePopup: 'Sign-In Popup',
icon: 'Icon', icon: 'Icon',
position: 'Position', position: 'Position',
offset: 'Offset', offset: 'Offset',
@@ -278,7 +279,9 @@ const en = {
popUp: 'Pop Up', popUp: 'Pop Up',
noRecipientTitle: 'No Recipient', noRecipientTitle: 'No Recipient',
noRecipientDesc: 'Emails can be received even without a registered email address.', noRecipientDesc: 'Emails can be received even without a registered email address.',
preview: 'Preview' preview: 'Preview',
help: 'Help',
document: 'Document'
} }
export default en export default en

View File

@@ -18,7 +18,7 @@ const zh = {
deleteUserBtn: '删除账户', deleteUserBtn: '删除账户',
changePassword: '修改密码', changePassword: '修改密码',
newPassword: '新的密码', newPassword: '新的密码',
confirmPassword: '旧的密码', confirmPassword: '确认密码',
add: '添加', add: '添加',
manage: '管理', manage: '管理',
rename: '改名', rename: '改名',
@@ -133,6 +133,7 @@ const zh = {
loginSwitch: '去登录', loginSwitch: '去登录',
websiteSetting: '网站设置', websiteSetting: '网站设置',
websiteReg: '用户注册', websiteReg: '用户注册',
loginDomain: '登录框域名',
multipleEmail: '多号模式', multipleEmail: '多号模式',
multipleEmailDesc: '开启后账号栏出现一个用户可以添加多个邮箱', multipleEmailDesc: '开启后账号栏出现一个用户可以添加多个邮箱',
physicallyWipeData: '物理清空数据', physicallyWipeData: '物理清空数据',
@@ -246,7 +247,7 @@ const zh = {
totalUserAccount: '{msg} 个', totalUserAccount: '{msg} 个',
sendBanned: '已禁用', sendBanned: '已禁用',
wrote: '来信', wrote: '来信',
support: '助', support: '助',
supportDesc: '请我喝杯奶茶', supportDesc: '请我喝杯奶茶',
featDesc: '功能说明', featDesc: '功能说明',
emailInterception: '邮件拦截', emailInterception: '邮件拦截',
@@ -278,7 +279,9 @@ const zh = {
popUp: '弹出', popUp: '弹出',
noRecipientTitle: '无人收件', noRecipientTitle: '无人收件',
noRecipientDesc: '即使没有注册的邮箱也能收到邮件', noRecipientDesc: '即使没有注册的邮箱也能收到邮件',
preview: '预览' preview: '预览',
help: '帮助',
document: '项目文档'
} }
export default zh export default zh

View File

@@ -1,28 +1,33 @@
<template> <template>
<div class="account-box"> <div class="account-box">
<div class="head-opt" > <div class="head-opt">
<Icon v-perm="'account:add'" class="icon add" icon="ion:add-outline" width="23" height="23" @click="add" /> <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" /> <Icon class="icon refresh" icon="ion:reload" width="18" height="18" @click="refresh"/>
</div> </div>
<el-scrollbar class="scrollbar"> <el-scrollbar class="scrollbar">
<div v-infinite-scroll="getAccountList" :infinite-scroll-distance="600" :infinite-scroll-immediate="false"> <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)"> <el-card class="item" :class="itemBg(item.accountId)" v-for="item in accounts" :key="item.accountId"
<div class="account" > @click="changeAccount(item)">
<div class="account">
{{ item.email }} {{ item.email }}
</div> </div>
<div class="opt"> <div class="opt">
<div class="send-email" @click.stop> <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>
<div class="settings" @click.stop> <div class="settings" @click.stop>
<Icon icon="fluent-color:clipboard-24" width="22" height="22" @click.stop="copyAccount(item.email)"/> <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> <el-dropdown v-else>
<Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399" /> <Icon icon="fluent:settings-24-filled" width="21" height="21" color="#909399"/>
<template #dropdown > <template #dropdown>
<el-dropdown-menu > <el-dropdown-menu>
<el-dropdown-item v-if="hasPerm('email:send')" @click="openSetName(item)">{{$t('rename')}}</el-dropdown-item> <el-dropdown-item v-if="hasPerm('email:send')" @click="openSetName(item)">{{ $t('rename') }}
<el-dropdown-item v-if="item.accountId !== userStore.user.accountId && hasPerm('account:delete')" @click="remove(item)">{{$t('delete')}}</el-dropdown-item> </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> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
@@ -32,13 +37,13 @@
<!-- Initial Loading Skeleton --> <!-- Initial Loading Skeleton -->
<template v-if="loading"> <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> <template #template>
<el-card class="item"> <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"> <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> </div>
</el-card> </el-card>
</template> </template>
@@ -50,10 +55,10 @@
<el-skeleton animated> <el-skeleton animated>
<template #template> <template #template>
<el-card class="item"> <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"> <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> </div>
</el-card> </el-card>
</template> </template>
@@ -61,43 +66,43 @@
</template> </template>
<div class="noLoading" v-if="noLoading && accounts.length > 0"> <div class="noLoading" v-if="noLoading && accounts.length > 0">
<div>{{$t('noMoreData')}}</div> <div>{{ $t('noMoreData') }}</div>
</div> </div>
<div class="empty" v-if="noLoading && accounts.length === 0"> <div class="empty" v-if="noLoading && accounts.length === 0">
<el-empty :description="$t('noMessagesFound')" /> <el-empty :description="$t('noMessagesFound')"/>
</div> </div>
</div> </div>
</el-scrollbar> </el-scrollbar>
<el-dialog v-model="showAdd" :title="$t('addAccount')" > <el-dialog v-model="showAdd" :title="$t('addAccount')">
<div class="container"> <div class="container">
<el-input v-model="addForm.email" ref="addRef" type="text" :placeholder="$t('emailAccount')" autocomplete="off"> <el-input v-model="addForm.email" ref="addRef" type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<template #append> <template #append>
<div @click.stop="openSelect"> <div @click.stop="openSelect">
<el-select <el-select
ref="mySelect" ref="mySelect"
v-model="addForm.suffix" v-model="addForm.suffix"
:placeholder="$t('select')" :placeholder="$t('select')"
class="select" class="select"
> >
<el-option <el-option
v-for="item in domainList" v-for="item in domainList"
:key="item" :key="item"
:label="item" :label="item"
:value="item" :value="item"
/> />
</el-select> </el-select>
<div style="color: #333"> <div style="color: #333">
<span >{{addForm.suffix}}</span> <span>{{ addForm.suffix }}</span>
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20" /> <Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
</div>
</div> </div>
</template> </div>
</el-input> </template>
<el-button class="btn" type="primary" @click="submit" :loading="addLoading" </el-input>
>{{$t('add')}} <el-button class="btn" type="primary" @click="submit" :loading="addLoading"
</el-button> >{{ $t('add') }}
</div> </el-button>
</div>
<div <div
class="add-email-turnstile" class="add-email-turnstile"
:class="verifyShow ? 'turnstile-show' : 'turnstile-hide'" :class="verifyShow ? 'turnstile-show' : 'turnstile-hide'"
@@ -105,15 +110,15 @@
data-callback="onTurnstileSuccess" data-callback="onTurnstileSuccess"
data-error-callback="onTurnstileError" 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> </div>
</el-dialog> </el-dialog>
<el-dialog v-model="setNameShow" :title="$t('changeUserName')" > <el-dialog v-model="setNameShow" :title="$t('changeUserName')">
<div class="container"> <div class="container">
<el-input v-model="accountName" type="text" :placeholder="$t('username')" autocomplete="off"> <el-input v-model="accountName" type="text" :placeholder="$t('username')" autocomplete="off">
</el-input> </el-input>
<el-button class="btn" type="primary" @click="setName" :loading="setNameLoading" <el-button class="btn" type="primary" @click="setName" :loading="setNameLoading"
>{{$t('save')}} >{{ $t('save') }}
</el-button> </el-button>
</div> </div>
</el-dialog> </el-dialog>
@@ -127,10 +132,10 @@ import {isEmail} from "@/utils/verify-utils.js";
import {useSettingStore} from "@/store/setting.js"; import {useSettingStore} from "@/store/setting.js";
import {useAccountStore} from "@/store/account.js"; import {useAccountStore} from "@/store/account.js";
import {useUserStore} from "@/store/user.js"; import {useUserStore} from "@/store/user.js";
import { hasPerm } from "@/perm/perm.js" import {hasPerm} from "@/perm/perm.js"
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
const { t } = useI18n(); const {t} = useI18n();
const userStore = useUserStore(); const userStore = useUserStore();
const accountStore = useAccountStore(); const accountStore = useAccountStore();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
@@ -150,6 +155,7 @@ let account = null
let turnstileId = null let turnstileId = null
const botJsError = ref(false) const botJsError = ref(false)
let verifyToken = '' let verifyToken = ''
let verifyErrorCount = 0
const addForm = reactive({ const addForm = reactive({
email: '', email: '',
suffix: settingStore.domainList[0] suffix: settingStore.domainList[0]
@@ -175,14 +181,20 @@ const openSelect = () => {
} }
window.onTurnstileError = (e) => { window.onTurnstileError = (e) => {
console.log('人机验加载失败') if (verifyErrorCount >= 4) {
nextTick(() => { return
if (!turnstileId) { }
turnstileId = window.turnstile.render('.register-turnstile') verifyErrorCount++
} else { console.warn('人机验加载失败', e)
window.turnstile.reset(turnstileId); setTimeout(() => {
} nextTick(() => {
}) if (!turnstileId) {
turnstileId = window.turnstile.render('.add-email-turnstile')
} else {
window.turnstile.reset(turnstileId);
}
})
}, 1500)
}; };
window.onTurnstileSuccess = (token) => { window.onTurnstileSuccess = (token) => {
@@ -208,7 +220,7 @@ function setName() {
} }
setNameLoading.value = true setNameLoading.value = true
accountSetName(account.accountId,name).then(() => { accountSetName(account.accountId, name).then(() => {
account.name = name account.name = name
setNameShow.value = false setNameShow.value = false
@@ -221,12 +233,12 @@ function setName() {
type: "success", type: "success",
plain: true plain: true
}) })
}).finally(()=> { }).finally(() => {
setNameLoading.value = false setNameLoading.value = false
}) })
} }
function openSetName (accountItem) { function openSetName(accountItem) {
accountName.value = accountItem.name accountName.value = accountItem.name
account = accountItem account = accountItem
setNameShow.value = true setNameShow.value = true
@@ -241,7 +253,7 @@ function itemBg(accountId) {
} }
function remove(account) { function remove(account) {
ElMessageBox.confirm(t('delConfirm',{msg: account.email}), { ElMessageBox.confirm(t('delConfirm', {msg: account.email}), {
confirmButtonText: t('confirm'), confirmButtonText: t('confirm'),
cancelButtonText: t('cancel'), cancelButtonText: t('cancel'),
type: 'warning' type: 'warning'
@@ -272,6 +284,7 @@ function refresh() {
accounts.splice(0, accounts.length) accounts.splice(0, accounts.length)
getAccountList() getAccountList()
} }
function changeAccount(account) { function changeAccount(account) {
accountStore.currentAccountId = account.accountId accountStore.currentAccountId = account.accountId
accountStore.currentAccount = account accountStore.currentAccount = account
@@ -281,7 +294,7 @@ function add() {
showAdd.value = true showAdd.value = true
setTimeout(() => { setTimeout(() => {
addRef.value.focus() addRef.value.focus()
},100) }, 100)
} }
async function copyAccount(account) { async function copyAccount(account) {
@@ -308,11 +321,11 @@ function getAccountList() {
if (accounts.length === 0) { if (accounts.length === 0) {
loading.value = true loading.value = true
}else { } else {
followLoading.value = true followLoading.value = true
} }
accountList(queryParams.accountId,queryParams.size).then(list => { accountList(queryParams.accountId, queryParams.size).then(list => {
if (list.length < queryParams.size) { if (list.length < queryParams.size) {
noLoading.value = true noLoading.value = true
} }
@@ -331,9 +344,9 @@ function getAccountList() {
} }
function submit() { function submit() {
if (!addForm.email){ if (!addForm.email) {
ElMessage({ ElMessage({
message: t('emptyEmailMsg'), message: t('emptyEmailMsg'),
type: "error", type: "error",
@@ -342,7 +355,7 @@ function submit() {
return return
} }
if (!isEmail(addForm.email+addForm.suffix)) { if (!isEmail(addForm.email + addForm.suffix)) {
ElMessage({ ElMessage({
message: t('notEmailMsg'), message: t('notEmailMsg'),
type: "error", type: "error",
@@ -376,7 +389,7 @@ function submit() {
} }
addLoading.value = true addLoading.value = true
accountAdd(addForm.email+addForm.suffix,verifyToken).then(account => { accountAdd(addForm.email + addForm.suffix, verifyToken).then(account => {
addLoading.value = false addLoading.value = false
showAdd.value = false showAdd.value = false
addForm.email = '' addForm.email = ''
@@ -418,6 +431,7 @@ path[fill="#ffdda1"] {
background-color: #FFF; background-color: #FFF;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
.head-opt { .head-opt {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -425,9 +439,11 @@ path[fill="#ffdda1"] {
box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12); box-shadow: inset 0 -1px 0 0 rgba(100, 121, 143, 0.12);
padding-left: 10px; padding-left: 10px;
padding-right: 10px; padding-right: 10px;
.icon{
.icon {
cursor: pointer; cursor: pointer;
} }
.refresh { .refresh {
margin-left: 10px; margin-left: 10px;
} }
@@ -440,6 +456,7 @@ path[fill="#ffdda1"] {
margin-left: 5px; margin-left: 5px;
} }
} }
.scrollbar { .scrollbar {
width: 100%; width: 100%;
height: calc(100% - 38px); height: calc(100% - 38px);
@@ -447,12 +464,14 @@ path[fill="#ffdda1"] {
@media (max-width: 767px) { @media (max-width: 767px) {
height: calc(100% - 98px); height: calc(100% - 98px);
} }
.empty { .empty {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100%; height: 100%;
} }
.noLoading { .noLoading {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -461,6 +480,7 @@ path[fill="#ffdda1"] {
color: gray; color: gray;
} }
} }
.btn { .btn {
width: 100%; width: 100%;
margin-top: 15px; margin-top: 15px;
@@ -474,6 +494,7 @@ path[fill="#ffdda1"] {
margin-left: 10px; margin-left: 10px;
margin-right: 10px; margin-right: 10px;
cursor: pointer; cursor: pointer;
.account { .account {
font-weight: 600; font-weight: 600;
margin-bottom: 20px; margin-bottom: 20px;
@@ -488,11 +509,13 @@ path[fill="#ffdda1"] {
justify-content: space-between; justify-content: space-between;
font-size: 12px; font-size: 12px;
color: #888; color: #888;
.settings { .settings {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.send-email { .send-email {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -503,8 +526,9 @@ path[fill="#ffdda1"] {
padding: 0; padding: 0;
} }
} }
.item:first-child{
margin-top: 10px ; .item:first-child {
margin-top: 10px;
} }
.item-choose { .item-choose {
@@ -560,4 +584,4 @@ path[fill="#ffdda1"] {
position: fixed; position: fixed;
} }
</style> </style>

View File

@@ -1,5 +1,6 @@
import {createRouter, createWebHistory} from 'vue-router' import {createRouter, createWebHistory} from 'vue-router'
import {useUiStore} from "@/store/ui.js"; import {useUiStore} from "@/store/ui.js";
import {replace} from "lodash-es";
const routes = [ const routes = [
{ {
@@ -81,7 +82,7 @@ router.beforeEach(async (to, from, next) => {
if (!token && to.name !== 'login') { if (!token && to.name !== 'login') {
return next({ return next({
name: 'login' name: 'login',
}) })
} }
@@ -89,6 +90,10 @@ router.beforeEach(async (to, from, next) => {
return next() return next()
} }
if (token && to.name === 'login') {
next(from.path)
}
next() next()
}) })

View File

@@ -1,14 +1,14 @@
<template> <template>
<div v-if="analysisLoading" class="analysis-loading"> <div v-if="analysisLoading" class="analysis-loading">
<loading /> <loading/>
</div> </div>
<el-scrollbar v-else style="height: 100%;"> <el-scrollbar v-else style="height: 100%;">
<div class="analysis" :key="boxKey"> <div class="analysis" :key="boxKey">
<div class="number"> <div class="number">
<div class="number-item"> <div class="number-item">
<div class="top"> <div class="top">
<div class="left"> <div class="left">
<div>{{$t('totalReceived')}}</div> <div>{{ $t('totalReceived') }}</div>
<div> <div>
<el-statistic :formatter="value => Math.round(value)" :value="receiveData"/> <el-statistic :formatter="value => Math.round(value)" :value="receiveData"/>
</div> </div>
@@ -20,14 +20,14 @@
</div> </div>
</div> </div>
<div class="delete-ratio"> <div class="delete-ratio">
<div>{{$t('active')}} <span class="normal">{{numberCount.normalReceiveTotal}}</span></div> <div>{{ $t('active') }} <span class="normal">{{ numberCount.normalReceiveTotal }}</span></div>
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delReceiveTotal}}</span></div> <div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delReceiveTotal }}</span></div>
</div> </div>
</div> </div>
<div class="number-item"> <div class="number-item">
<div class="top"> <div class="top">
<div class="left"> <div class="left">
<div>{{$t('totalSent')}}</div> <div>{{ $t('totalSent') }}</div>
<div> <div>
<el-statistic :formatter="value => Math.round(value)" :value="sendData"/> <el-statistic :formatter="value => Math.round(value)" :value="sendData"/>
</div> </div>
@@ -39,14 +39,14 @@
</div> </div>
</div> </div>
<div class="delete-ratio"> <div class="delete-ratio">
<div>{{$t('active')}} <span class="normal">{{numberCount.normalSendTotal}}</span></div> <div>{{ $t('active') }} <span class="normal">{{ numberCount.normalSendTotal }}</span></div>
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delSendTotal}}</span></div> <div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delSendTotal }}</span></div>
</div> </div>
</div> </div>
<div class="number-item"> <div class="number-item">
<div class="top"> <div class="top">
<div class="left"> <div class="left">
<div>{{$t('totalMailboxes')}}</div> <div>{{ $t('totalMailboxes') }}</div>
<div> <div>
<el-statistic :formatter="value => Math.round(value)" :value="accountData"/> <el-statistic :formatter="value => Math.round(value)" :value="accountData"/>
</div> </div>
@@ -58,14 +58,14 @@
</div> </div>
</div> </div>
<div class="delete-ratio"> <div class="delete-ratio">
<div>{{$t('active')}} <span class="normal">{{numberCount.normalAccountTotal}}</span></div> <div>{{ $t('active') }} <span class="normal">{{ numberCount.normalAccountTotal }}</span></div>
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delAccountTotal}}</span></div> <div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delAccountTotal }}</span></div>
</div> </div>
</div> </div>
<div class="number-item"> <div class="number-item">
<div class="top"> <div class="top">
<div class="left"> <div class="left">
<div>{{$t('totalUsers')}}</div> <div>{{ $t('totalUsers') }}</div>
<div> <div>
<el-statistic :formatter="value => Math.round(value)" :value="userData"/> <el-statistic :formatter="value => Math.round(value)" :value="userData"/>
</div> </div>
@@ -77,19 +77,19 @@
</div> </div>
</div> </div>
<div class="delete-ratio"> <div class="delete-ratio">
<div>{{$t('active')}} <span class="normal">{{numberCount.normalUserTotal}}</span></div> <div>{{ $t('active') }} <span class="normal">{{ numberCount.normalUserTotal }}</span></div>
<div>{{$t('deleted')}} <span class="deleted">{{numberCount.delUserTotal}}</span></div> <div>{{ $t('deleted') }} <span class="deleted">{{ numberCount.delUserTotal }}</span></div>
</div> </div>
</div> </div>
</div> </div>
<div class="picture"> <div class="picture">
<div class="picture-item"> <div class="picture-item">
<div class="title" style="display: flex;justify-content: space-between;"> <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"> <span class="source-button" v-if="false">
<el-radio-group v-model="checkedSourceType" > <el-radio-group v-model="checkedSourceType">
<el-radio-button label="发件人" value="sender" /> <el-radio-button label="发件人" value="sender"/>
<el-radio-button label="邮箱" value="email" /> <el-radio-button label="邮箱" value="email"/>
</el-radio-group> </el-radio-group>
</span> </span>
</div> </div>
@@ -98,7 +98,7 @@
</div> </div>
</div> </div>
<div class="picture-item"> <div class="picture-item">
<div class="title">{{$t('userGrowth')}}</div> <div class="title">{{ $t('userGrowth') }}</div>
<div class="increase-line"> <div class="increase-line">
</div> </div>
@@ -106,11 +106,11 @@
</div> </div>
<div class="picture-cs"> <div class="picture-cs">
<div class="picture-cs-item"> <div class="picture-cs-item">
<div class="title">{{$t('emailGrowth')}}</div> <div class="title">{{ $t('emailGrowth') }}</div>
<div class="email-column"></div> <div class="email-column"></div>
</div> </div>
<div class="picture-cs-item"> <div class="picture-cs-item">
<div class="title">{{$t('sentToday')}}</div> <div class="title">{{ $t('sentToday') }}</div>
<div class="send-count"></div> <div class="send-count"></div>
</div> </div>
</div> </div>
@@ -129,13 +129,13 @@ import {useUiStore} from "@/store/ui.js";
import {debounce} from "lodash-es"; import {debounce} from "lodash-es";
import loading from "@/components/loading/index.vue"; import loading from "@/components/loading/index.vue";
import {useRoute} from "vue-router"; import {useRoute} from "vue-router";
import { useI18n } from 'vue-i18n'; import {useI18n} from 'vue-i18n';
defineOptions({ defineOptions({
name: 'analysis' name: 'analysis'
}) })
const { t } = useI18n(); const {t} = useI18n();
const route = useRoute(); const route = useRoute();
const uiStore = useUiStore() const uiStore = useUiStore()
const checkedSourceType = ref('sender') 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) userLineData.sdata = data.userDayCount.map(item => item.total)
emailColumnData.daysData = data.emailDayCount.receiveDayCount.map(item => dayjs(item.date).format("M.D")) emailColumnData.daysData = data.emailDayCount.receiveDayCount.map(item => dayjs(item.date).format("M.D"))
emailColumnData.receiveData = data.emailDayCount.receiveDayCount.map(item => item.total) emailColumnData.receiveData = data.emailDayCount.receiveDayCount.map(item => item.total)
emailColumnData.sendData = data.emailDayCount.sendDayCount.map(item => item.total) emailColumnData.sendData = data.emailDayCount.sendDayCount.map(item => item.total)
daySendTotal = data.daySendTotal daySendTotal = data.daySendTotal
analysisLoading.value = false analysisLoading.value = false
initPicture(); initPicture();
@@ -234,8 +233,8 @@ onMounted(() => {
}) })
function initPicture() { function initPicture() {
if(route.name !== 'analysis') return if (route.name !== 'analysis') return
boxKey.value ++ boxKey.value++
setTimeout(() => { setTimeout(() => {
createSenderPie() createSenderPie()
createIncreaseLine() createIncreaseLine()
@@ -282,7 +281,7 @@ function setStyle() {
const measureCtx = document.createElement('canvas').getContext('2d'); const measureCtx = document.createElement('canvas').getContext('2d');
measureCtx.font = '12px sans-serif'; measureCtx.font = '12px sans-serif';
function truncateTextByWidth(text,maxWidth = 140) { function truncateTextByWidth(text, maxWidth = 140) {
let width = measureCtx.measureText(text).width; let width = measureCtx.measureText(text).width;
if (width <= maxWidth) return text; if (width <= maxWidth) return text;
@@ -311,22 +310,22 @@ function createSenderPie() {
return `${params.marker} ${params.name} ${params.value} (${params.percent}%)`; return `${params.marker} ${params.name} ${params.value} (${params.percent}%)`;
} }
}, },
legend: { legend: {
type: 'scroll', type: 'scroll',
orient: 'vertical', orient: 'vertical',
left: '10', left: '10',
top: '20', top: '20',
formatter: function (name) { formatter: function (name) {
return truncateTextByWidth(name) return truncateTextByWidth(name)
} }
}, },
series: [ series: [
{ {
data: senderData.value, data: senderData.value,
name: '', name: '',
type: 'pie', type: 'pie',
radius: ['40%', '65%'], radius: ['40%', '65%'],
center: [ senderPieLeft, '45%'], center: [senderPieLeft, '45%'],
avoidLabelOverlap: false, avoidLabelOverlap: false,
itemStyle: { itemStyle: {
borderRadius: 4, borderRadius: 4,
@@ -378,7 +377,7 @@ function createIncreaseLine() {
formatter: function (params) { formatter: function (params) {
let result = '' let result = ''
params.forEach(item => { params.forEach(item => {
result = `${item.marker} ${t('growthTotalUsers')} ${item.value}`; result = `${item.marker} ${t('growthTotalUsers')} ${item.value}`;
}); });
return result; return result;
}, },
@@ -438,7 +437,7 @@ function createIncreaseLine() {
}, },
boundaryGap: [0, 0.1], boundaryGap: [0, 0.1],
max: (params) => { max: (params) => {
if (params.max < 8 ) { if (params.max < 8) {
return 10 return 10
} }
}, },
@@ -493,6 +492,21 @@ function createIncreaseLine() {
] ]
}; };
increaseLine.setOption(option); 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() { function createEmailColumnChart() {
@@ -537,7 +551,7 @@ function createEmailColumnChart() {
}, },
yAxis: { yAxis: {
max: (params) => { max: (params) => {
if (params.max < 8 ) { if (params.max < 8) {
return 10 return 10
} }
}, },
@@ -584,7 +598,7 @@ function createEmailColumnChart() {
} }
function createSendGauge() { function createSendGauge() {
if(sendGauge) { if (sendGauge) {
sendGauge.dispose() sendGauge.dispose()
} }
sendGauge = echarts.init(document.querySelector(".send-count")); sendGauge = echarts.init(document.querySelector(".send-count"));
@@ -649,6 +663,7 @@ function createSendGauge() {
margin-top: 10px; margin-top: 10px;
font-size: 28px; font-size: 28px;
} }
.percentage-label { .percentage-label {
display: block; display: block;
margin-top: 10px; margin-top: 10px;
@@ -663,6 +678,7 @@ function createSendGauge() {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.analysis { .analysis {
height: 100%; height: 100%;
padding: 20px 20px 30px; padding: 20px 20px 30px;
@@ -674,12 +690,14 @@ function createSendGauge() {
padding: 15px 15px 30px; padding: 15px 15px 30px;
gap: 15px gap: 15px
} }
.title { .title {
margin-top: 10px; margin-top: 10px;
margin-left: 15px; margin-left: 15px;
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
} }
.number { .number {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr 1fr;
@@ -691,11 +709,13 @@ function createSendGauge() {
@media (max-width: 767px) { @media (max-width: 767px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.number-item { .number-item {
background: var(--el-bg-color); background: var(--el-bg-color);
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--el-border-color); border: 1px solid var(--el-border-color);
padding: 21px 20px; padding: 21px 20px;
.top { .top {
display: grid; display: grid;
justify-content: space-between; justify-content: space-between;
@@ -719,6 +739,7 @@ function createSendGauge() {
.right { .right {
display: grid; display: grid;
align-items: center; align-items: center;
.count-icon { .count-icon {
top: 3px; top: 3px;
position: relative; position: relative;
@@ -740,6 +761,7 @@ function createSendGauge() {
justify-content: start; justify-content: start;
gap: 20px; gap: 20px;
padding-top: 5px; padding-top: 5px;
.normal { .normal {
width: fit-content; width: fit-content;
color: var(--el-color-success); color: var(--el-color-success);
@@ -768,24 +790,29 @@ function createSendGauge() {
@media (max-width: 1024px) { @media (max-width: 1024px) {
gap: 15px; gap: 15px;
} }
.picture-item { .picture-item {
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--el-border-color); border: 1px solid var(--el-border-color);
.source-button { .source-button {
padding-right: 15px; padding-right: 15px;
display: flex; display: flex;
align-items: start; align-items: start;
:deep(.el-radio-button__inner) { :deep(.el-radio-button__inner) {
padding: 6px 10px; padding: 6px 10px;
} }
} }
.sender-pie { .sender-pie {
height: 350px; height: 350px;
@media (max-width: 767px) { @media (max-width: 767px) {
height: 200px; height: 200px;
} }
} }
.increase-line { .increase-line {
height: 350px; height: 350px;
@media (max-width: 767px) { @media (max-width: 767px) {
@@ -803,16 +830,19 @@ function createSendGauge() {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 15px; gap: 15px;
} }
.picture-cs-item { .picture-cs-item {
background: #fff; background: #fff;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--el-border-color); border: 1px solid var(--el-border-color);
.send-count { .send-count {
height: 350px; height: 350px;
@media (max-width: 767px) { @media (max-width: 767px) {
height: 320px; height: 320px;
} }
} }
.email-column { .email-column {
height: 350px; height: 350px;
@media (max-width: 767px) { @media (max-width: 767px) {

View File

@@ -9,89 +9,96 @@
</div> </div>
<div v-else :style="background"></div> <div v-else :style="background"></div>
<div class="form-wrapper"> <div class="form-wrapper">
<div class="container"> <div class="container">
<span class="form-title">{{settingStore.settings.title}}</span> <span class="form-title">{{ settingStore.settings.title }}</span>
<span class="form-desc" v-if="show === 'login'">{{$t('loginTitle')}}</span> <span class="form-desc" v-if="show === 'login'">{{ $t('loginTitle') }}</span>
<span class="form-desc" v-else>{{$t('regTitle')}}</span> <span class="form-desc" v-else>{{ $t('regTitle') }}</span>
<div v-show="show === 'login'"> <div v-show="show === 'login'">
<el-input class="email-input" v-model="form.email" type="text" :placeholder="$t('emailAccount')" autocomplete="off"> <el-input :class="settingStore.settings.loginDomain === 0 ? 'email-input' : ''" v-model="form.email"
<template #append> type="text" :placeholder="$t('emailAccount')" autocomplete="off">
<div @click.stop="openSelect"> <template #append v-if="settingStore.settings.loginDomain === 0">
<el-select <div @click.stop="openSelect">
v-if="show === 'login'" <el-select
ref="mySelect" v-if="show === 'login'"
v-model="suffix" ref="mySelect"
:placeholder="$t('select')" v-model="suffix"
class="select" :placeholder="$t('select')"
> class="select"
<el-option >
v-for="item in domainList" <el-option
:key="item" v-for="item in domainList"
:label="item" :key="item"
:value="item" :label="item"
/> :value="item"
</el-select> />
<div style="color: #333"> </el-select>
<span>{{ suffix }}</span> <div style="color: #333">
<Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/> <span>{{ suffix }}</span>
</div> <Icon class="setting-icon" icon="mingcute:down-small-fill" width="20" height="20"/>
</div> </div>
</template> </div>
</el-input> </template>
<el-input v-model="form.password" :placeholder="$t('password')" type="password" autocomplete="off"> </el-input>
</el-input> <el-input v-model="form.password" :placeholder="$t('password')" type="password" autocomplete="off">
<el-button class="btn" type="primary" @click="submit" :loading="loginLoading" </el-input>
>{{$t('loginBtn')}} <el-button class="btn" type="primary" @click="submit" :loading="loginLoading"
</el-button> >{{ $t('loginBtn') }}
</div> </el-button>
<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 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>
</div> </div>
</template> </template>
@@ -112,7 +119,7 @@ import {loginUserInfo} from "@/request/my.js";
import {permsToRouter} from "@/perm/perm.js"; import {permsToRouter} from "@/perm/perm.js";
import {useI18n} from "vue-i18n"; import {useI18n} from "vue-i18n";
const { t } = useI18n(); const {t} = useI18n();
const accountStore = useAccountStore(); const accountStore = useAccountStore();
const userStore = useUserStore(); const userStore = useUserStore();
const uiStore = useUiStore(); const uiStore = useUiStore();
@@ -139,20 +146,27 @@ const verifyShow = ref(false)
let verifyToken = '' let verifyToken = ''
let turnstileId = null let turnstileId = null
let botJsError = ref(false) let botJsError = ref(false)
let verifyErrorCount = 0
window.onTurnstileSuccess = (token) => { window.onTurnstileSuccess = (token) => {
verifyToken = token; verifyToken = token;
}; };
window.onTurnstileError = (e) => { window.onTurnstileError = (e) => {
console.log('人机验加载失败') if (verifyErrorCount >= 4) {
nextTick(() => { return
if (!turnstileId) { }
turnstileId = window.turnstile.render('.register-turnstile') verifyErrorCount++
} else { console.warn('人机验加载失败', e)
window.turnstile.reset(turnstileId); setTimeout(() => {
} nextTick(() => {
}) if (!turnstileId) {
turnstileId = window.turnstile.render('.register-turnstile')
} else {
window.turnstile.reset(turnstileId);
}
})
}, 1500)
}; };
window.loadAfter = (e) => { window.loadAfter = (e) => {
@@ -193,7 +207,9 @@ const submit = () => {
return return
} }
if (!isEmail(form.email + suffix.value)) { let email = form.email + (settingStore.settings.loginDomain === 0 ? suffix.value : '');
if (!isEmail(email)) {
ElMessage({ ElMessage({
message: t('notEmailMsg'), message: t('notEmailMsg'),
type: 'error', type: 'error',
@@ -212,7 +228,7 @@ const submit = () => {
} }
loginLoading.value = true 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) localStorage.setItem('token', data.token)
const user = await loginUserInfo(); const user = await loginUserInfo();
accountStore.currentAccountId = user.accountId; accountStore.currentAccountId = user.accountId;
@@ -277,7 +293,7 @@ function submitRegister() {
return return
} }
if(settingStore.settings.regKey === 0) { if (settingStore.settings.regKey === 0) {
if (!registerForm.code) { if (!registerForm.code) {
@@ -414,6 +430,7 @@ function submitRegister() {
margin-right: 18px; margin-right: 18px;
margin-left: 18px; margin-left: 18px;
} }
.btn { .btn {
height: 36px; height: 36px;
width: 100%; width: 100%;
@@ -434,6 +451,7 @@ function submitRegister() {
.switch { .switch {
margin-top: 20px; margin-top: 20px;
text-align: center; text-align: center;
span { span {
color: #006be6; color: #006be6;
cursor: pointer; cursor: pointer;
@@ -444,7 +462,7 @@ function submitRegister() {
border-radius: 6px; border-radius: 6px;
} }
.email-input :deep(.el-input__wrapper){ .email-input :deep(.el-input__wrapper) {
border-radius: 6px 0 0 6px; border-radius: 6px 0 0 6px;
} }
@@ -452,6 +470,7 @@ function submitRegister() {
height: 38px; height: 38px;
width: 100%; width: 100%;
margin-bottom: 18px; margin-bottom: 18px;
:deep(.el-input__inner) { :deep(.el-input__inner) {
height: 36px; height: 36px;
} }
@@ -497,7 +516,6 @@ function submitRegister() {
} }
#login-box { #login-box {
background: linear-gradient(to bottom, #2980b9, #6dd5fa, #fff); background: linear-gradient(to bottom, #2980b9, #6dd5fa, #fff);
color: #333; color: #333;

View File

@@ -41,8 +41,8 @@
</div> </div>
<el-dialog v-model="pwdShow" :title="$t('changePassword')" width="340"> <el-dialog v-model="pwdShow" :title="$t('changePassword')" width="340">
<div class="update-pwd"> <div class="update-pwd">
<el-input type="password" :placeholder="$t('newPassword')" v-model="form.password"/> <el-input type="password" :placeholder="$t('newPassword')" v-model="form.password" autocomplete="off"/>
<el-input type="password" :placeholder="$t('confirmPassword')" v-model="form.newPwd"/> <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> <el-button type="primary" :loading="setPwdLoading" @click="submitPwd">{{$t('save')}}</el-button>
</div> </div>
</el-dialog> </el-dialog>
@@ -256,4 +256,4 @@ function submitPwd() {
gap: 20px; gap: 20px;
} }
} }
</style> </style>

View File

@@ -17,6 +17,13 @@
v-model="setting.register"/> v-model="setting.register"/>
</div> </div>
</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 class="setting-item">
<div><span>{{$t('regKey')}}</span></div> <div><span>{{$t('regKey')}}</span></div>
<div> <div>
@@ -326,7 +333,7 @@
<div class="card-content"> <div class="card-content">
<div class="concerning-item"> <div class="concerning-item">
<span>{{$t('version')}} :</span> <span>{{$t('version')}} :</span>
<span>v1.6.0</span> <span>v1.7.0</span>
</div> </div>
<div class="concerning-item"> <div class="concerning-item">
<span>{{$t('community')}} : </span> <span>{{$t('community')}} : </span>
@@ -345,13 +352,22 @@
</div> </div>
<div class="concerning-item"> <div class="concerning-item">
<span>{{$t('support')}} : </span> <span>{{$t('support')}} : </span>
<el-button @click="jump('https://support.skymail.ink')" > <el-button @click="jump('https://doc.skymail.ink/support.html')" >
{{t('supportDesc')}} {{t('supportDesc')}}
<template #icon> <template #icon>
<Icon color="#79D6B5" icon="simple-icons:buymeacoffee" width="20" height="20" /> <Icon color="#79D6B5" icon="simple-icons:buymeacoffee" width="20" height="20" />
</template> </template>
</el-button> </el-button>
</div> </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> </div>
</div> </div>
@@ -1150,8 +1166,8 @@ function editSetting(settingForm, refreshStatus = true) {
} }
.background { .background {
width: 230px; width: 250px;
height: 120px; height: 140px;
border-radius: 4px; border-radius: 4px;
border: 1px solid #e4e7ed; border: 1px solid #e4e7ed;
@media (max-width: 500px) { @media (max-width: 500px) {
@@ -1178,11 +1194,7 @@ function editSetting(settingForm, refreshStatus = true) {
overflow: hidden; overflow: hidden;
} }
@media (min-width: 885px) {
.about {
height: 210px;
}
}
.card-title { .card-title {
font-size: 15px; font-size: 15px;
@@ -1501,4 +1513,4 @@ form .el-button {
<style> <style>
.el-popper.is-dark { .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

View File

@@ -7,8 +7,8 @@
<link rel="icon" href="/assets/favicon-C5dAZutX.svg" type="image/svg+xml"> <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"> <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 src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script type="module" crossorigin src="/assets/index-DQO7jFFS.js"></script> <script type="module" crossorigin src="/assets/index-ONNky_gH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BP2DuLPL.css"> <link rel="stylesheet" crossorigin href="/assets/index-NqVTnf-N.css">
</head> </head>
<body> <body>
<div id="loading-first"> <div id="loading-first">

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());
});

View File

@@ -1,7 +1,8 @@
const KvConst = { const KvConst = {
AUTH_INFO: 'auth-uid:', AUTH_INFO: 'auth-uid:',
SETTING: 'setting:', SETTING: 'setting:',
SEND_DAY_COUNT: 'send_day_count:' SEND_DAY_COUNT: 'send_day_count:',
PUBLIC_KEY: "public_key:"
} }
export default KvConst; export default KvConst;

View File

@@ -33,6 +33,7 @@ export const setting = sqliteTable('setting', {
noticeOffset: integer('notice_offset').default(0).notNull(), noticeOffset: integer('notice_offset').default(0).notNull(),
noticeWidth: integer('notice_width').default(400).notNull(), noticeWidth: integer('notice_width').default(400).notNull(),
notice: integer('notice').default(0).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 export default setting

View File

@@ -17,4 +17,5 @@ import '../api/all-email-api'
import '../api/init-api' import '../api/init-api'
import '../api/analysis-api' import '../api/analysis-api'
import '../api/reg-key-api' import '../api/reg-key-api'
import '../api/public-api'
export default app; export default app;

View File

@@ -59,6 +59,9 @@ const en = {
noDomainPermRegKey: "Registration code not valid for this domain", noDomainPermRegKey: "Registration code not valid for this domain",
noDomainPermSend: "No permission to send from this domain email", noDomainPermSend: "No permission to send from this domain email",
JWTMismatch: 'JWT secret mismatch', 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: { perms: {
"邮件": "Email", "邮件": "Email",
"邮件发送": "Send Email", "邮件发送": "Send Email",

View File

@@ -21,7 +21,7 @@ const resources = {
}; };
i18next.init({ i18next.init({
fallbackLng: 'en', fallbackLng: 'zh',
resources, resources,
}); });

View File

@@ -34,7 +34,7 @@ const zh = {
noRegKeyTotal: '注册码使用次数已耗尽', noRegKeyTotal: '注册码使用次数已耗尽',
regKeyExpire: '注册码已过期', regKeyExpire: '注册码已过期',
emailAndPwdEmpty: '邮箱和密码不能为空', emailAndPwdEmpty: '邮箱和密码不能为空',
notExistUser: '邮箱不存在', notExistUser: '输入的邮箱不存在',
isDelUser: '该邮箱已被注销', isDelUser: '该邮箱已被注销',
isBanUser: '该邮箱已被禁用', isBanUser: '该邮箱已被禁用',
regKeyUseCount: '使用次数不能为空', regKeyUseCount: '使用次数不能为空',
@@ -59,6 +59,9 @@ const zh = {
noDomainPermRegKey: '你的注册码没有权限注册该域名邮箱', noDomainPermRegKey: '你的注册码没有权限注册该域名邮箱',
noDomainPermSend: '你没有权限使用该域名邮箱发送邮件', noDomainPermSend: '你没有权限使用该域名邮箱发送邮件',
JWTMismatch: 'jwt_secret 不匹配', JWTMismatch: 'jwt_secret 不匹配',
publicTokenFail: 'token验证失败',
notAdmin: '输入的邮箱不是管理员邮箱',
emailExistDatabase: '有邮箱已存在数据库中',
perms: { perms: {
"邮件": "邮件", "邮件": "邮件",
"邮件发送": "邮件发送", "邮件发送": "邮件发送",

View File

@@ -20,10 +20,15 @@ const init = {
await this.v1_4DB(c); await this.v1_4DB(c);
await this.v1_5DB(c); await this.v1_5DB(c);
await this.v1_6DB(c); await this.v1_6DB(c);
await this.v1_7DB(c);
await settingService.refresh(c); await settingService.refresh(c);
return c.text(t('initSuccess')); 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) { async v1_6DB(c) {
const noticeContent = '<div style="color: teal;margin-bottom: 5px;">欢迎使用 Cloud Mail 🎉 </div >\n' + const noticeContent = '<div style="color: teal;margin-bottom: 5px;">欢迎使用 Cloud Mail 🎉 </div >\n' +

View File

@@ -14,7 +14,8 @@ const exclude = [
'/file', '/file',
'/setting/websiteConfig', '/setting/websiteConfig',
'/webhooks', '/webhooks',
'/init' '/init',
'/public/genToken'
]; ];
const requirePerms = [ const requirePerms = [
@@ -95,6 +96,16 @@ app.use('*', async (c, next) => {
return await 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); const jwt = c.req.header(constant.TOKEN_HEADER);

View File

@@ -152,6 +152,10 @@ const accountService = {
await orm(c).insert(account).values({ ...params }).returning(); await orm(c).insert(account).values({ ...params }).returning();
}, },
async insertList(c, list) {
await orm(c).insert(account).values(list).run();
},
async physicsDeleteAll(c) { async physicsDeleteAll(c) {
const accountIdsRow = await orm(c).select({accountId: account.accountId}).from(account).where(eq(account.isDel,isDel.DELETE)).limit(99); const accountIdsRow = await orm(c).select({accountId: account.accountId}).from(account).where(eq(account.isDel,isDel.DELETE)).limit(99);
if (accountIdsRow.length === 0) { if (accountIdsRow.length === 0) {

View File

@@ -119,10 +119,10 @@ const loginService = {
const userId = await userService.insert(c, { email, regKeyId,password: hash, salt, type: type || defType }); 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 accountService.insert(c, { userId: userId, email, name: emailUtils.getName(email) });
await userService.updateUserInfo(c, userId, true);
if (regKey !== settingConst.regKey.CLOSE && type) { if (regKey !== settingConst.regKey.CLOSE && type) {
await regKeyService.reduceCount(c, code, 1); await regKeyService.reduceCount(c, code, 1);
} }
@@ -136,6 +136,10 @@ const loginService = {
}, },
async registerVerify() {
},
async handleOpenRegKey(c, regKey, code) { async handleOpenRegKey(c, regKey, code) {
if (!code) { if (!code) {

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

View File

@@ -116,7 +116,7 @@ const roleService = {
}, },
roleSelectUse(c) { 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) { async selectDefaultRole(c) {
@@ -170,6 +170,10 @@ const roleService = {
}) })
return availIndex > -1 return availIndex > -1
},
selectByName(c, roleName) {
return orm(c).select().from(role).where(eq(role.name, roleName)).get();
} }
}; };

View File

@@ -148,6 +148,7 @@ const settingService = {
noticeWidth: settingRow.noticeWidth, noticeWidth: settingRow.noticeWidth,
noticeOffset: settingRow.noticeOffset, noticeOffset: settingRow.noticeOffset,
notice: settingRow.notice, notice: settingRow.notice,
loginDomain: settingRow.loginDomain
}; };
} }
}; };

View File

@@ -8,7 +8,6 @@ import kvConst from '../const/kv-const';
import KvConst from '../const/kv-const'; import KvConst from '../const/kv-const';
import cryptoUtils from '../utils/crypto-utils'; import cryptoUtils from '../utils/crypto-utils';
import emailService from './email-service'; import emailService from './email-service';
import { UAParser } from 'ua-parser-js';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import permService from './perm-service'; import permService from './perm-service';
import roleService from './role-service'; import roleService from './role-service';
@@ -16,6 +15,7 @@ import emailUtils from '../utils/email-utils';
import saltHashUtils from '../utils/crypto-utils'; import saltHashUtils from '../utils/crypto-utils';
import constant from '../const/constant'; import constant from '../const/constant';
import { t } from '../i18n/i18n' import { t } from '../i18n/i18n'
import reqUtils from '../utils/req-utils';
const userService = { const userService = {
@@ -216,49 +216,22 @@ const userService = {
async updateUserInfo(c, userId, recordCreateIp = false) { 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) { const activeIp = reqUtils.getIp(c);
browserInfo = browser.name + ' ' + browser.version;
}
if (os.name) { const {os, browser, device} = reqUtils.getUserAgent(c);
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 params = { const params = {
os: osInfo, os,
browser: browserInfo, browser,
device: deviceInfo, device,
activeIp: userIp, activeIp,
activeTime: dayjs().format('YYYY-MM-DD HH:mm:ss') activeTime: dayjs().format('YYYY-MM-DD HH:mm:ss')
}; };
if (recordCreateIp) { if (recordCreateIp) {
params.createIp = userIp; params.createIp = activeIp;
} }
await orm(c) await orm(c)

View File

@@ -2,13 +2,13 @@ import orm from '../entity/orm';
import verifyRecord from '../entity/verify-record'; import verifyRecord from '../entity/verify-record';
import { eq, sql, and } from 'drizzle-orm'; import { eq, sql, and } from 'drizzle-orm';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import ipUtils from '../utils/ip-utils'; import reqUtils from '../utils/req-utils';
import { verifyRecordType } from '../const/entity-const'; import { verifyRecordType } from '../const/entity-const';
const verifyRecordService = { const verifyRecordService = {
async selectListByIP(c) { 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(); return orm(c).select().from(verifyRecord).where(eq(verifyRecord.ip, ip)).all();
}, },
@@ -18,7 +18,7 @@ const verifyRecordService = {
async isOpenRegVerify(c, regVerifyCount) { 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(); 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) { 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(); 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) { 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 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'); const now = dayjs().format('YYYY-MM-DD HH:mm:ss');
@@ -70,7 +70,7 @@ const verifyRecordService = {
async increaseAddCount(c) { 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 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'); const now = dayjs().format('YYYY-MM-DD HH:mm:ss');

View File

@@ -25,6 +25,15 @@ const saltHashUtils = {
async verifyPassword(inputPassword, salt, storedHash) { async verifyPassword(inputPassword, salt, storedHash) {
const hash = await this.genHashPassword(inputPassword, salt); const hash = await this.genHashPassword(inputPassword, salt);
return hash === storedHash; 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;
} }
}; };

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

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

View File

@@ -1,6 +1,6 @@
name = "cloud-mail" name = "cloud-mail"
main = "src/index.js" main = "src/index.js"
compatibility_date = "2025-07-29" compatibility_date = "2025-06-04"
keep_vars = true keep_vars = true
[observability] [observability]

View File

@@ -1,6 +1,6 @@
name = "cloud-mail-dev" name = "cloud-mail-dev"
main = "src/index.js" main = "src/index.js"
compatibility_date = "2025-07-29" compatibility_date = "2025-06-04"
keep_vars = true keep_vars = true
[observability] [observability]

View File

@@ -1,6 +1,6 @@
name = "cloud-mail-test" name = "cloud-mail-test"
main = "src/index.js" main = "src/index.js"
compatibility_date = "2025-07-29" compatibility_date = "2025-06-04"
keep_vars = true keep_vars = true
[observability] [observability]

View File

@@ -1,6 +1,6 @@
name = "cloud-mail" name = "cloud-mail"
main = "src/index.js" main = "src/index.js"
compatibility_date = "2025-07-29" compatibility_date = "2025-06-04"
keep_vars = true keep_vars = true
[observability] [observability]