支持多用户
支持SSL证书支持DNS验证
增加mysql测试用例
修复DNS设置时的展示歧义
This commit is contained in:
Jinnrry 2024-07-02 21:02:00 +08:00 committed by GitHub
parent 76bc24d2bd
commit 01cbdc9875
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 4722 additions and 538 deletions

View File

@ -19,6 +19,14 @@ jobs:
test:
name: Docker tests
runs-on: ubuntu-latest
services:
mysql:
image: mysql
env:
MYSQL_DATABASE: pmail
MYSQL_ROOT_PASSWORD: githubTest
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
container:
image: golang
env:
@ -51,3 +59,13 @@ jobs:
- name: Run Test
run: make test
- uses: actions/upload-artifact@v4
with:
name: dbfile
path: server/config/pmail_temp.db
- name: Run Test Mysql
run: make test_mysql

2
.gitignore vendored
View File

@ -5,3 +5,5 @@ output
pmail.db
server/plugins
config
*_KEY
AURORA_SECRET

View File

@ -50,3 +50,6 @@ package: clean
test:
export setup_port=17888 && cd server && go test -v ./...
test_mysql:
export setup_port=17888 && cd server && go test -args "mysql" -v ./...

View File

@ -146,3 +146,6 @@ The code is in `server` folder.
[go to wiki](https://github.com/Jinnrry/PMail/wiki/%E6%8F%92%E4%BB%B6%E5%BC%80%E5%8F%91%E8%AF%B4%E6%98%8E)
# Thanks
A special thanks to [Jetbrains](http://jetbrains.com/) for donating licenses to the project.

View File

@ -83,7 +83,7 @@ PMail是一个追求极简部署流程、极致资源占用的个人域名邮箱
"domain": "domain.com", // 你的域名
"webDomain": "mail.domain.com", // web域名
"dkimPrivateKeyPath": "config/dkim/dkim.priv", // dkim 私钥地址
"sslType": "0", // ssl证书更新模式0自动1手动
"sslType": "0", // ssl证书更新模式0自动HTTP模式1手动、2自动DNS模式
"SSLPrivateKeyPath": "config/ssl/private.key", // ssl 证书地址
"SSLPublicKeyPath": "config/ssl/public.crt", // ssl 证书地址
"dbDSN": "./config/pmail.db", // 数据库连接DSN
@ -149,4 +149,6 @@ SMTP端口 25/465(SSL)
[go to wiki](https://github.com/Jinnrry/PMail/wiki/%E6%8F%92%E4%BB%B6%E5%BC%80%E5%8F%91%E8%AF%B4%E6%98%8E)
# 致谢
感谢 [Jetbrains](http://jetbrains.com/) 为本项目免费提供开发工具。

View File

@ -17,11 +17,12 @@
</template>
<script setup>
import $http from "../http/http";
import { reactive, ref } from 'vue'
import { reactive, ref, getCurrentInstance } from 'vue'
import lang from '../i18n/i18n';
const data = reactive([])
const app = getCurrentInstance()
const $http = app.appContext.config.globalProperties.$http
$http.get('/api/group').then(res => {
data.push(...res.data)
@ -29,7 +30,7 @@ $http.get('/api/group').then(res => {
const del = function (node, data) {
if (data.id != -1) {
$http.post("/api/group/del", { "id": data.id }).then(res => {
this.$axios.post("/api/group/del", { "id": data.id }).then(res => {
if (res.errorNo != 0) {
ElMessage({
message: res.errorMsg,
@ -79,14 +80,14 @@ const addRoot = function () {
const onInputBlur = function (item) {
if (item.label != "") {
$http.post("/api/group/add", { "name": item.label, "parent_id": item.parent_id }).then(res => {
this.$axios.post("/api/group/add", { "name": item.label, "parent_id": item.parent_id }).then(res => {
if (res.errorNo != 0) {
ElMessage({
message: res.errorMsg,
type: 'error',
})
} else {
$http.get('/api/group').then(res => {
this.$axios.get('/api/group').then(res => {
data.splice(0, data.length)
data.push(...res.data)
})

View File

@ -8,10 +8,12 @@
<script setup>
import { useRouter } from 'vue-router'
import $http from "../http/http";
import { reactive, ref } from 'vue'
import useGroupStore from '../stores/group'
import lang from '../i18n/i18n';
import { getCurrentInstance } from 'vue'
const app = getCurrentInstance()
const $http = app.appContext.config.globalProperties.$http
const groupStore = useGroupStore()
const router = useRouter()
@ -19,6 +21,7 @@ const router = useRouter()
const data = ref([])
$http.get('/api/group').then(res => {
data.value = res.data
})

View File

@ -3,7 +3,7 @@
<div id="logo">
<span style="padding-left: 20px;">PMail</span>
</div>
<div id="settings" @click="settings">
<div id="settings" @click="settings" v-if="$isLogin">
<el-icon style="font-size: 25px;">
<Setting style="color:#FFFFFF" />
</el-icon>
@ -21,6 +21,10 @@
<el-tab-pane :label="lang.rule_setting">
<RuleSettings />
</el-tab-pane>
<el-tab-pane v-if="$userInfos.is_admin" :label="lang.user_management">
<UserManagement />
</el-tab-pane>
</el-tabs>
</el-drawer>
@ -30,15 +34,39 @@
<script setup>
import { Setting } from '@element-plus/icons-vue';
import { ref } from 'vue'
import { ElMessageBox } from 'element-plus'
import { ElMessage } from 'element-plus'
import SecuritySettings from '@/components/SecuritySettings.vue'
import lang from '../i18n/i18n';
import GroupSettings from './GroupSettings.vue';
import RuleSettings from './RuleSettings.vue';
import UserManagement from './UserManagement.vue';
import { getCurrentInstance } from 'vue'
const app = getCurrentInstance()
const $http = app.appContext.config.globalProperties.$http
const $isLogin = app.appContext.config.globalProperties.$isLogin
const $userInfos = app.appContext.config.globalProperties.$userInfos
const openSettings = ref(false)
const settings = function () {
if (Object.keys($userInfos.value).length == 0) {
$http.post("/api/user/info", {}).then(res => {
if (res.errorNo == 0) {
$userInfos.value = res.data
openSettings.value = true;
} else {
ElMessage({
type: 'error',
message: res.errorMsg,
})
}
})
}else{
openSettings.value = true;
}
}
</script>

View File

@ -98,7 +98,6 @@
<script setup>
import { ref, reactive } from 'vue';
import $http from "../http/http";
import lang from '../i18n/i18n';
import {
Plus,
@ -106,6 +105,12 @@ import {
Edit,
InfoFilled
} from '@element-plus/icons-vue'
import { getCurrentInstance } from 'vue'
const app = getCurrentInstance()
const $http = app.appContext.config.globalProperties.$http
const data = ref([])
const dialogVisible = ref(false)
const READ = 1
@ -134,10 +139,13 @@ const groupData = reactive({
const reflushGroupInfos = function () {
$http.get('/api/group/list').then(res => {
if (res.data != null) {
groupData.list = res.data
for (let i = 0; i < groupData.list.length; i++) {
groupData.list[i].id += ""
}
}
})
}

View File

@ -1,5 +1,8 @@
<template>
<el-form :model="ruleForm" :rules="rules" status-icon>
<el-divider content-position="left">{{lang.modify_pwd}}</el-divider>
<el-form-item :label="lang.modify_pwd" prop="new_pwd">
<el-input type="password" v-model="ruleForm.new_pwd" />
</el-form-item>
@ -13,14 +16,25 @@
{{ lang.submit }}
</el-button>
</el-form-item>
<el-divider content-position="left">{{lang.logout}}</el-divider>
<el-form-item>
<el-button type="primary" @click="logout">
{{ lang.logout }}
</el-button>
</el-form-item>
</el-form>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { ElNotification } from 'element-plus'
import $http from "../http/http";
import lang from '../i18n/i18n';
import { getCurrentInstance } from 'vue'
const app = getCurrentInstance()
const $http = app.appContext.config.globalProperties.$http
const ruleForm = reactive({
new_pwd: "",
new_pwd2: ""
@ -32,6 +46,12 @@ const rules = reactive({
})
const logout = function(){
$http.post("/api/logout", { }).then(res => {
location.reload();
})
}
const submit = function () {
if (ruleForm.new_pwd == ""){
return

View File

@ -0,0 +1,181 @@
<template>
<div id="main">
<el-table :data="userList" style="width: 100%">
<el-table-column label="ID" prop="ID" />
<el-table-column :label="lang.account" prop="Account" />
<el-table-column :label="lang.user_name" prop="Name" />
<el-table-column :label="lang.disabled" prop="Disabled">
<template #default="scope">
<span>{{ scope.row.Disabled == 1 ? lang.disabled : lang.enabled }}</span>
</template>
</el-table-column>
<el-table-column align="right">
<template #header>
<el-button type="primary" size="small" @click="createUser">
New
</el-button>
</template>
<template #default="scope">
<el-button size="small" @click="handleEdit(scope.$index, scope.row)">
Edit
</el-button>
</template>
</el-table-column>
</el-table>
<div id="paginationBox">
<el-pagination v-model:current-page="currentPage" small background layout="prev, pager, next"
:page-count="totalPage" class="mt-4" @current-change="reflushList" />
</div>
<el-dialog v-model="userInfoDialog" :title="title" width="500">
<el-form>
<el-form-item label-width="100px" :label="lang.account">
<el-input :disabled="editModel == 'edit'" v-model="editUserInfo.account" autocomplete="off" />
</el-form-item>
<el-form-item label-width="100px" :label="lang.user_name">
<el-input v-model="editUserInfo.name" autocomplete="off" />
</el-form-item>
<el-form-item label-width="100px" :label="lang.password">
<el-input :placeholder="lang.resetPwd" v-model="editUserInfo.password" autocomplete="off" />
</el-form-item>
<div style="display: flex;">
<div
style="display: inline-flex;justify-content: flex-end;align-items: flex-start;flex: 0 0 auto;font-size: var(--el-form-label-font-size); height: 32px;line-height: 32px;padding: 0 12px 0 60px;box-sizing: border-box; ">
<el-switch v-model="editUserInfo.disabled" class="ml-2" :active-text="lang.disabled"
:inactive-text="lang.enabled" />
</div>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="userInfoDialog = false">Cancel</el-button>
<el-button type="primary" @click="submit">
Confirm
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref, getCurrentInstance } from 'vue'
import lang from '../i18n/i18n';
const userList = reactive([])
const app = getCurrentInstance()
const $http = app.appContext.config.globalProperties.$http
const currentPage = ref(1)
const totalPage = ref(1)
const userInfoDialog = ref(false)
const editModel = ref("edit")
const editUserInfo = reactive({
"account": "",
"name": "",
"password": "",
"disabled": false
})
const title = ref(lang.editUser)
const reflushList = function () {
$http.post('/api/user/list', { "current_page": currentPage.value, "page_size": 10 }).then(res => {
userList.length = 0
totalPage.value = res.data.total_page
userList.push(...res.data["list"])
})
}
const handleEdit = function (idx, row) {
editUserInfo.account = row.Account
editUserInfo.name = row.Name
editUserInfo.disabled = row.Disabled == 1
editUserInfo.password = ""
editModel.value = "edit"
title.value = lang.editUser
userInfoDialog.value = true
}
const createUser = function(){
editUserInfo.account = ""
editUserInfo.name = ""
editUserInfo.disabled = false
editUserInfo.password = ""
editModel.value = "create"
title.value = lang.newUser
userInfoDialog.value = true
}
const submit = function () {
if (editModel.value == 'edit') {
let newData = {
"account": editUserInfo.account,
"username": editUserInfo.name,
"disabled": editUserInfo.disabled ? 1 : 0
}
if (editUserInfo.password != "") {
newData["password"] = editUserInfo.password
}
$http.post('/api/user/edit', newData).then(res => {
ElNotification({
title: res.errorNo == 0 ? lang.succ : lang.fail,
message: res.errorNo == 0 ? "" : res.data,
type: res.errorNo == 0 ? 'success' : 'error',
})
if (res.errorNo == 0) {
reflushList()
userInfoDialog.value = false
}
})
}else{
let newData = {
"account": editUserInfo.account,
"username": editUserInfo.name,
"disabled": editUserInfo.disabled ? 1 : 0,
"password":editUserInfo.password
}
$http.post('/api/user/create', newData).then(res => {
ElNotification({
title: res.errorNo == 0 ? lang.succ : lang.fail,
message: res.errorNo == 0 ? "" : res.data,
type: res.errorNo == 0 ? 'success' : 'error',
})
if (res.errorNo == 0) {
reflushList()
userInfoDialog.value = false
}
})
}
}
reflushList()
</script>
<style scoped>
#paginationBox {
margin-top: 10px;
display: flex;
justify-content: center;
}
</style>

View File

@ -1,101 +0,0 @@
// http/index.js
import axios from 'axios'
import router from "@/router"; //根路由对象
import lang from '../i18n/i18n';
//创建axios的一个实例
var $http = axios.create({
baseURL: import.meta.env.VITE_APP_URL, //接口统一域名
timeout: 60000, //设置超时
headers: {
'Content-Type': 'application/json;charset=UTF-8;',
'Lang': lang.lang
}
})
//请求拦截器
$http.interceptors.request.use((config) => {
//若请求方式为post则将data参数转为JSON字符串
if (config.method === 'POST') {
config.data = JSON.stringify(config.data);
}
return config;
}, (error) =>
// 对请求错误做些什么
Promise.reject(error));
//响应拦截器
$http.interceptors.response.use((response) => {
//响应成功
if (response.data.errorNo == 403) {
router.replace({
path: '/login',
query: {
redirect: router.currentRoute.fullPath
}
})
}
//响应成功
if (response.data.errorNo == 402) {
router.replace({
path: '/setup',
query: {
redirect: router.currentRoute.fullPath
}
})
}
return response.data;
}, (error) => {
//响应错误
if (error.response && error.response.status) {
const status = error.response.status
let message = ""
switch (status) {
case 400:
message = '请求错误';
break;
case 401:
message = '请求错误';
break;
case 403:
router.replace({
path: '/login',
query: {
redirect: router.currentRoute.fullPath
}
})
break;
case 404:
message = '请求地址出错';
break;
case 408:
message = '请求超时';
break;
case 500:
message = '服务器内部错误!';
break;
case 501:
message = '服务未实现!';
break;
case 502:
message = '网关错误!';
break;
case 503:
message = '服务不可用!';
break;
case 504:
message = '网关超时!';
break;
case 505:
message = 'HTTP版本不受支持';
break;
default:
message = '请求失败'
}
return Promise.reject(error);
}
return Promise.reject(error);
});
export default $http;

View File

@ -1,4 +1,12 @@
var lang = {
"logout": "Logout",
"resetPwd": "Reset the account password",
"disabled": "Disabled",
"enabled": "Enabled",
"newUser": "Create Account",
"editUser": "Edit Account",
"user_name": "User Name",
"user_management": "user management",
"lang": "en",
"submit": "submit",
"compose": "compose",
@ -55,6 +63,12 @@ var lang = {
"web_domain": "Web Domain",
"dns_desc": "Please add the following information to your DNS records",
"ssl_auto": "Automatically configure SSL certificates (recommended)",
"wait_desc":"HTTP challenge mode completes in approximately 1 minute and DNS API challenge mode completes in approximately 10 minutes.",
"ssl_challenge_type":"Challenge Type",
"ssl_auto_http":"Http Request",
"ssl_auto_dns":"DNS Records",
"challenge_typ_desc":"If PMail uses port 80 directly, it is recommended that you use the HTTP challenge method. If PMail does not use port 80 directly, it is recommended to use DNS challenge method, DNS API key to ask your domain name service provider to apply.",
"oomain_service_provider":"Domain Name Service Provider",
"ssl_manuallyf": "Manually configure an SSL certificate",
"ssl_key_path": "ssl key file path",
"ssl_crt_path": "ssl crt file path",
@ -92,11 +106,19 @@ var lang = {
var zhCN = {
"logout": "注销",
"resetPwd": "重置账号密码",
"disabled": "禁用",
"enabled": "启用",
"user_name": "用户名",
"newUser": "新增用户",
"editUser": "编辑用户",
"user_management": "用户管理",
"lang": "zhCn",
"submit": "提交",
"compose": "发件",
"new": "新",
"account": "用户名",
"account": "账号",
"password": "密码",
"login": "登录",
"search": "搜索邮件",
@ -148,7 +170,13 @@ var zhCN = {
"web_domain": "Web域名地址",
"dns_desc": "请将以下信息添加到DNS记录中",
"ssl_auto": "自动配置SSL证书(推荐)",
"oomain_service_provider":"域名服务商",
"ssl_auto_http":"HTTP请求",
"ssl_auto_dns":"DNS记录",
"ssl_challenge_type":"验证方式",
"ssl_manuallyf": "手动配置SSL证书",
"challenge_typ_desc":"如果PMail直接使用80端口建议使用HTTP验证方式。如果PMail没有直接使用80端口建议使用DNS验证方式DNS API Key找你的域名服务商申请",
"wait_desc":"HTTP验证模式大约1分钟完成DNS API验证模式大约15分钟完成。",
"ssl_key_path": "ssl key文件位置",
"ssl_crt_path": "ssl crt文件位置",
"group_settings": "分组",

View File

@ -3,14 +3,119 @@ import 'element-plus/dist/index.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import {ref} from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.config.globalProperties.$isLogin = ref(true)
app.config.globalProperties.$userInfos = ref({})
app.use(createPinia())
app.use(router)
import axios from 'axios'
import lang from './i18n/i18n';
//创建axios的一个实例
var $http = axios.create({
baseURL: import.meta.env.VITE_APP_URL, //接口统一域名
timeout: 60000, //设置超时
headers: {
'Content-Type': 'application/json;charset=UTF-8;',
'Lang': lang.lang
}
})
//请求拦截器
$http.interceptors.request.use((config) => {
//若请求方式为post则将data参数转为JSON字符串
if (config.method === 'POST') {
config.data = JSON.stringify(config.data);
}
return config;
}, (error) =>
// 对请求错误做些什么
Promise.reject(error));
//响应拦截器
$http.interceptors.response.use((response) => {
//响应成功
if (response.data.errorNo == 403) {
app.config.globalProperties.$isLogin.value = false
router.replace({
path: '/login',
query: {
redirect: router.currentRoute.fullPath
}
})
}
//响应成功
if (response.data.errorNo == 402) {
router.replace({
path: '/setup',
query: {
redirect: router.currentRoute.fullPath
}
})
}
return response.data;
}, (error) => {
//响应错误
if (error.response && error.response.status) {
const status = error.response.status
let message = ""
switch (status) {
case 400:
message = '请求错误';
break;
case 401:
message = '请求错误';
break;
case 403:
router.replace({
path: '/login',
query: {
redirect: router.currentRoute.fullPath
}
})
break;
case 404:
message = '请求地址出错';
break;
case 408:
message = '请求超时';
break;
case 500:
message = '服务器内部错误!';
break;
case 501:
message = '服务未实现!';
break;
case 502:
message = '网关错误!';
break;
case 503:
message = '服务不可用!';
break;
case 504:
message = '网关超时!';
break;
case 505:
message = 'HTTP版本不受支持';
break;
default:
message = '请求失败'
}
return Promise.reject(error);
}
return Promise.reject(error);
});
// 注册到全局
app.config.globalProperties.$http = $http
app.mount('#app')

View File

@ -42,4 +42,8 @@ const router = createRouter({
]
})
export default router

View File

@ -2,7 +2,7 @@
<div id="main">
<el-form label-width="100px" :rules="rules" ref="ruleFormRef" :model="ruleForm" status-icon>
<el-form-item :label="lang.sender" prop="sender">
<el-input v-model="ruleForm.sender" :placeholder="lang.sender_desc"></el-input>
<el-input :disabled="!$userInfos.is_admin" v-model="ruleForm.sender" :placeholder="lang.sender_desc"></el-input>
</el-form-item>
@ -76,8 +76,6 @@
<script setup>
import $http from '../http/http';
import '@wangeditor/editor/dist/css/style.css' // css
import { ElMessage } from 'element-plus'
import { onBeforeUnmount, ref, shallowRef, reactive, onMounted } from 'vue'
@ -85,10 +83,15 @@ import { Close } from '@element-plus/icons-vue';
import lang from '../i18n/i18n';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { i18nChangeLanguage } from '@wangeditor/editor'
import router from "@/router"; //
import { useRouter } from 'vue-router';
const router = useRouter();
import useGroupStore from '../stores/group'
const groupStore = useGroupStore()
import { getCurrentInstance } from 'vue'
const app = getCurrentInstance()
const $http = app.appContext.config.globalProperties.$http
const $isLogin = app.appContext.config.globalProperties.$isLogin
const $userInfos = app.appContext.config.globalProperties.$userInfos
if (lang.lang == "zhCn"){
i18nChangeLanguage('zh-CN')
@ -124,6 +127,24 @@ const ruleForm = reactive({
const fileList = reactive([]);
const init =function(){
if (Object.keys($userInfos.value).length == 0) {
$http.post("/api/user/info", {}).then(res => {
if (res.errorNo == 0) {
$userInfos.value = res.data
} else {
ElMessage({
type: 'error',
message: res.errorMsg,
})
}
})
}
}
init()
ruleForm.sender = $userInfos.value.account
const validateSender = function (rule, value, callback) {
if (typeof ruleForm.sender === "undefined" || ruleForm.sender === null || ruleForm.sender.trim() === "") {
callback(new Error(lang.err_sender_must))

View File

@ -35,15 +35,17 @@
</template>
<script setup>
import $http from "../http/http";
import { RouterLink } from 'vue-router'
import { reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import router from "@/router"; //
import { Document } from '@element-plus/icons-vue';
import lang from '../i18n/i18n';
import { getCurrentInstance } from 'vue'
const app = getCurrentInstance()
const $http = app.appContext.config.globalProperties.$http
const route = useRoute()
const detailData = ref({
attachments:[]

View File

@ -81,13 +81,22 @@
<script setup>
import $http from "../http/http";
import { ArrowDown } from '@element-plus/icons-vue'
import { RouterLink } from 'vue-router'
import { reactive, ref, watch } from 'vue'
import router from "@/router"; //
import useGroupStore from '../stores/group'
import lang from '../i18n/i18n';
import { useRouter } from 'vue-router';
import { getCurrentInstance } from 'vue'
const app = getCurrentInstance()
const $http = app.appContext.config.globalProperties.$http
const router = useRouter();
const groupStore = useGroupStore()

View File

@ -20,11 +20,14 @@
<script setup>
import { reactive } from 'vue'
import $http from "../http/http";
import { ElMessage } from 'element-plus'
import router from "@/router"; //
import lang from '../i18n/i18n';
import { getCurrentInstance } from 'vue'
const app = getCurrentInstance()
const $http = app.appContext.config.globalProperties.$http
const $isLogin = app.appContext.config.globalProperties.$isLogin
const $userInfos = app.appContext.config.globalProperties.$userInfos
const form = reactive({
account: '',
@ -36,6 +39,8 @@ const onSubmit = () => {
if (res.errorNo != 0) {
ElMessage.error(res.errorMsg)
} else {
$isLogin.value = true
$userInfos.value = res.data
router.replace({
path: '/',
query: {

View File

@ -28,7 +28,8 @@
<div class="form" style="width: 400px;">
<el-form label-width="120px">
<el-form-item :label="lang.type">
<el-select :placeholder="lang.db_select_ph" v-model="dbSettings.type" @change="dbSettings.dsn=''">
<el-select :placeholder="lang.db_select_ph" v-model="dbSettings.type"
@change="dbSettings.dsn = ''">
<el-option label="MySQL" value="mysql" />
<el-option label="SQLite3" value="sqlite" />
</el-select>
@ -121,9 +122,8 @@
</div>
</div>
<el-alert :closable="false" title="Warning!" type="error" center v-if="active == 5 && sslSettings.type == 0 && port != 80 "
:description="lang.autoSSLWarn"
/>
<el-alert :closable="false" title="Warning!" type="error" center
v-if="active == 5 && sslSettings.type == 0 && port != 80" :description="lang.autoSSLWarn" />
<div v-if="active == 5" class="ctn">
<div class="desc">
@ -139,6 +139,166 @@
</el-select>
</el-form-item>
<el-form-item :label="lang.ssl_challenge_type" v-if="sslSettings.type == '0'">
<el-select :placeholder="lang.ssl_auto_http" v-model="sslSettings.challenge">
<el-option :label="lang.ssl_auto_http" value="http" />
<el-option :label="lang.ssl_auto_dns" value="dns" />
</el-select>
<el-tooltip class="box-item" effect="dark" :content="lang.challenge_typ_desc"
placement="top-start">
<span style="margin-left: 6px; font-size:18px; font-weight: bolder;">?</span>
</el-tooltip>
</el-form-item>
<el-form-item :label="lang.oomain_service_provider"
v-if="sslSettings.type == '0' && sslSettings.challenge == 'dns'">
<el-select @change="provide_change" :placeholder="lang.oomain_service_provider"
v-model="sslSettings.provider">
<el-option label="acme-dns" value="acme-dns" />
<el-option label="alidns" value="alidns" />
<el-option label="allinkl" value="allinkl" />
<el-option label="arvancloud" value="arvancloud" />
<el-option label="azure" value="azure" />
<el-option label="azuredns" value="azuredns" />
<el-option label="auroradns" value="auroradns" />
<el-option label="autodns" value="autodns" />
<el-option label="bindman" value="bindman" />
<el-option label="bluecat" value="bluecat" />
<el-option label="brandit" value="brandit" />
<el-option label="bunny" value="bunny" />
<el-option label="checkdomain" value="checkdomain" />
<el-option label="civo" value="civo" />
<el-option label="clouddns" value="clouddns" />
<el-option label="cloudflare" value="cloudflare" />
<el-option label="cloudns" value="cloudns" />
<el-option label="cloudru" value="cloudru" />
<el-option label="cloudxns" value="cloudxns" />
<el-option label="conoha" value="conoha" />
<el-option label="constellix" value="constellix" />
<el-option label="cpanel" value="cpanel" />
<el-option label="derak" value="derak" />
<el-option label="desec" value="desec" />
<el-option label="designate" value="designate" />
<el-option label="digitalocean" value="digitalocean" />
<el-option label="dnshomede" value="dnshomede" />
<el-option label="dnsimple" value="dnsimple" />
<el-option label="dnsmadeeasy" value="dnsmadeeasy" />
<el-option label="dnspod" value="dnspod" />
<el-option label="dode" value="dode" />
<el-option label="domeneshop" value="domeneshop" />
<el-option label="domainnameshop" value="domainnameshop" />
<el-option label="dreamhost" value="dreamhost" />
<el-option label="duckdns" value="duckdns" />
<el-option label="dyn" value="dyn" />
<el-option label="dynu" value="dynu" />
<el-option label="easydns" value="easydns" />
<el-option label="edgedns" value="edgedns" />
<el-option label="fastdns" value="fastdns" />
<el-option label="efficientip" value="efficientip" />
<el-option label="epik" value="epik" />
<el-option label="exec" value="exec" />
<el-option label="exoscale" value="exoscale" />
<el-option label="freemyip" value="freemyip" />
<el-option label="gandi" value="gandi" />
<el-option label="gandiv5" value="gandiv5" />
<el-option label="gcloud" value="gcloud" />
<el-option label="gcore" value="gcore" />
<el-option label="glesys" value="glesys" />
<el-option label="godaddy" value="godaddy" />
<el-option label="googledomains" value="googledomains" />
<el-option label="hetzner" value="hetzner" />
<el-option label="hostingde" value="hostingde" />
<el-option label="hosttech" value="hosttech" />
<el-option label="httpreq" value="httpreq" />
<el-option label="hurricane" value="hurricane" />
<el-option label="hyperone" value="hyperone" />
<el-option label="ibmcloud" value="ibmcloud" />
<el-option label="iij" value="iij" />
<el-option label="iijdpf" value="iijdpf" />
<el-option label="infoblox" value="infoblox" />
<el-option label="infomaniak" value="infomaniak" />
<el-option label="internetbs" value="internetbs" />
<el-option label="inwx" value="inwx" />
<el-option label="ionos" value="ionos" />
<el-option label="ipv64" value="ipv64" />
<el-option label="iwantmyname" value="iwantmyname" />
<el-option label="joker" value="joker" />
<el-option label="liara" value="liara" />
<el-option label="lightsail" value="lightsail" />
<el-option label="linode" value="linode" />
<el-option label="linodev4" value="linodev4" />
<el-option label="liquidweb" value="liquidweb" />
<el-option label="loopia" value="loopia" />
<el-option label="luadns" value="luadns" />
<el-option label="mailinabox" value="mailinabox" />
<el-option label="manual" value="manual" />
<el-option label="metaname" value="metaname" />
<el-option label="mydnsjp" value="mydnsjp" />
<el-option label="mythicbeasts" value="mythicbeasts" />
<el-option label="namecheap" value="namecheap" />
<el-option label="namedotcom" value="namedotcom" />
<el-option label="namesilo" value="namesilo" />
<el-option label="nearlyfreespeech" value="nearlyfreespeech" />
<el-option label="netcup" value="netcup" />
<el-option label="netlify" value="netlify" />
<el-option label="nicmanager" value="nicmanager" />
<el-option label="nifcloud" value="nifcloud" />
<el-option label="njalla" value="njalla" />
<el-option label="nodion" value="nodion" />
<el-option label="ns1" value="ns1" />
<el-option label="oraclecloud" value="oraclecloud" />
<el-option label="otc" value="otc" />
<el-option label="ovh" value="ovh" />
<el-option label="pdns" value="pdns" />
<el-option label="plesk" value="plesk" />
<el-option label="porkbun" value="porkbun" />
<el-option label="rackspace" value="rackspace" />
<el-option label="rcodezero" value="rcodezero" />
<el-option label="regru" value="regru" />
<el-option label="rfc2136" value="rfc2136" />
<el-option label="rimuhosting" value="rimuhosting" />
<el-option label="route53" value="route53" />
<el-option label="safedns" value="safedns" />
<el-option label="sakuracloud" value="sakuracloud" />
<el-option label="scaleway" value="scaleway" />
<el-option label="selectel" value="selectel" />
<el-option label="servercow" value="servercow" />
<el-option label="shellrent" value="shellrent" />
<el-option label="simply" value="simply" />
<el-option label="sonic" value="sonic" />
<el-option label="stackpath" value="stackpath" />
<el-option label="tencentcloud" value="tencentcloud" />
<el-option label="transip" value="transip" />
<el-option label="ultradns" value="ultradns" />
<el-option label="variomedia" value="variomedia" />
<el-option label="vegadns" value="vegadns" />
<el-option label="vercel" value="vercel" />
<el-option label="versio" value="versio" />
<el-option label="vinyldns" value="vinyldns" />
<el-option label="vkcloud" value="vkcloud" />
<el-option label="vscale" value="vscale" />
<el-option label="vultr" value="vultr" />
<el-option label="webnames" value="webnames" />
<el-option label="websupport" value="websupport" />
<el-option label="wedos" value="wedos" />
<el-option label="yandex" value="yandex" />
<el-option label="yandex360" value="yandex360" />
<el-option label="yandexcloud" value="yandexcloud" />
<el-option label="zoneee" value="zoneee" />
<el-option label="zonomi" value="zonomi" />
</el-select>
</el-form-item>
<el-form-item :label="item"
v-if="sslSettings.paramsList.length != 0 && sslSettings.type == 0 && sslSettings.challenge == 'dns'"
v-for="item in sslSettings.paramsList">
<el-input style="width: 240px" :placeholder="item" v-model="dnsApiParams[item]" />
</el-form-item>
<el-form-item :label="lang.ssl_key_path" v-if="sslSettings.type == '1'">
<el-input placeholder="./config/ssl/private.key" v-model="sslSettings.key_path"></el-input>
</el-form-item>
@ -153,19 +313,21 @@
</div>
<el-button v-loading.fullscreen.lock="fullscreenLoading" id="next" style="margin-top: 12px" @click="next">{{
<el-button :element-loading-text="lang.wait_desc" v-loading.fullscreen.lock="fullscreenLoading" id="next" style="margin-top: 12px" @click="next">{{
lang.next }}</el-button>
</div>
</template>
<script setup>
import $http from "../http/http";
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import router from "@/router"; //
import lang from '../i18n/i18n';
import axios from 'axios'
import { getCurrentInstance } from 'vue'
const app = getCurrentInstance()
const $http = app.appContext.config.globalProperties.$http
const adminSettings = reactive({
"account": "admin",
@ -187,10 +349,15 @@ const domainSettings = reactive({
const sslSettings = reactive({
"type": "0",
"provider": "",
"challenge": "http",
"key_path": "./config/ssl/private.key",
"crt_path": "./config/ssl/public.crt"
"crt_path": "./config/ssl/public.crt",
"paramsList": {},
})
const dnsApiParams = reactive({})
const active = ref(0)
const fullscreenLoading = ref(false)
@ -288,6 +455,12 @@ const getSSLConfig = () => {
ElMessage.error(res.errorMsg)
} else {
sslSettings.type = res.data.type
if (sslSettings.type == "2"){
sslSettings.type = "0"
sslSettings.challenge="dns"
}
port.value = res.data.port
}
})
@ -296,20 +469,68 @@ const getSSLConfig = () => {
const setSSLConfig = () => {
fullscreenLoading.value = true;
$http.post("/api/setup", { "action": "set", "step": "ssl", "ssl_type": sslSettings.type, "key_path": sslSettings.key_path, "crt_path": sslSettings.crt_path }).then((res) => {
let sslType = sslSettings.type;
if (sslType == "0" && sslSettings.challenge == "dns") {
sslType = "2"
}
if (sslType == "2") {
let params = { "action": "setParams", "step": "ssl", };
params = Object.assign(params, dnsApiParams);
// dnsDNS api Key
$http.post("/api/setup", params).then((res) => {
if (res.errorNo != 0) {
fullscreenLoading.value = false;
ElMessage.error(res.errorMsg);
return;
}
})
}
$http.post("/api/setup", {
"action": "set",
"step": "ssl",
"ssl_type": sslType,
"key_path": sslSettings.key_path,
"crt_path": sslSettings.crt_path,
"serviceName": sslSettings.provider
}).then((res) => {
if (res.errorNo != 0) {
fullscreenLoading.value = false;
ElMessage.error(res.errorMsg)
} else {
setTimeout(function () {
window.location.href = "https://" + domainSettings.web_domain;
}, 10000);
checkStatus();
}
})
}
const checkStatus = () => {
axios.post("/api/ping", {}).then((res) => {
if (res.data.errorNo != 0) {
setTimeout(function () {
checkStatus()
}, 1000);
} else {
window.location.href = "https://" + domainSettings.web_domain;
}
}).catch((error) => {
setTimeout(function () {
checkStatus()
}, 1000);
})
}
const setDomainConfig = () => {
$http.post("/api/setup", { "action": "set", "step": "domain", "web_domain": domainSettings.web_domain, "smtp_domain": domainSettings.smtp_domain }).then((res) => {
if (res.errorNo != 0) {
@ -321,6 +542,19 @@ const setDomainConfig = () => {
})
}
const provide_change = () => {
console.log(sslSettings.provider)
$http.post("/api/setup", { "action": "getParams", "step": "ssl", "serverName": sslSettings.provider }).then((res) => {
if (res.errorNo != 0) {
ElMessage.error(res.errorMsg)
} else {
sslSettings.paramsList = res.data
}
})
}
const next = () => {
switch (active.value) {
@ -377,5 +611,6 @@ const next = () => {
flex-direction: column;
}
#next {}
</style>

View File

@ -13,7 +13,8 @@ type Config struct {
Domains []string `json:"domains"` //多域名设置,把所有收信域名都填进去
WebDomain string `json:"webDomain"`
DkimPrivateKeyPath string `json:"dkimPrivateKeyPath"`
SSLType string `json:"sslType"` // 0表示自动生成证书1表示用户上传证书
SSLType string `json:"sslType"` // 0表示自动生成证书HTTP挑战模式1表示用户上传证书2表示自动-DNS挑战模式
DomainServiceName string `json:"domainServerName"` // 域名服务商名称
SSLPrivateKeyPath string `json:"SSLPrivateKeyPath"`
SSLPublicKeyPath string `json:"SSLPublicKeyPath"`
DbDSN string `json:"dbDSN"`
@ -46,7 +47,8 @@ func (c *Config) SetSetupPort(setupPort int) {
const DBTypeMySQL = "mysql"
const DBTypeSQLite = "sqlite"
const SSLTypeAuto = "0" //自动生成证书
const SSLTypeAutoHTTP = "0" //自动生成证书
const SSLTypeAutoDNS = "2" //自动生成证书DNS api验证
const SSLTypeUser = "1" //用户上传证书
var DBTypes []string = []string{DBTypeMySQL, DBTypeSQLite}

View File

@ -4,6 +4,7 @@
"webDomain": "mail.domain.com",
"dkimPrivateKeyPath": "config/dkim/dkim.priv",
"sslType": "0",
"domainServerName": "",
"SSLPrivateKeyPath": "config/ssl/private.key",
"SSLPublicKeyPath": "config/ssl/public.crt",
"dbDSN": "./config/pmail.db",

20
server/consts/consts.go Normal file
View File

@ -0,0 +1,20 @@
package consts
const (
// EmailTypeSend 发信
EmailTypeSend int8 = 1
// EmailTypeReceive 收信
EmailTypeReceive int8 = 0
//EmailStatusWait 0未发送
EmailStatusWait int8 = 0
//EmailStatusSent 1已发送
EmailStatusSent int8 = 1
//EmailStatusFail 2发送失败
EmailStatusFail int8 = 2
//EmailStatusDel 3删除
EmailStatusDel int8 = 3
)

View File

@ -6,7 +6,6 @@ import (
"io"
"net/http"
"pmail/dto/response"
"pmail/services/auth"
"pmail/services/detail"
"pmail/utils/context"
)
@ -37,13 +36,6 @@ func EmailDetail(ctx *context.Context, w http.ResponseWriter, req *http.Request)
return
}
// 检查是否有权限
hasAuth := auth.HasAuth(ctx, email)
if !hasAuth {
response.NewErrorResponse(response.ParamsError, "", "").FPrint(w)
return
}
response.NewSuccessResponse(email).FPrint(w)
}

View File

@ -7,6 +7,7 @@ import (
"io"
"math"
"net/http"
"pmail/dto"
"pmail/dto/response"
"pmail/services/list"
"pmail/utils/context"
@ -61,7 +62,14 @@ func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
retData.PageSize = 15
}
emailList, total := list.GetEmailList(ctx, retData.Tag, retData.Keyword, offset, retData.PageSize)
var tagInfo dto.SearchTag = dto.SearchTag{
Type: -1,
Status: -1,
GroupId: -1,
}
_ = json.Unmarshal([]byte(retData.Tag), &tagInfo)
emailList, total := list.GetEmailList(ctx, tagInfo, retData.Keyword, false, offset, retData.PageSize)
for _, email := range emailList {
var sender User

View File

@ -64,6 +64,11 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
return
}
if !ctx.IsAdmin && reqData.From.Name != ctx.UserAccount {
response.NewErrorResponse(response.ParamsError, "params error", "").FPrint(w)
return
}
if reqData.From.Email == "" && reqData.From.Name != "" {
reqData.From.Email = reqData.From.Name + "@" + config.Instance.Domain
}

View File

@ -12,6 +12,7 @@ import (
"pmail/models"
"pmail/session"
"pmail/utils/context"
"pmail/utils/errors"
"pmail/utils/password"
)
@ -35,17 +36,25 @@ func Login(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var user models.User
encodePwd := password.Encode(reqData.Password)
_, err = db.Instance.Where("account =? and password =?", reqData.Account, encodePwd).Get(&user)
if err != nil && err != sql.ErrNoRows {
_, err = db.Instance.Where("account =? and password =? and disabled=0", reqData.Account, encodePwd).Get(&user)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Errorf("%+v", err)
}
if user.ID != 0 {
userStr, _ := json.Marshal(user)
session.Instance.Put(req.Context(), "user", string(userStr))
response.NewSuccessResponse("").FPrint(w)
response.NewSuccessResponse(map[string]any{
"account": user.Account,
"name": user.Name,
"is_admin": user.IsAdmin,
}).FPrint(w)
} else {
response.NewErrorResponse(response.ParamsError, i18n.GetText(ctx.Lang, "aperror"), "").FPrint(w)
}
}
func Logout(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
session.Instance.Clear(ctx.Context)
response.NewSuccessResponse("Success").FPrint(w)
}

View File

@ -1,13 +1,10 @@
package controllers
import (
log "github.com/sirupsen/logrus"
"net/http"
"pmail/dto/response"
"pmail/utils/context"
)
func Ping(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
func Ping(w http.ResponseWriter, req *http.Request) {
response.NewSuccessResponse("pong").FPrint(w)
log.WithContext(ctx).Info("pong")
}

View File

@ -18,7 +18,7 @@ import (
)
func GetRule(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
res := rule.GetAllRules(ctx)
res := rule.GetAllRules(ctx, ctx.UserID)
response.NewSuccessResponse(res).FPrint(w)
}

View File

@ -134,14 +134,39 @@ func Setup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
return
}
if reqData["step"] == "ssl" && reqData["action"] == "getParams" {
params, err := ssl.GetServerParamsList(reqData["serverName"])
if err != nil {
response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
return
}
response.NewSuccessResponse(params).FPrint(w)
return
}
if reqData["step"] == "ssl" && reqData["action"] == "setParams" {
for key, v := range reqData {
if key != "step" && key != "action" {
ssl.SetDomainServerParams(key, v)
}
}
response.NewSuccessResponse("Succ").FPrint(w)
return
}
if reqData["step"] == "ssl" && reqData["action"] == "set" {
err := ssl.SetSSL(reqData["ssl_type"], reqData["key_path"], reqData["crt_path"])
serviceName, ok := reqData["serviceName"]
if !ok {
serviceName = ""
}
err := ssl.SetSSL(reqData["ssl_type"], reqData["key_path"], reqData["crt_path"], serviceName)
if err != nil {
response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
return
}
if reqData["ssl_type"] == config.SSLTypeAuto {
if reqData["ssl_type"] == config.SSLTypeAutoHTTP || reqData["ssl_type"] == config.SSLTypeAutoDNS {
err = ssl.GenSSL(false)
if err != nil {
response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
@ -150,7 +175,10 @@ func Setup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
}
response.NewSuccessResponse("Succ").FPrint(w)
setup.Finish(ctx)
if reqData["ssl_type"] == config.SSLTypeUser {
setup.Finish()
}
return
}

174
server/controllers/user.go Normal file
View File

@ -0,0 +1,174 @@
package controllers
import (
"encoding/json"
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
"io"
"math"
"net/http"
"pmail/db"
"pmail/dto/response"
"pmail/models"
"pmail/utils/context"
"pmail/utils/password"
)
type userCreateRequest struct {
Id int `json:"id"`
Account string `json:"account"`
Username string `json:"username"`
Password string `json:"password"`
Disabled int `json:"disabled"`
}
func CreateUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if !ctx.IsAdmin {
response.NewErrorResponse(response.NoAccessPrivileges, "No Access Privileges", "").FPrint(w)
return
}
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
log.Errorf("%+v", err)
}
var reqData userCreateRequest
err = json.Unmarshal(reqBytes, &reqData)
if err != nil {
log.Errorf("%+v", err)
}
if reqData.Username == "" || reqData.Password == "" || reqData.Account == "" {
response.NewErrorResponse(response.ParamsError, "Params Error", "").FPrint(w)
return
}
var user models.User
user.Name = reqData.Username
user.Password = password.Encode(reqData.Password)
user.Account = reqData.Account
_, err = db.Instance.Insert(&user)
if err != nil {
response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
return
}
response.NewSuccessResponse(user).FPrint(w)
}
type userListRequest struct {
CurrentPage int `json:"current_page"`
PageSize int `json:"page_size"`
}
func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if !ctx.IsAdmin {
response.NewErrorResponse(response.NoAccessPrivileges, "No Access Privileges", "").FPrint(w)
return
}
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
log.Errorf("%+v", err)
}
var reqData userListRequest
err = json.Unmarshal(reqBytes, &reqData)
if err != nil {
log.Errorf("%+v", err)
}
offset := 0
if reqData.CurrentPage >= 1 {
offset = (reqData.CurrentPage - 1) * reqData.PageSize
}
if reqData.PageSize == 0 {
reqData.PageSize = 15
}
var users []models.User
totalNum, err := db.Instance.Table(&models.User{}).Limit(reqData.PageSize, offset).FindAndCount(&users)
if err != nil {
log.Errorf("%+v", err)
}
response.NewSuccessResponse(map[string]any{
"current_page": reqData.CurrentPage,
"total_page": cast.ToInt(math.Ceil(cast.ToFloat64(totalNum) / cast.ToFloat64(reqData.PageSize))),
"list": users,
}).FPrint(w)
}
func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
response.NewSuccessResponse(map[string]any{
"account": ctx.UserAccount,
"name": ctx.UserName,
"is_admin": ctx.IsAdmin,
}).FPrint(w)
}
func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
if !ctx.IsAdmin {
response.NewErrorResponse(response.NoAccessPrivileges, "No Access Privileges", "").FPrint(w)
return
}
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
log.Errorf("%+v", err)
}
var reqData userCreateRequest
err = json.Unmarshal(reqBytes, &reqData)
if err != nil {
log.Errorf("%+v", err)
}
if reqData.Id == 0 && reqData.Account == "" {
response.NewErrorResponse(response.ParamsError, "Params Error", "").FPrint(w)
return
}
var user models.User
if reqData.Id != 0 {
_, err = db.Instance.Where("id=?", reqData.Id).Get(&user)
if err != nil {
log.Errorf("SQL Error: %+v", err)
}
} else {
_, err = db.Instance.Where("account=?", reqData.Account).Get(&user)
if err != nil {
log.Errorf("SQL Error: %+v", err)
}
}
if user.ID == 0 {
response.NewErrorResponse(response.ParamsError, "User not found", "").FPrint(w)
return
}
if reqData.Username != "" && reqData.Username != user.Name {
user.Name = reqData.Username
}
if reqData.Disabled != user.Disabled {
user.Disabled = reqData.Disabled
}
if reqData.Password != "" {
user.Password = password.Encode(reqData.Password)
}
num, err := db.Instance.ID(user.ID).Cols("name", "password", "disabled").Update(&user)
if err != nil {
response.NewErrorResponse(response.ServerError, err.Error(), "").FPrint(w)
return
}
if num == 0 {
response.NewErrorResponse(response.ServerError, "No Data Update", "").FPrint(w)
return
}
response.NewSuccessResponse(user).FPrint(w)
}

View File

@ -3,6 +3,7 @@ package db
import (
"fmt"
_ "github.com/go-sql-driver/mysql"
log "github.com/sirupsen/logrus"
_ "modernc.org/sqlite"
"pmail/config"
"pmail/models"
@ -13,27 +14,45 @@ import (
var Instance *xorm.Engine
func Init() error {
func Init(version string) error {
dsn := config.Instance.DbDSN
var err error
switch config.Instance.DbType {
case "mysql":
Instance, err = xorm.NewEngine("mysql", dsn)
Instance.SetMaxOpenConns(100)
Instance.SetMaxIdleConns(10)
case "sqlite":
Instance, err = xorm.NewEngine("sqlite", dsn)
Instance.SetMaxOpenConns(1)
Instance.SetMaxIdleConns(1)
default:
return errors.New("Database Type Error!")
}
if err != nil {
return errors.Wrap(err)
}
Instance.SetMaxOpenConns(100)
Instance.SetMaxIdleConns(10)
Instance.ShowSQL(false)
// 同步表结构
syncTables()
// 更新历史数据
fixHistoryData()
// 在数据库中记录程序版本
var v models.Version
_, err = Instance.Get(&v)
if err != nil {
panic(err)
}
if version != "" && v.Info != version {
v.Info = version
Instance.Update(&v)
}
return nil
}
@ -62,13 +81,75 @@ func syncTables() {
if err != nil {
panic(err)
}
err = Instance.Sync2(&models.UserAuth{})
if err != nil {
panic(err)
}
err = Instance.Sync2(&models.Sessions{})
if err != nil {
panic(err)
}
err = Instance.Sync2(&models.UserEmail{})
if err != nil {
panic(err)
}
err = Instance.Sync2(&models.Version{})
if err != nil {
panic(err)
}
}
func fixHistoryData() {
var ueNum int
_, err := Instance.Table(&models.UserEmail{}).Select("count(1)").Get(&ueNum)
if err != nil {
panic(err)
}
if ueNum > 0 {
return
}
// 只有一个管理员用户
var user []models.User
err = Instance.Table(&models.User{}).OrderBy("id asc").Find(&user)
if err != nil {
panic(err)
}
// 只有一个账号,且不是管理员账号,将账号提权为管理员
if len(user) == 1 && user[0].IsAdmin == 0 {
u := user[0]
u.IsAdmin = 1
_, err = Instance.Update(&u)
if err != nil {
panic(err)
}
}
if len(user) != 1 {
return
}
// 以前有邮件
var emails []*models.Email
err = Instance.Table(&models.Email{}).Select("id,status").OrderBy("id asc").Find(&emails)
if err != nil {
panic(err)
}
if len(emails) == 0 {
return
}
log.Infof("Sync History DataPlease Wait")
// 把以前的邮件,全部分到管理员账号下面去
for _, email := range emails {
ue := models.UserEmail{
UserID: user[0].ID,
EmailID: email.Id,
Status: email.Status,
}
_, err = Instance.Insert(&ue)
if err != nil {
log.Errorf("SQL Error: %v", err)
}
}
log.Infof("Sync History Data Finished. Num: %d", len(emails))
}

View File

@ -52,9 +52,7 @@ type Email struct {
Attachments []*Attachment
ReadReceipt []string
Date string
IsRead int
Status int // 0未发送1已发送2发送失败3删除
GroupId int // 分组id
MessageId int64
}

View File

@ -0,0 +1,8 @@
package response
import "pmail/models"
type EmailResponseData struct {
models.Email `xorm:"extends"`
IsRead int8 `json:"is_read"`
}

View File

@ -8,6 +8,7 @@ import (
const (
NeedSetup = 402
NeedLogin = 403
NoAccessPrivileges = 405
ParamsError = 100
ServerError = 500
)

View File

@ -17,6 +17,7 @@ var (
type Rule struct {
Id int `json:"id"`
UserId int `json:"user_id"`
Name string `json:"name"`
Rules []*Value `json:"rules"`
Action RuleType `json:"action"`
@ -37,6 +38,7 @@ func (p *Rule) Decode(data *models.Rule) *Rule {
p.Action = RuleType(data.Action)
p.Sort = data.Sort
p.Params = data.Params
p.UserId = data.UserId
return p
}

View File

@ -3,8 +3,8 @@ package dto
import "encoding/json"
type SearchTag struct {
Type int `json:"type"` // -1 不限
Status int `json:"status"` // -1 不限
Type int8 `json:"type"` // -1 不限
Status int8 `json:"status"` // -1 不限
GroupId int `json:"group_id"` // -1 不限
}

View File

@ -1,6 +1,6 @@
module pmail
go 1.22
go 1.22.0
require (
github.com/Jinnrry/gopop v0.0.0-20231113115125-fbdf52ae39ea
@ -10,45 +10,255 @@ require (
github.com/emersion/go-message v0.18.1
github.com/emersion/go-msgauth v0.6.8
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43
github.com/emersion/go-smtp v0.21.0
github.com/go-acme/lego/v4 v4.16.1
github.com/emersion/go-smtp v0.21.2
github.com/go-acme/lego/v4 v4.17.3
github.com/go-sql-driver/mysql v1.8.1
github.com/mileusna/spf v0.9.5
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cast v1.6.0
golang.org/x/crypto v0.22.0
golang.org/x/text v0.14.0
modernc.org/sqlite v1.29.6
golang.org/x/crypto v0.24.0
golang.org/x/text v0.16.0
modernc.org/sqlite v1.30.0
xorm.io/builder v0.3.13
xorm.io/xorm v1.3.9
)
require (
cloud.google.com/go/auth v0.5.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
cloud.google.com/go/compute/metadata v0.3.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest v0.11.29 // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect
github.com/CloudyKit/jet/v6 v6.2.0 // indirect
github.com/Joker/jade v1.1.3 // indirect
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.62.758 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.27.1 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.17 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.10 // indirect
github.com/aws/aws-sdk-go-v2/service/lightsail v1.38.2 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.40.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.20.10 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.28.11 // indirect
github.com/aws/smithy-go v1.20.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/boombuler/barcode v1.0.1 // indirect
github.com/bytedance/sonic v1.11.8 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/civo/civogo v0.3.70 // indirect
github.com/cloudflare/cloudflare-go v0.97.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/cpu/goacmedns v0.1.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deepmap/oapi-codegen v1.16.3 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/dnsimple/dnsimple-go v1.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/exoscale/egoscale v0.102.3 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flosch/pongo2/v4 v4.0.2 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.10.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.21.0 // indirect
github.com/go-resty/resty/v2 v2.13.1 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gomarkdown/markdown v0.0.0-20240419095408-642f0ee99ae2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.4 // indirect
github.com/gophercloud/gophercloud v1.12.0 // indirect
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect
github.com/iris-contrib/schema v0.0.6 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
github.com/kataras/blocks v0.0.8 // indirect
github.com/kataras/golog v0.1.12 // indirect
github.com/kataras/iris/v12 v12.2.11 // indirect
github.com/kataras/pio v0.0.13 // indirect
github.com/kataras/sitemap v0.0.6 // indirect
github.com/kataras/tunnel v0.0.4 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect
github.com/labbsr0x/goh v1.0.1 // indirect
github.com/labstack/echo/v4 v4.12.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/linode/linodego v1.35.0 // indirect
github.com/liquidweb/liquidweb-cli v0.7.0 // indirect
github.com/liquidweb/liquidweb-go v1.6.4 // indirect
github.com/mailgun/raymond/v2 v2.0.48 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.58 // indirect
github.com/microcosm-cc/bluemonday v1.0.26 // indirect
github.com/miekg/dns v1.1.59 // indirect
github.com/mimuret/golang-iij-dpf v0.9.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/nrdcg/auroradns v1.1.0 // indirect
github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 // indirect
github.com/nrdcg/desec v0.8.0 // indirect
github.com/nrdcg/dnspod-go v0.4.0 // indirect
github.com/nrdcg/freemyip v0.2.0 // indirect
github.com/nrdcg/goinwx v0.10.0 // indirect
github.com/nrdcg/mailinabox v0.2.0 // indirect
github.com/nrdcg/namesilo v0.2.1 // indirect
github.com/nrdcg/nodion v0.1.0 // indirect
github.com/nrdcg/porkbun v0.3.0 // indirect
github.com/nzdjb/go-metaname v1.0.0 // indirect
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/oracle/oci-go-sdk/v65 v65.67.0 // indirect
github.com/ovh/go-ovh v1.5.1 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pquerna/otp v1.4.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sacloud/api-client-go v0.2.10 // indirect
github.com/sacloud/go-http v0.1.8 // indirect
github.com/sacloud/iaas-api-go v1.12.0 // indirect
github.com/sacloud/packages-go v0.0.10 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.27 // indirect
github.com/schollz/closestmatch v2.1.0+incompatible // indirect
github.com/selectel/domains-go v1.1.0 // indirect
github.com/selectel/go-selvpcclient/v3 v3.1.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
github.com/softlayer/softlayer-go v1.1.5 // indirect
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.24.0 // indirect
github.com/tdewolff/minify/v2 v2.20.32 // indirect
github.com/tdewolff/parse/v2 v2.7.14 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.938 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.938 // indirect
github.com/transip/gotransip/v6 v6.24.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ultradns/ultradns-go-sdk v1.6.2-20240501171831-432d643 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/vinyldns/go-vinyldns v0.9.16 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/vultr/govultr/v2 v2.17.2 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20240529120826-df2b24336f42 // indirect
github.com/yandex-cloud/go-sdk v0.0.0-20240529122015-8b0dc5b8bcbf // indirect
github.com/yosssi/ace v0.0.5 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect
go.opentelemetry.io/otel v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.27.0 // indirect
go.opentelemetry.io/otel/trace v1.27.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/tools v0.20.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.22.0 // indirect
google.golang.org/api v0.183.0 // indirect
google.golang.org/genproto v0.0.0-20240604185151-ef581f913117 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect
google.golang.org/grpc v1.64.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/ns1/ns1-go.v2 v2.11.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.30.1 // indirect
k8s.io/apimachinery v0.30.1 // indirect
k8s.io/klog/v2 v2.120.1 // indirect
k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.49.3 // indirect
modernc.org/libc v1.52.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@ import (
"path/filepath"
"pmail/dto/parsemail"
"pmail/hooks/framework"
"pmail/models"
"pmail/utils/context"
"strings"
"time"
@ -26,12 +27,13 @@ type HookSender struct {
socket string
}
func (h *HookSender) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email) {
func (h *HookSender) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) {
log.WithContext(ctx).Debugf("[%s]Plugin ReceiveSaveAfter Start", h.name)
dto := framework.HookDTO{
Ctx: ctx,
Email: email,
UserEmail: ue,
}
body, _ := json.Marshal(dto)

View File

@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"pmail/dto/parsemail"
"pmail/models"
"pmail/utils/context"
"time"
)
@ -25,7 +26,7 @@ type EmailHook interface {
// ReceiveParseAfter 接收到邮件,解析之后的结构化数据 (收信规则前,写数据库前执行) 同步执行
ReceiveParseAfter(ctx *context.Context, email *parsemail.Email)
// ReceiveSaveAfter 邮件落库以后执行(收信规则后执行) 异步执行
ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email)
ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail)
}
// HookDTO PMail 主程序和插件通信的结构体
@ -35,6 +36,7 @@ type HookDTO struct {
Email *parsemail.Email // 邮件内容
EmailByte *[]byte // 未解析前的文件内容
ErrMap map[string]error // 错误信息
UserEmail []*models.UserEmail
}
type Plugin struct {
@ -153,7 +155,7 @@ func (p *Plugin) Run() {
log.Errorf("params error %+v", err)
return
}
p.hook.ReceiveSaveAfter(hookDTO.Ctx, hookDTO.Email)
p.hook.ReceiveSaveAfter(hookDTO.Ctx, hookDTO.Email, hookDTO.UserEmail)
body, _ = json.Marshal(hookDTO)
writer.Write(body)
log.Debugf("[%s] ReceiveSaveAfter End", p.name)

View File

@ -3,15 +3,15 @@ package main
import (
"encoding/json"
"fmt"
log "github.com/sirupsen/logrus"
"net/http"
"os"
"pmail/config"
"pmail/dto/parsemail"
"pmail/hooks/framework"
"pmail/models"
"pmail/utils/context"
"strings"
log "github.com/sirupsen/logrus"
)
type TelegramPushHook struct {
@ -21,15 +21,18 @@ type TelegramPushHook struct {
webDomain string
}
func (w *TelegramPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email) {
func (w *TelegramPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) {
if w.chatId == "" || w.botToken == "" {
return
}
// 被标记为已读,或者是已删除,或是垃圾邮件 就不处理了
if email.IsRead == 1 || email.Status == 3 || email.MessageId <= 0 {
return
}
for _, u := range ue {
// 管理员Uid=1收到邮件且非已读、非已删除 触发通知
if u.UserID == 1 && u.IsRead == 0 && u.Status != 3 && email.MessageId > 0 {
w.sendUserMsg(nil, email)
}
}
}
func (w *TelegramPushHook) SendBefore(ctx *context.Context, email *parsemail.Email) {

View File

@ -9,6 +9,7 @@ import (
"pmail/config"
"pmail/dto/parsemail"
"pmail/hooks/framework"
"pmail/models"
"pmail/utils/context"
)
@ -17,7 +18,7 @@ type WebPushHook struct {
token string
}
func (w *WebPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email) {
func (w *WebPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) {
if w.url == "" {
return
}
@ -77,7 +78,8 @@ func (w *WebPushHook) ReceiveParseBefore(ctx *context.Context, email *[]byte) {
}
func (w *WebPushHook) ReceiveParseAfter(ctx *context.Context, email *parsemail.Email) {}
func (w *WebPushHook) ReceiveParseAfter(ctx *context.Context, email *parsemail.Email) {
}
type Config struct {
WebPushUrl string `json:"webPushUrl"`

View File

@ -1,10 +1,14 @@
## How To Ues
## How To Ues / 如何使用
Copy plugin binary file to `/plugins`
复制插件二进制文件到`/plugins`文件夹
add config.json to `/plugins/config.com` like this:
新建配置文件`/plugins/config.com`,内容如下
```json
{
"weChatPushAppId": "", // wechat appid
@ -13,3 +17,15 @@ add config.json to `/plugins/config.com` like this:
"weChatPushUserId": "", // weChat UserId
}
```
WeChat Message Template :
微信推送模板设置:
Template Title: New Email Notice
模板标题:新邮件提醒
Template Content: {{Content.DATA}}
模板内容:{{Content.DATA}}

View File

@ -11,6 +11,7 @@ import (
"pmail/config"
"pmail/dto/parsemail"
"pmail/hooks/framework"
"pmail/models"
"pmail/utils/context"
"strings"
"time"
@ -31,23 +32,20 @@ type WeChatPushHook struct {
mainConfig *config.Config
}
func (w *WeChatPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email) {
func (w *WeChatPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) {
if w.appId == "" || w.secret == "" || w.pushUser == "" {
return
}
// 被标记为已读,或者是已删除,或是垃圾邮件 就不处理了
if email.IsRead == 1 || email.Status == 3 || email.MessageId <= 0 {
return
}
content := string(email.Text)
if content == "" {
content = email.Subject
}
for _, u := range ue {
// 管理员Uid=1收到邮件且非已读、非已删除 触发通知
if u.UserID == 1 && u.IsRead == 0 && u.Status != 3 && email.MessageId > 0 {
content := "<<" + email.Subject + ">> " + string(email.Text)
w.sendUserMsg(nil, w.pushUser, content)
}
}
}
func (w *WeChatPushHook) SendBefore(ctx *context.Context, email *parsemail.Email) {
@ -132,7 +130,7 @@ func NewWechatPushHook() *WeChatPushHook {
var cfgData []byte
var err error
cfgData, err = os.ReadFile("../../config/config.json")
cfgData, err = os.ReadFile("./config/config.json")
if err != nil {
panic(err)
}

View File

@ -22,23 +22,7 @@ func HttpStop() {
}
}
func HttpStart() {
mux := http.NewServeMux()
HttpPort := 80
if config.Instance.HttpPort > 0 {
HttpPort = config.Instance.HttpPort
}
if config.Instance.HttpsEnabled != 2 {
mux.HandleFunc("/", controllers.Interceptor)
httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", HttpPort),
Handler: mux,
ReadTimeout: time.Second * 90,
WriteTimeout: time.Second * 90,
}
} else {
func router(mux *http.ServeMux) {
fe, err := fs.Sub(local, "dist")
if err != nil {
panic(err)
@ -46,8 +30,9 @@ func HttpStart() {
mux.Handle("/", http.FileServer(http.FS(fe)))
// 挑战请求类似这样 /.well-known/acme-challenge/QPyMAyaWw9s5JvV1oruyqWHG7OqkHMJEHPoUz2046KM
mux.HandleFunc("/.well-known/", controllers.AcmeChallenge)
mux.HandleFunc("/api/ping", contextIterceptor(controllers.Ping))
mux.HandleFunc("/api/ping", controllers.Ping)
mux.HandleFunc("/api/login", contextIterceptor(controllers.Login))
mux.HandleFunc("/api/logout", contextIterceptor(controllers.Logout))
mux.HandleFunc("/api/group", contextIterceptor(controllers.GetUserGroup))
mux.HandleFunc("/api/group/list", contextIterceptor(controllers.GetUserGroupList))
mux.HandleFunc("/api/group/add", contextIterceptor(controllers.AddGroup))
@ -65,6 +50,33 @@ func HttpStart() {
mux.HandleFunc("/api/rule/del", contextIterceptor(controllers.DelRule))
mux.HandleFunc("/attachments/", contextIterceptor(controllers.GetAttachments))
mux.HandleFunc("/attachments/download/", contextIterceptor(controllers.Download))
mux.HandleFunc("/api/user/create", contextIterceptor(controllers.CreateUser))
mux.HandleFunc("/api/user/edit", contextIterceptor(controllers.EditUser))
mux.HandleFunc("/api/user/info", contextIterceptor(controllers.Info))
mux.HandleFunc("/api/user/list", contextIterceptor(controllers.UserList))
}
func HttpStart() {
mux := http.NewServeMux()
HttpPort := 80
if config.Instance.HttpPort > 0 {
HttpPort = config.Instance.HttpPort
}
if config.Instance.HttpsEnabled != 2 {
mux.HandleFunc("/api/ping", controllers.Ping)
mux.HandleFunc("/", controllers.Interceptor)
httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", HttpPort),
Handler: mux,
ReadTimeout: time.Second * 90,
WriteTimeout: time.Second * 90,
}
} else {
router(mux)
log.Infof("HttpServer Start On Port :%d", HttpPort)
httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", HttpPort),

View File

@ -4,12 +4,10 @@ import (
"embed"
"encoding/json"
"fmt"
"io/fs"
olog "log"
"net/http"
"pmail/config"
"pmail/controllers"
"pmail/controllers/email"
"pmail/dto/response"
"pmail/i18n"
"pmail/models"
@ -38,32 +36,7 @@ func HttpsStart() {
mux := http.NewServeMux()
fe, err := fs.Sub(local, "dist")
if err != nil {
panic(err)
}
mux.Handle("/", http.FileServer(http.FS(fe)))
// 挑战请求类似这样 /.well-known/acme-challenge/QPyMAyaWw9s5JvV1oruyqWHG7OqkHMJEHPoUz2046KM
mux.HandleFunc("/.well-known/", controllers.AcmeChallenge)
mux.HandleFunc("/api/ping", contextIterceptor(controllers.Ping))
mux.HandleFunc("/api/login", contextIterceptor(controllers.Login))
mux.HandleFunc("/api/group", contextIterceptor(controllers.GetUserGroup))
mux.HandleFunc("/api/group/list", contextIterceptor(controllers.GetUserGroupList))
mux.HandleFunc("/api/group/add", contextIterceptor(controllers.AddGroup))
mux.HandleFunc("/api/group/del", contextIterceptor(controllers.DelGroup))
mux.HandleFunc("/api/email/list", contextIterceptor(email.EmailList))
mux.HandleFunc("/api/email/read", contextIterceptor(email.MarkRead))
mux.HandleFunc("/api/email/del", contextIterceptor(email.EmailDelete))
mux.HandleFunc("/api/email/detail", contextIterceptor(email.EmailDetail))
mux.HandleFunc("/api/email/send", contextIterceptor(email.Send))
mux.HandleFunc("/api/email/move", contextIterceptor(email.Move))
mux.HandleFunc("/api/settings/modify_password", contextIterceptor(controllers.ModifyPassword))
mux.HandleFunc("/api/rule/get", contextIterceptor(controllers.GetRule))
mux.HandleFunc("/api/rule/add", contextIterceptor(controllers.UpsertRule))
mux.HandleFunc("/api/rule/update", contextIterceptor(controllers.UpsertRule))
mux.HandleFunc("/api/rule/del", contextIterceptor(controllers.DelRule))
mux.HandleFunc("/attachments/", contextIterceptor(controllers.GetAttachments))
mux.HandleFunc("/attachments/download/", contextIterceptor(controllers.Download))
router(mux)
// go http server会打一堆没用的日志写一个空的日志处理器屏蔽掉日志输出
nullLog := olog.New(&nullWrite{}, "", olog.Ldate)
@ -82,7 +55,7 @@ func HttpsStart() {
WriteTimeout: time.Second * 90,
ErrorLog: nullLog,
}
err = httpsServer.ListenAndServeTLS("config/ssl/public.crt", "config/ssl/private.key")
err := httpsServer.ListenAndServeTLS("config/ssl/public.crt", "config/ssl/private.key")
if err != nil {
panic(err)
}
@ -121,6 +94,7 @@ func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc {
ctx.UserID = userInfo.ID
ctx.UserName = userInfo.Name
ctx.UserAccount = userInfo.Account
ctx.IsAdmin = userInfo.IsAdmin == 1
}
if ctx.UserID == 0 {

View File

@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"flag"
"fmt"
"github.com/spf13/cast"
"io"
@ -14,6 +15,7 @@ import (
"pmail/models"
"pmail/services/setup"
"pmail/signal"
"pmail/utils/array"
"strconv"
"strings"
"testing"
@ -32,7 +34,7 @@ func TestMain(m *testing.M) {
panic(err)
}
httpClient = &http.Client{Jar: cookeieJar, Timeout: 5 * time.Second}
httpClient = &http.Client{Jar: cookeieJar, Timeout: 5 * time.Minute}
os.Remove("config/config.json")
os.Remove("config/pmail_temp.db")
go func() {
@ -63,12 +65,201 @@ func TestMaster(t *testing.T) {
t.Fatal(err)
}
t.Run("testSSLSet", testSSLSet)
time.Sleep(3 * time.Second)
t.Run("testLogin", testLogin)
time.Sleep(8 * time.Second)
t.Run("testLogin", testLogin) // 登录管理员账号
t.Run("testCreateUser", testCreateUser) // 创建3个测试用户
t.Run("testEditUser", testEditUser) // 编辑user2封禁user3
t.Run("testSendEmail", testSendEmail)
time.Sleep(3 * time.Second)
time.Sleep(8 * time.Second)
t.Run("testEmailList", testEmailList)
t.Run("testDelEmail", testDelEmail)
t.Run("testSendEmail2User1", testSendEmail2User1)
t.Run("testSendEmail2User2", testSendEmail2User2)
t.Run("testSendEmail2User3", testSendEmail2User3)
time.Sleep(8 * time.Second)
t.Run("testLoginUser3", testLoginUser3) // 测试登录被封禁账号
t.Run("testLoginUser2", testLoginUser2) // 测试登录普通账号
t.Run("testUser2EmailList", testUser2EmailList)
// 创建group
t.Run("testCreateGroup", testCreateGroup)
// 创建rule
t.Run("testCreateRule", testCreateRule)
// 再次发邮件
t.Run("testMoverEmailSend", testSendEmail2User2ForMove)
time.Sleep(3 * time.Second)
// 检查规则执行
t.Run("testCheckRule", testCheckRule)
time.Sleep(3 * time.Second)
}
func testCheckRule(t *testing.T) {
var ue models.UserEmail
db.Instance.Where("group_id!=0").Get(&ue)
if ue.GroupId == 0 {
t.Error("邮件规则执行失败!")
}
}
func testCreateRule(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/rule/add", "application/json", strings.NewReader(`{
"name":"Move Group",
"rules":[{"field":"Subject","type":"contains","rule":"Move"}],
"action":4,
"params":"1",
"sort":1
}`))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("CreateRule Api Error!", data)
}
ret, err = httpClient.Post(TestHost+"/api/rule/get", "application/json", strings.NewReader(`{}`))
if err != nil {
t.Error(err)
}
data, err = readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("CreateRule Api Error!", data)
}
dt := data.Data.([]any)
if len(dt) != 1 {
t.Error("Rule List Is Empty!")
}
}
func testCreateGroup(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/group/add", "application/json", strings.NewReader(`{
"name":"TestGroup"
}`))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("CreateGroup Api Error!", data)
}
ret, err = httpClient.Post(TestHost+"/api/group/list", "application/json", strings.NewReader(`{}`))
if err != nil {
t.Error(err)
}
data, err = readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("CreateGroup Api Error!", data)
}
dt := data.Data.([]any)
if len(dt) != 1 {
t.Error("Group List Is Empty!")
}
}
func testEditUser(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/user/edit", "application/json", strings.NewReader(`{
"account":"user2",
"username":"user2New",
"password":"user2New"
}`))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Edit User Api Error!", data)
}
ret, err = httpClient.Post(TestHost+"/api/user/edit", "application/json", strings.NewReader(`{
"account":"user3",
"disabled": 1
}`))
if err != nil {
t.Error(err)
}
data, err = readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Edit User Api Error!", data)
}
}
func testCreateUser(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/user/create", "application/json", strings.NewReader(`{
"account":"user1",
"username":"user1",
"password":"user1"
}`))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Create User Api Error!")
}
ret, err = httpClient.Post(TestHost+"/api/user/create", "application/json", strings.NewReader(`{
"account":"user2",
"username":"user2",
"password":"user2"
}`))
if err != nil {
t.Error(err)
}
data, err = readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Create User Api Error!")
}
ret, err = httpClient.Post(TestHost+"/api/user/create", "application/json", strings.NewReader(`{
"account":"user3",
"username":"user3",
"password":"user3"
}`))
if err != nil {
t.Error(err)
}
data, err = readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Create User Api Error!")
}
}
func testPort(t *testing.T) {
@ -92,10 +283,21 @@ func testDataBaseSet(t *testing.T) {
if data.ErrorNo != 0 {
t.Error("Get Database Config Api Error!")
}
// 设置配置
ret, err = http.Post(TestHost+"/api/setup", "application/json", strings.NewReader(`
argList := flag.Args()
configData := `
{"action":"set","step":"database","db_type":"sqlite","db_dsn":"./config/pmail_temp.db"}
`))
`
if array.InArray("mysql", argList) {
configData = `
{"action":"set","step":"database","db_type":"mysql","db_dsn":"root:githubTest@tcp(mysql:3306)/pmail?parseTime=True"}
`
}
// 设置配置
ret, err = http.Post(TestHost+"/api/setup", "application/json", strings.NewReader(configData))
if err != nil {
t.Error(err)
}
@ -120,7 +322,7 @@ func testDataBaseSet(t *testing.T) {
t.Error("Get Database Config Api Error!")
}
dt := data.Data.(map[string]interface{})
if cast.ToString(dt["db_dsn"]) != "./config/pmail_temp.db" {
if cast.ToString(dt["db_dsn"]) == "" {
t.Error("Check Database Config Api Error!")
}
@ -154,6 +356,7 @@ func testPwdSet(t *testing.T) {
}
if data.ErrorNo != 0 {
t.Error("Set Password Config Api Error!")
t.Error(data)
}
// 获取配置
@ -241,6 +444,7 @@ func testDNSSet(t *testing.T) {
if data.ErrorNo != 0 {
t.Error("Get domain Config Api Error!")
}
t.Log("DNS Set Success!")
}
func testSSLSet(t *testing.T) {
@ -284,8 +488,39 @@ func testLogin(t *testing.T) {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Get domain Config Api Error!")
t.Error("Login Api Error!")
}
t.Logf("testLogin Success! Response: %+v", data)
}
func testLoginUser2(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/login", "application/json", strings.NewReader("{\"account\":\"user2\",\"password\":\"user2New\"}"))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Login User2 Api Error!", data)
}
t.Logf("testLoginUser2 Success! Response: %+v", data)
}
func testLoginUser3(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/login", "application/json", strings.NewReader("{\"account\":\"user3\",\"password\":\"user3\"}"))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 100 {
t.Error("Login User3 Api Error!", data)
}
t.Logf("testLoginUser3 Success! Response: %+v", data)
}
func testSendEmail(t *testing.T) {
@ -320,8 +555,154 @@ func testSendEmail(t *testing.T) {
if data.ErrorNo != 0 {
t.Error("Send Email Api Error!")
}
t.Logf("testSendEmail Success! Response: %+v", data)
}
func testSendEmail2User2ForMove(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/email/send", "application/json", strings.NewReader(`
{
"from": {
"name": "user2",
"email": "user2@test.domain"
},
"to": [
{
"name": "y",
"email": "user2@test.domain"
}
],
"cc": [
],
"subject": "MovePlease",
"text": "NeedMove",
"html": "<div>text</div>"
}
`))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Send Email Api Error!")
}
t.Logf("testSendEmail2User2ForMove Success! Response: %+v", data)
}
func testSendEmail2User1(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/email/send", "application/json", strings.NewReader(`
{
"from": {
"name": "i",
"email": "i@test.domain"
},
"to": [
{
"name": "y",
"email": "user1@test.domain"
}
],
"cc": [
],
"subject": "HelloUser1",
"text": "text",
"html": "<div>text</div>"
}
`))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Send Email Api Error!")
}
t.Logf("testSendEmail2User1 Success! Response: %+v", data)
}
func testSendEmail2User2(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/email/send", "application/json", strings.NewReader(`
{
"from": {
"name": "i",
"email": "i@test.domain"
},
"to": [
{
"name": "y",
"email": "user2@test.domain"
}
],
"cc": [
],
"subject": "HelloUser2",
"text": "text",
"html": "<div>text</div>"
}
`))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Send Email Api Error!")
}
t.Logf("testSendEmail2User2 Success! Response: %+v", data)
}
func testSendEmail2User3(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/email/send", "application/json", strings.NewReader(`
{
"from": {
"name": "i",
"email": "i@test.domain"
},
"to": [
{
"name": "y",
"email": "user3@test.domain"
}
],
"cc": [
],
"subject": "HelloUser3",
"text": "text",
"html": "<div>text</div>"
}
`))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Send Email Api Error!")
}
t.Logf("testSendEmail2User3 Success! Response: %+v", data)
}
func testEmailList(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/email/list", "application/json", strings.NewReader(`{}`))
if err != nil {
@ -338,6 +719,37 @@ func testEmailList(t *testing.T) {
if len(dt["list"].([]interface{})) == 0 {
t.Error("Email List Is Empty!")
}
lst := dt["list"].([]interface{})
item := lst[0].(map[string]interface{})
id := cast.ToInt(item["id"])
if id == 0 {
t.Error("Email List Data Error!")
}
t.Logf("testEmailList Success! Response: %+v", data)
}
func testUser2EmailList(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/email/list", "application/json", strings.NewReader(`{}`))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Get Email List Api Error!")
}
dt := data.Data.(map[string]interface{})
if dt["list"] == nil || len(dt["list"].([]interface{})) != 1 {
t.Error("Email List Is Empty!")
}
t.Logf("testUser2EmailList Success! Response: %+v", data)
}
func testDelEmail(t *testing.T) {
@ -373,12 +785,13 @@ func testDelEmail(t *testing.T) {
if data.ErrorNo != 0 {
t.Error("Email Delete Api Error!")
}
var mail models.Email
db.Instance.Where("id = ?", id).Get(&mail)
var mail models.UserEmail
db.Instance.Where("email_id = ?", id).Get(&mail)
if mail.Status != 3 {
t.Error("Email Delete Api Error!")
}
t.Logf("testDelEmail Success! Response: %+v", data)
}
// portCheck 检查端口是占用

View File

@ -4,7 +4,9 @@ type User struct {
ID int `xorm:"id unsigned int not null pk autoincr"`
Account string `xorm:"varchar(20) notnull unique comment('账号登陆名')"`
Name string `xorm:"varchar(10) notnull comment('用户名')"`
Password string `xorm:"char(32) notnull comment('登陆密码两次md5加盐md5(md5(password+pmail) +pmail2023)')"`
Password string `xorm:"char(32) notnull comment('登陆密码两次md5加盐md5(md5(password+pmail) +pmail2023)')" json:"-"`
Disabled int `xorm:"disabled unsigned int not null default(0) comment('0启用1禁用')"`
IsAdmin int `xorm:"is_admin unsigned int not null default(0) comment('0不是管理员1是管理员')"`
}
func (p User) TableName() string {

View File

@ -1,11 +0,0 @@
package models
type UserAuth struct {
ID int `xorm:"id int unsigned not null pk autoincr"`
UserID int `xorm:"user_id int not null unique('uid_account') index comment('用户id')"`
EmailAccount string `xorm:"email_account not null unique('uid_account') index comment('收信人前缀')"`
}
func (p UserAuth) TableName() string {
return "user_auth"
}

View File

@ -10,7 +10,6 @@ import (
type Email struct {
Id int `xorm:"id pk unsigned int autoincr notnull" json:"id"`
Type int8 `xorm:"type tinyint(4) notnull default(0) comment('邮件类型0:收到的邮件1:发送的邮件')" json:"type"`
GroupId int `xorm:"group_id int notnull default(0) comment('分组id')'" json:"group_id"`
Subject string `xorm:"subject varchar(1000) notnull default('') comment('邮件标题')" json:"subject"`
ReplyTo string `xorm:"reply_to text comment('回复人')" json:"reply_to"`
FromName string `xorm:"from_name varchar(50) notnull default('') comment('发件人名称')" json:"from_name"`
@ -24,17 +23,17 @@ type Email struct {
Attachments string `xorm:"attachments longtext comment('附件')" json:"attachments"`
SPFCheck int8 `xorm:"spf_check tinyint(1) comment('spf校验是否通过')" json:"spf_check"`
DKIMCheck int8 `xorm:"dkim_check tinyint(1) comment('dkim校验是否通过')" json:"dkim_check"`
Status int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送1已发送2发送失败3删除')" json:"status"` // 0未发送1已发送2发送失败3删除
Status int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送1已发送2发送失败')" json:"status"` // 0未发送1已发送2发送失败
CronSendTime time.Time `xorm:"cron_send_time comment('定时发送时间')" json:"cron_send_time"`
UpdateTime time.Time `xorm:"update_time updated comment('更新时间')" json:"update_time"`
SendUserID int `xorm:"send_user_id unsigned int notnull default(0) comment('发件人用户id')" json:"send_user_id"`
IsRead int8 `xorm:"is_read tinyint(1) comment('是否已读')" json:"is_read"`
Size int `xorm:"size unsigned int notnull default(1000) comment('邮件大小')" json:"size"`
Error sql.NullString `xorm:"error text comment('投递错误信息')" json:"error"`
SendDate time.Time `xorm:"send_date comment('投递时间')" json:"send_date"`
CreateTime time.Time `xorm:"create_time created" json:"create_time"`
}
func (d Email) TableName() string {
func (d *Email) TableName() string {
return "email"
}
@ -45,43 +44,43 @@ type attachments struct {
//Content []byte
}
func (d Email) GetTos() []*parsemail.User {
func (d *Email) GetTos() []*parsemail.User {
var ret []*parsemail.User
json.Unmarshal([]byte(d.To), &ret)
return ret
}
func (d Email) GetReplyTo() []*parsemail.User {
func (d *Email) GetReplyTo() []*parsemail.User {
var ret []*parsemail.User
json.Unmarshal([]byte(d.ReplyTo), &ret)
return ret
}
func (d Email) GetSender() *parsemail.User {
func (d *Email) GetSender() *parsemail.User {
var ret *parsemail.User
json.Unmarshal([]byte(d.Sender), &ret)
return ret
}
func (d Email) GetBcc() []*parsemail.User {
func (d *Email) GetBcc() []*parsemail.User {
var ret []*parsemail.User
json.Unmarshal([]byte(d.Bcc), &ret)
return ret
}
func (d Email) GetCc() []*parsemail.User {
func (d *Email) GetCc() []*parsemail.User {
var ret []*parsemail.User
json.Unmarshal([]byte(d.Cc), &ret)
return ret
}
func (d Email) GetAttachments() []*parsemail.Attachment {
func (d *Email) GetAttachments() []*parsemail.Attachment {
var ret []*parsemail.Attachment
json.Unmarshal([]byte(d.Attachments), &ret)
return ret
}
func (d Email) MarshalJSON() ([]byte, error) {
func (d *Email) MarshalJSON() ([]byte, error) {
type Alias Email
var allAtt = []attachments{}
@ -108,7 +107,7 @@ func (d Email) MarshalJSON() ([]byte, error) {
Error string `json:"error"`
Attachments []attachments `json:"attachments"`
}{
Alias: (Alias)(d),
Alias: (Alias)(*d),
CronSendTime: d.CronSendTime.Format("2006-01-02 15:04:05"),
UpdateTime: d.UpdateTime.Format("2006-01-02 15:04:05"),
CreateTime: d.CreateTime.Format("2006-01-02 15:04:05"),
@ -120,7 +119,7 @@ func (d Email) MarshalJSON() ([]byte, error) {
})
}
func (d Email) ToTransObj() *parsemail.Email {
func (d *Email) ToTransObj() *parsemail.Email {
return &parsemail.Email{
From: &parsemail.User{

View File

@ -0,0 +1,14 @@
package models
type UserEmail struct {
ID int `xorm:"id int unsigned not null pk autoincr"`
UserID int `xorm:"user_id int not null unique('uid_eid') index comment('用户id')"`
EmailID int `xorm:"email_id not null unique('uid_eid') index comment('信件id')"`
IsRead int8 `xorm:"is_read tinyint(1) comment('是否已读')" json:"is_read"`
GroupId int `xorm:"group_id int notnull default(0) comment('分组id')'" json:"group_id"`
Status int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送1已发送2发送失败3删除')" json:"status"` // 0未发送1已发送2发送失败 3删除
}
func (p UserEmail) TableName() string {
return "user_email"
}

10
server/models/version.go Normal file
View File

@ -0,0 +1,10 @@
package models
type Version struct {
Id int `xorm:"id int unsigned not null pk autoincr" json:"id"`
Info string `xorm:"varchar(255) notnull" json:"info"`
}
func (p *Version) TableName() string {
return "version"
}

View File

@ -5,9 +5,13 @@ import (
"github.com/Jinnrry/gopop"
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
"pmail/consts"
"pmail/db"
"pmail/dto"
"pmail/models"
"pmail/services/del_email"
"pmail/services/detail"
"pmail/services/list"
"pmail/utils/array"
"pmail/utils/context"
"pmail/utils/errors"
@ -100,7 +104,7 @@ func (a action) Pass(session *gopop.Session, pwd string) error {
encodePwd := password.Encode(pwd)
_, err := db.Instance.Where("account =? and password =?", session.User, encodePwd).Get(&user)
_, err := db.Instance.Where("account =? and password =? and disabled = 0", session.User, encodePwd).Get(&user)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.WithContext(session.Ctx.(*context.Context)).Errorf("%+v", err)
}
@ -136,7 +140,7 @@ func (a action) Apop(session *gopop.Session, username, digest string) error {
var user models.User
_, err := db.Instance.Where("account =? ", username).Get(&user)
_, err := db.Instance.Where("account =? and disabled = 0", username).Get(&user)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.WithContext(session.Ctx.(*context.Context)).Errorf("%+v", err)
}
@ -156,26 +160,13 @@ func (a action) Apop(session *gopop.Session, username, digest string) error {
}
type statInfo struct {
Num int64 `json:"num"`
Size int64 `json:"size"`
}
// Stat 查询邮件数量
func (a action) Stat(session *gopop.Session) (msgNum, msgSize int64, err error) {
log.WithContext(session.Ctx).Debugf("POP3 CMD: STAT")
var si statInfo
_, err = db.Instance.Select("count(1) as `num`, sum(length(text)+length(html)) as `size`").Table("email").Where("type=0 and status=0").Get(&si)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.WithContext(session.Ctx.(*context.Context)).Errorf("%+v", err)
err = nil
log.WithContext(session.Ctx).Debugf("POP3 STAT RETURT :0,0")
return 0, 0, nil
}
log.WithContext(session.Ctx).Debugf("POP3 STAT RETURT : %d,%d", si.Num, si.Size)
return si.Num, si.Size, nil
num, size := list.Stat(session.Ctx.(*context.Context))
log.WithContext(session.Ctx).Debugf("POP3 STAT RETURT : %d,%d", num, size)
return num, size, nil
}
// Uidl 查询某封邮件的唯一标志符
@ -223,26 +214,31 @@ type listItem struct {
func (a action) List(session *gopop.Session, msg string) ([]gopop.MailInfo, error) {
log.WithContext(session.Ctx).Debugf("POP3 CMD: LIST ,Args:%s", msg)
var res []listItem
var listId int64
var listId int
if msg != "" {
listId = cast.ToInt64(msg)
listId = cast.ToInt(msg)
if listId == 0 {
return nil, errors.New("params error")
}
}
var err error
var ssql string
if listId != 0 {
err = db.Instance.Select("id, ifnull(LENGTH(TEXT) , 0) + ifnull(LENGTH(html) , 0) AS `size`").Table("email").Where("id=?", listId).Find(&res)
} else {
err = db.Instance.Select("id, ifnull(LENGTH(TEXT) , 0) + ifnull(LENGTH(html) , 0) AS `size`").Table("email").Where("type=0 and status=0").Find(&res)
info, err := detail.GetEmailDetail(session.Ctx.(*context.Context), listId, false)
if err != nil {
return nil, err
}
res = append(res, listItem{
Id: cast.ToInt64(info.Id),
Size: cast.ToInt64(info.Size),
})
} else {
emailList, _ := list.GetEmailList(session.Ctx.(*context.Context), dto.SearchTag{Type: consts.EmailTypeReceive, Status: -1, GroupId: -1}, "", true, 0, 99999)
for _, info := range emailList {
res = append(res, listItem{
Id: cast.ToInt64(info.Id),
Size: cast.ToInt64(info.Size),
})
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.WithContext(session.Ctx.(*context.Context)).Errorf("SQL:%s Error: %+v", ssql, err)
err = nil
return []gopop.MailInfo{}, nil
}
ret := []gopop.MailInfo{}
for _, re := range res {
@ -316,11 +312,7 @@ func (a action) Noop(session *gopop.Session) error {
func (a action) Quit(session *gopop.Session) error {
log.WithContext(session.Ctx).Debugf("POP3 CMD: QUIT ")
if len(session.DeleteIds) > 0 {
_, err := db.Instance.Exec(db.WithContext(session.Ctx.(*context.Context), "UPDATE email SET status=3 WHERE id in ?"), session.DeleteIds)
if err != nil {
log.WithContext(session.Ctx.(*context.Context)).Errorf("%+v", err)
}
del_email.DelEmailI64(session.Ctx.(*context.Context), session.DeleteIds)
}
return nil

View File

@ -52,6 +52,11 @@ func Start() {
}
func Stop() {
if instance != nil {
instance.Stop()
}
if instanceTls != nil {
instanceTls.Stop()
}
}

View File

@ -32,7 +32,7 @@ func Init(serverVersion string) {
// 启动前检查一遍证书
ssl.Update(false)
parsemail.Init()
err := db.Init()
err := db.Init(serverVersion)
if err != nil {
panic(err)
}

View File

@ -17,26 +17,17 @@ import (
// HasAuth 检查当前用户是否有某个邮件的auth
func HasAuth(ctx *context.Context, email *models.Email) bool {
// 获取当前用户的auth
var auth []models.UserAuth
err := db.Instance.Where("user_id = ?", ctx.UserID).Find(&auth)
if ctx.IsAdmin {
return true
}
var ue *models.UserEmail
err := db.Instance.Where("email_id = ?", email.Id).Find(&ue)
if err != nil {
log.WithContext(ctx).Errorf("SQL error:%+v", err)
log.Errorf("Error while checking user: %v", err)
return false
}
var hasAuth bool
for _, userAuth := range auth {
if userAuth.EmailAccount == "*" {
hasAuth = true
break
} else if strings.Contains(email.Bcc, ctx.UserAccount) || strings.Contains(email.Cc, ctx.UserAccount) || strings.Contains(email.To, ctx.UserAccount) {
hasAuth = true
break
}
}
return hasAuth
return ue != nil
}
func DkimGen() string {

View File

@ -1,35 +1,50 @@
package del_email
import (
log "github.com/sirupsen/logrus"
"pmail/consts"
"pmail/db"
"pmail/models"
"pmail/services/auth"
"pmail/utils/context"
"pmail/utils/errors"
"xorm.io/builder"
)
import . "xorm.io/builder"
func DelEmail(ctx *context.Context, ids []int) error {
var emails []*models.Email
err := db.Instance.Table("email").Where(builder.In("id", ids)).Find(&emails)
if err != nil {
return errors.Wrap(err)
}
for _, email := range emails {
// 检查是否有权限
hasAuth := auth.HasAuth(ctx, email)
if !hasAuth {
return errors.New("No Auth!")
}
email.Status = 3
}
_, err = db.Instance.Table("email").Where(builder.In("id", ids)).Cols("status").Update(map[string]interface{}{"status": 3})
if err != nil {
return errors.Wrap(err)
}
if len(ids) == 0 {
return nil
}
where, params, err := ToSQL(Eq{"user_id": ctx.UserID}.And(Eq{"email_id": ids}))
if err != nil {
log.Errorf("del email err: %v", err)
return err
}
_, err = db.Instance.Table(&models.UserEmail{}).Where(where, params...).Update(map[string]interface{}{"status": consts.EmailStatusDel})
if err != nil {
log.Errorf("del email err: %v", err)
}
return err
}
func DelEmailI64(ctx *context.Context, ids []int64) error {
if len(ids) == 0 {
return nil
}
where, params, err := ToSQL(Eq{"user_id": ctx.UserID}.And(Eq{"email_id": ids}))
if err != nil {
log.Errorf("del email err: %v", err)
return err
}
_, err = db.Instance.Table(&models.UserEmail{}).Where(where, params...).Update(map[string]interface{}{"status": consts.EmailStatusDel})
if err != nil {
log.Errorf("del email err: %v", err)
}
return err
}

View File

@ -7,22 +7,37 @@ import (
log "github.com/sirupsen/logrus"
"pmail/db"
"pmail/dto/parsemail"
"pmail/dto/response"
"pmail/models"
"pmail/utils/context"
"pmail/utils/errors"
"strings"
)
func GetEmailDetail(ctx *context.Context, id int, markRead bool) (*models.Email, error) {
// 获取邮件内容
var email models.Email
_, err := db.Instance.ID(id).Get(&email)
func GetEmailDetail(ctx *context.Context, id int, markRead bool) (*response.EmailResponseData, error) {
// 先查是否是本人的邮件
var ue models.UserEmail
_, err := db.Instance.Where("email_id = ?", id).Get(&ue)
if err != nil {
log.Error(err)
}
if ue.ID == 0 && !ctx.IsAdmin {
return nil, errors.New("Not authorized")
}
//获取邮件内容
var email response.EmailResponseData
_, err = db.Instance.ID(id).Get(&email)
if err != nil {
log.WithContext(ctx).Errorf("SQL error:%+v", err)
return nil, err
}
if markRead && email.IsRead == 0 {
_, err = db.Instance.Exec(db.WithContext(ctx, "update email set is_read =1 where id =?"), email.Id)
email.IsRead = ue.IsRead
if markRead && ue.IsRead == 0 {
ue.IsRead = 1
_, err = db.Instance.Update(&ue)
if err != nil {
log.WithContext(ctx).Errorf("SQL error:%+v", err)
}

View File

@ -1,49 +1,62 @@
package list
import (
"encoding/json"
"fmt"
log "github.com/sirupsen/logrus"
"pmail/db"
"pmail/dto"
"pmail/models"
"pmail/dto/response"
"pmail/utils/context"
)
func GetEmailList(ctx *context.Context, tag string, keyword string, offset, limit int) (emailList []*models.Email, total int64) {
querySQL, queryParams := genSQL(ctx, tag, keyword)
total, err := db.Instance.Table("email").Where(querySQL, queryParams...).Desc("id").Limit(limit, offset).FindAndCount(&emailList)
if err != nil {
log.Errorf("SQL ERROR: %s ,Error:%s", querySQL, err)
}
return
func GetEmailList(ctx *context.Context, tagInfo dto.SearchTag, keyword string, pop3List bool, offset, limit int) (emailList []*response.EmailResponseData, total int64) {
return getList(ctx, tagInfo, keyword, pop3List, offset, limit)
}
func genSQL(ctx *context.Context, tag, keyword string) (string, []any) {
func getList(ctx *context.Context, tagInfo dto.SearchTag, keyword string, pop3List bool, offset, limit int) (emailList []*response.EmailResponseData, total int64) {
querySQL, queryParams := genSQL(ctx, false, tagInfo, keyword, pop3List, offset, limit)
sql := "1=1 "
err := db.Instance.SQL(querySQL, queryParams...).Find(&emailList)
if err != nil {
log.WithContext(ctx).Errorf("SQL ERROR: %s ,Error:%s", querySQL, err)
}
sqlParams := []any{}
totalSQL, totalParams := genSQL(ctx, true, tagInfo, keyword, pop3List, offset, limit)
var tagInfo dto.SearchTag
_ = json.Unmarshal([]byte(tag), &tagInfo)
_, err = db.Instance.SQL(totalSQL, totalParams...).Get(&total)
if err != nil {
log.WithContext(ctx).Errorf("SQL ERROR: %s ,Error:%s", querySQL, err)
}
return emailList, total
}
func genSQL(ctx *context.Context, count bool, tagInfo dto.SearchTag, keyword string, pop3List bool, offset, limit int) (string, []any) {
sqlParams := []any{ctx.UserID}
sql := "select "
if count {
sql += `count(1) from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ? `
} else if pop3List {
sql += `e.id,e.size from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ? `
} else {
sql += `e.*,ue.is_read from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ? `
}
if tagInfo.Status != -1 {
sql += " and ue.status =? "
sqlParams = append(sqlParams, tagInfo.Status)
} else {
sql += " and ue.status != 3"
}
if tagInfo.Type != -1 {
sql += " and type =? "
sqlParams = append(sqlParams, tagInfo.Type)
}
if tagInfo.Status != -1 {
sql += " and status =? "
sqlParams = append(sqlParams, tagInfo.Status)
} else {
sql += " and status != 3"
}
if tagInfo.GroupId != -1 {
sql += " and group_id=? "
sql += " and ue.group_id=? "
sqlParams = append(sqlParams, tagInfo.GroupId)
}
@ -52,5 +65,32 @@ func genSQL(ctx *context.Context, tag, keyword string) (string, []any) {
sqlParams = append(sqlParams, "%"+keyword+"%", "%"+keyword+"%")
}
if limit == 0 {
limit = 10
}
sql += " order by e.id desc"
if limit < 10000 {
sql += fmt.Sprintf(" limit %d,%d ", offset, limit)
}
return sql, sqlParams
}
type statRes struct {
Total int64
Size int64
}
// Stat 查询邮件总数和大小
func Stat(ctx *context.Context) (int64, int64) {
sql := `select count(1) as total,sum(size) as size from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ? and e.type = 0 and ue.status != 3`
var ret statRes
_, err := db.Instance.SQL(sql, ctx.UserID).Get(&ret)
if err != nil {
log.WithContext(ctx).Errorf("SQL ERROR: %s ,Error:%s", sql, err)
}
return ret.Total, ret.Size
}

View File

@ -0,0 +1,54 @@
package list
import (
"pmail/dto"
"pmail/utils/context"
"reflect"
"testing"
)
func Test_genSQL(t *testing.T) {
type args struct {
ctx *context.Context
count bool
tagInfo dto.SearchTag
keyword string
pop3List bool
offset int
limit int
}
tests := []struct {
name string
args args
want string
want1 []any
}{
{
name: "Group搜索",
args: args{
ctx: &context.Context{
UserID: 1,
},
count: false,
tagInfo: dto.SearchTag{-1, -1, 2},
keyword: "",
pop3List: false,
offset: 0,
limit: 0,
},
want: "select e.*,ue.is_read from email e left join user_email ue on e.id=ue.email_id where ue.user_id = ? and ue.status != 3 and ue.group_id=? order by e.id desc limit 0,10 ",
want1: []any{1, 2},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got1 := genSQL(tt.args.ctx, tt.args.count, tt.args.tagInfo, tt.args.keyword, tt.args.pop3List, tt.args.offset, tt.args.limit)
if got != tt.want {
t.Errorf("genSQL() got = \n%v, want \n%v", got, tt.want)
}
if !reflect.DeepEqual(got1, tt.want1) {
t.Errorf("genSQL() got1 = \n%v, want \n%v", got1, tt.want1)
}
})
}
}

View File

@ -4,6 +4,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
"pmail/config"
"pmail/consts"
"pmail/db"
"pmail/dto"
"pmail/dto/parsemail"
@ -14,13 +15,13 @@ import (
"strings"
)
func GetAllRules(ctx *context.Context) []*dto.Rule {
func GetAllRules(ctx *context.Context, userId int) []*dto.Rule {
var res []*models.Rule
var err error
if ctx == nil || ctx.UserID == 0 {
err = db.Instance.Decr("sort").Find(&res)
if userId == 0 {
return nil
} else {
err = db.Instance.Where("user_id=?", ctx.UserID).Decr("sort").Find(&res)
err = db.Instance.Where("user_id=?", userId).Decr("sort").Find(&res)
}
if err != nil {
@ -64,14 +65,16 @@ func DoRule(ctx *context.Context, rule *dto.Rule, email *parsemail.Email) {
switch rule.Action {
case dto.READ:
email.IsRead = 1
if email.MessageId > 0 {
db.Instance.Exec(db.WithContext(ctx, "update email set is_read=1 where id =?"), email.MessageId)
_, err := db.Instance.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", email.MessageId, rule.UserId).Cols("is_read").Update(map[string]interface{}{"is_read": 1})
if err != nil {
log.WithContext(ctx).Errorf("sqlERror :%v", err)
}
}
case dto.DELETE:
email.Status = 3
if email.MessageId > 0 {
db.Instance.Exec(db.WithContext(ctx, "update email set status=3 where id =?"), email.MessageId)
_, err := db.Instance.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", email.MessageId, rule.UserId).Cols("status").Update(map[string]interface{}{"status": consts.EmailStatusDel})
if err != nil {
log.WithContext(ctx).Errorf("sqlERror :%v", err)
}
case dto.FORWARD:
if strings.Contains(rule.Params, config.Instance.Domain) {
@ -83,9 +86,9 @@ func DoRule(ctx *context.Context, rule *dto.Rule, email *parsemail.Email) {
log.WithContext(ctx).Errorf("Forward Error:%v", err)
}
case dto.MOVE:
email.GroupId = cast.ToInt(rule.Params)
if email.MessageId > 0 {
db.Instance.Exec(db.WithContext(ctx, "update email set group_id=? where id =?"), email.GroupId, email.MessageId)
_, err := db.Instance.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", email.MessageId, rule.UserId).Cols("group_id").Update(map[string]interface{}{"group_id": cast.ToInt(rule.Params)})
if err != nil {
log.WithContext(ctx).Errorf("sqlERror :%v", err)
}
}

View File

@ -43,18 +43,11 @@ func GetAdminPassword(ctx *context.Context) (string, error) {
func SetAdminPassword(ctx *context.Context, account, pwd string) error {
encodePwd := password.Encode(pwd)
res, err := db.Instance.Exec(db.WithContext(ctx, "INSERT INTO user (account, name, password) VALUES (?, 'admin',?)"), account, encodePwd)
if err != nil {
return errors.Wrap(err)
}
id, err := res.LastInsertId()
if err != nil {
return errors.Wrap(err)
}
_, err = db.Instance.Exec(db.WithContext(ctx, "INSERT INTO user_auth (user_id, email_account) VALUES (?, '*')"), id)
_, err := db.Instance.Exec(db.WithContext(ctx, "INSERT INTO user (account, name, password,is_admin) VALUES (?, 'admin',?,1)"), account, encodePwd)
if err != nil {
return errors.Wrap(err)
}
return nil
}
@ -81,7 +74,7 @@ func SetDatabaseSettings(ctx *context.Context, dbType, dbDSN string) error {
}
config.Init()
// 检查数据库是否能正确连接
err = db.Init()
err = db.Init("")
if err != nil {
return errors.Wrap(err)
}

View File

@ -26,9 +26,9 @@ func GetDNSSettings(ctx *context.Context) ([]*DNSItem, error) {
ret := []*DNSItem{
{Type: "A", Host: "smtp", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
{Type: "A", Host: "pop", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
{Type: "A", Host: "-", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
{Type: "MX", Host: "-", Value: fmt.Sprintf("smtp.%s", configData.Domain), TTL: 3600},
{Type: "TXT", Host: "-", Value: "v=spf1 a mx ~all", TTL: 3600},
{Type: "A", Host: "", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
{Type: "MX", Host: "", Value: fmt.Sprintf("smtp.%s", configData.Domain), TTL: 3600},
{Type: "TXT", Host: "", Value: "v=spf1 a mx ~all", TTL: 3600},
{Type: "TXT", Host: "default._domainkey", Value: auth.DkimGen(), TTL: 3600},
}
return ret, nil

View File

@ -2,12 +2,11 @@ package setup
import (
"pmail/signal"
"pmail/utils/context"
"pmail/utils/errors"
)
// Finish 标记初始化完成
func Finish(ctx *context.Context) error {
func Finish() error {
cfg, err := ReadConfig()
if err != nil {
return errors.Wrap(err)

View File

@ -0,0 +1,67 @@
package ssl
import (
"github.com/go-acme/lego/v4/providers/dns"
"os"
"pmail/utils/errors"
"regexp"
"strings"
)
func GetServerParamsList(serverName string) ([]string, error) {
var serverParams []string
infos, err := os.ReadDir("./")
if err != nil {
return nil, errors.Wrap(err)
}
upperServerName := strings.ToUpper(serverName)
for _, info := range infos {
if strings.HasPrefix(info.Name(), upperServerName) {
serverParams = append(serverParams, info.Name())
}
}
if len(serverParams) != 0 {
return serverParams, nil
}
_, err = dns.NewDNSChallengeProviderByName(serverName)
if err == nil {
return nil, errors.New(serverName + " Not Support")
}
if strings.Contains(err.Error(), "unrecognized DNS provider") {
return nil, err
}
re := regexp.MustCompile(`missing: (.+)`)
// namesilo: some credentials information are missing: NAMESILO_API_KEY
estr := err.Error()
name := re.FindStringSubmatch(estr)
if len(name) == 2 {
names := strings.Split(name[1], ",")
for _, s := range names {
serverParams = append(serverParams, s)
SetDomainServerParams(s, "empty")
}
}
_, err = dns.NewDNSChallengeProviderByName(serverName)
return serverParams, err
}
func SetDomainServerParams(name, value string) {
key := name
err := os.WriteFile(key, []byte(value), 0644)
if err != nil {
panic(err)
}
err = os.Setenv(name+"_FILE", key)
if err != nil {
panic(err)
}
}

View File

@ -0,0 +1,36 @@
package ssl
import (
"reflect"
"testing"
)
func TestGetServerParamsList(t *testing.T) {
type args struct {
serverName string
}
tests := []struct {
name string
args args
want []string
wantErr bool
}{
{name: "namesilo", args: args{serverName: "namesilo"}, want: []string{"NAMESILO_API_KEY"}, wantErr: false},
{name: "namesiloAgain", args: args{serverName: "namesilo"}, want: []string{"NAMESILO_API_KEY"}, wantErr: false},
{name: "auroradns", args: args{serverName: "auroradns"}, want: []string{"AURORA_API_KEY", "AURORA_SECRET"}, wantErr: false},
{name: "alidns", args: args{serverName: "alidns"}, want: []string{"ALICLOUD_ACCESS_KEY", "ALICLOUD_SECRET_KEY"}, wantErr: false},
{name: "null", args: args{serverName: "null"}, want: nil, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetServerParamsList(tt.args.serverName)
if (err != nil) != tt.wantErr {
t.Errorf("GetServerParamsList() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetServerParamsList() got = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -8,13 +8,17 @@ import (
"crypto/tls"
"crypto/x509"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/providers/dns"
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
"os"
"pmail/config"
"pmail/services/setup"
"pmail/signal"
"pmail/utils/async"
"pmail/utils/errors"
"strings"
"time"
"github.com/go-acme/lego/v4/certcrypto"
@ -44,19 +48,20 @@ func GetSSL() string {
panic(err)
}
if cfg.SSLType == "" {
return config.SSLTypeAuto
return config.SSLTypeAutoHTTP
}
return cfg.SSLType
}
func SetSSL(sslType, priKey, crtKey string) error {
func SetSSL(sslType, priKey, crtKey, serviceName string) error {
cfg, err := setup.ReadConfig()
if err != nil {
panic(err)
}
if sslType == config.SSLTypeAuto || sslType == config.SSLTypeUser {
if sslType == config.SSLTypeAutoHTTP || sslType == config.SSLTypeUser || sslType == config.SSLTypeAutoDNS {
cfg.SSLType = sslType
cfg.DomainServiceName = serviceName
} else {
return errors.New("SSL Type Error!")
}
@ -101,20 +106,35 @@ func GenSSL(update bool) error {
key: privateKey,
}
config := lego.NewConfig(&myUser)
config.Certificate.KeyType = certcrypto.RSA2048
conf := lego.NewConfig(&myUser)
conf.UserAgent = "PMail"
conf.Certificate.KeyType = certcrypto.RSA2048
// A client facilitates communication with the CA server.
client, err := lego.NewClient(config)
client, err := lego.NewClient(conf)
if err != nil {
return errors.Wrap(err)
}
if cfg.SSLType == "0" {
err = client.Challenge.SetHTTP01Provider(GetHttpChallengeInstance())
if err != nil {
return errors.Wrap(err)
}
} else if cfg.SSLType == "2" {
err = os.Setenv(strings.ToUpper(cfg.DomainServiceName)+"_PROPAGATION_TIMEOUT", "900")
if err != nil {
log.Errorf("Set ENV Variable Error: %s", err.Error())
}
dnspodProvider, err := dns.NewDNSChallengeProviderByName(cfg.DomainServiceName)
if err != nil {
return errors.Wrap(err)
}
err = client.Challenge.SetDNS01Provider(dnspodProvider, dns01.AddDNSTimeout(15*time.Minute))
if err != nil {
return errors.Wrap(err)
}
}
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
@ -126,26 +146,35 @@ func GenSSL(update bool) error {
Domains: []string{"smtp." + cfg.Domain, cfg.WebDomain, "pop." + cfg.Domain},
Bundle: true,
}
as := async.New(nil)
as.Process(func(params any) {
log.Infof("wait ssl")
certificates, err := client.Certificate.Obtain(request)
if err != nil {
return errors.Wrap(err)
panic(err)
}
err = os.WriteFile("./config/ssl/private.key", certificates.PrivateKey, 0666)
if err != nil {
return errors.Wrap(err)
panic(err)
}
err = os.WriteFile("./config/ssl/public.crt", certificates.Certificate, 0666)
if err != nil {
return errors.Wrap(err)
panic(err)
}
err = os.WriteFile("./config/ssl/issuerCert.crt", certificates.IssuerCertificate, 0666)
if err != nil {
return errors.Wrap(err)
panic(err)
}
setup.Finish()
}, nil)
return nil
}

View File

@ -19,9 +19,11 @@ import (
"pmail/services/rule"
"pmail/utils/async"
"pmail/utils/context"
"pmail/utils/errors"
"pmail/utils/send"
"strings"
"time"
. "xorm.io/builder"
)
func (s *Session) Data(r io.Reader) error {
@ -61,8 +63,12 @@ func (s *Session) Data(r io.Reader) error {
}
// 判断是收信还是转发,只要是登陆了,都当成转发处理
//account, domain := email.From.GetDomainAccount()
if s.Ctx.UserID > 0 {
account, _ := email.From.GetDomainAccount()
if account != ctx.UserAccount && !ctx.IsAdmin {
return errors.New("No Auth")
}
log.WithContext(ctx).Debugf("开始执行插件SendBefore")
for _, hook := range hooks.HookList {
if hook == nil {
@ -77,7 +83,7 @@ func (s *Session) Data(r io.Reader) error {
}
// 转发
err := saveEmail(ctx, email, s.Ctx.UserID, 1, true, true)
_, err := saveEmail(ctx, len(emailData), email, s.Ctx.UserID, 1, true, true)
if err != nil {
log.WithContext(ctx).Errorf("Email Save Error %v", err)
}
@ -105,11 +111,20 @@ func (s *Session) Data(r io.Reader) error {
if err != nil {
log.WithContext(ctx).Errorf("sql Error :%+v", err)
}
_, err = db.Instance.Exec(db.WithContext(ctx, "update user_email set status =2 where email_id = ? "), email.MessageId)
if err != nil {
log.WithContext(ctx).Errorf("sql Error :%+v", err)
}
} else {
_, err := db.Instance.Exec(db.WithContext(ctx, "update email set status =1 where id = ? "), email.MessageId)
if err != nil {
log.WithContext(ctx).Errorf("sql Error :%+v", err)
}
_, err = db.Instance.Exec(db.WithContext(ctx, "update user_email set status =1 where email_id = ? "), email.MessageId)
if err != nil {
log.WithContext(ctx).Errorf("sql Error :%+v", err)
}
}
} else {
@ -120,10 +135,6 @@ func (s *Session) Data(r io.Reader) error {
// DKIM校验
dkimStatus = parsemail.Check(bytes.NewReader(emailData))
if err != nil {
log.WithContext(ctx).Errorf("邮件内容解析失败! Error : %v \n", err)
}
SPFStatus = spfCheck(s.RemoteAddress.String(), email.Sender, email.Sender.EmailAddress)
log.WithContext(ctx).Debugf("开始执行插件ReceiveParseAfter")
@ -135,10 +146,6 @@ func (s *Session) Data(r io.Reader) error {
}
log.WithContext(ctx).Debugf("开始执行插件ReceiveParseAfterEnd")
if email == nil {
return nil
}
// 垃圾过滤
if config.Instance.SpamFilterLevel == 1 && !SPFStatus && !dkimStatus {
log.WithContext(ctx).Infoln("垃圾邮件,拒信")
@ -150,27 +157,34 @@ func (s *Session) Data(r io.Reader) error {
return nil
}
saveEmail(ctx, email, 0, 0, SPFStatus, dkimStatus)
users, _ := saveEmail(ctx, len(emailData), email, 0, 0, SPFStatus, dkimStatus)
if email.MessageId > 0 {
log.WithContext(ctx).Debugf("开始执行邮件规则!")
for _, user := range users {
// 执行邮件规则
rs := rule.GetAllRules(ctx)
rs := rule.GetAllRules(ctx, user.ID)
for _, r := range rs {
if rule.MatchRule(ctx, r, email) {
rule.DoRule(ctx, r, email)
}
}
}
}
log.WithContext(ctx).Debugf("开始执行插件ReceiveSaveAfter")
var ue []*models.UserEmail
err = db.Instance.Table(&models.UserEmail{}).Where("email_id=?", email.MessageId).Find(&ue)
if err != nil {
log.WithContext(ctx).Errorf("sql Error :%+v", err)
}
as3 := async.New(ctx)
for _, hook := range hooks.HookList {
if hook == nil {
continue
}
as3.WaitProcess(func(hk any) {
hk.(framework.EmailHook).ReceiveSaveAfter(ctx, email)
hk.(framework.EmailHook).ReceiveSaveAfter(ctx, email, ue)
}, hook)
}
as3.Wait()
@ -181,7 +195,7 @@ func (s *Session) Data(r io.Reader) error {
return nil
}
func saveEmail(ctx *context.Context, email *parsemail.Email, sendUserID int, emailType int, SPFStatus, dkimStatus bool) error {
func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserID int, emailType int, SPFStatus, dkimStatus bool) ([]*models.User, error) {
var dkimV, spfV int8
if dkimStatus {
dkimV = 1
@ -193,12 +207,11 @@ func saveEmail(ctx *context.Context, email *parsemail.Email, sendUserID int, ema
log.WithContext(ctx).Debugf("开始入库!")
if email == nil {
return nil
return nil, nil
}
modelEmail := models.Email{
Type: cast.ToInt8(emailType),
GroupId: email.GroupId,
Subject: email.Subject,
ReplyTo: json2string(email.ReplyTo),
FromName: email.From.Name,
@ -227,8 +240,55 @@ func saveEmail(ctx *context.Context, email *parsemail.Email, sendUserID int, ema
if modelEmail.Id > 0 {
email.MessageId = cast.ToInt64(modelEmail.Id)
}
// 收信人信息
var users []*models.User
return nil
// 如果是收信
if emailType == 0 {
// 找到收信人id
accounts := []string{}
for _, user := range append(append(email.To, email.Cc...), email.Bcc...) {
account, _ := user.GetDomainAccount()
if account != "" {
accounts = append(accounts, account)
}
}
where, params, _ := ToSQL(In("account", accounts))
err = db.Instance.Table(&models.User{}).Where(where, params...).Find(&users)
if err != nil {
log.WithContext(ctx).Errorf("db Select error:%+v", err.Error())
}
if len(users) > 0 {
for _, user := range users {
ue := models.UserEmail{EmailID: modelEmail.Id, UserID: user.ID}
_, err = db.Instance.Insert(&ue)
if err != nil {
log.WithContext(ctx).Errorf("db insert error:%+v", err.Error())
}
}
} else {
users = append(users, &models.User{ID: 1})
// 当邮件找不到收件人的时候,邮件全部丢给管理员账号
// id = 1的账号直接当成管理员账号处理
ue := models.UserEmail{EmailID: modelEmail.Id, UserID: 1}
_, err = db.Instance.Insert(&ue)
if err != nil {
log.WithContext(ctx).Errorf("db insert error:%+v", err.Error())
}
}
} else {
ue := models.UserEmail{EmailID: modelEmail.Id, UserID: ctx.UserID}
_, err = db.Instance.Insert(&ue)
if err != nil {
log.WithContext(ctx).Errorf("db insert error:%+v", err.Error())
}
}
return users, nil
}
func json2string(d any) string {

View File

@ -43,7 +43,7 @@ func testInit() {
config.Instance.DbDSN = "../config/pmail_temp.db"
parsemail2.Init()
db.Init()
db.Init("")
session.Init()
hooks.Init("dev")
}

View File

@ -81,7 +81,7 @@ func (s *Session) AuthPlain(username, pwd string) error {
username = infos[0]
}
_, err := db.Instance.Where("account =? and password =?", username, encodePwd).Get(&user)
_, err := db.Instance.Where("account =? and password =? and disabled=0", username, encodePwd).Get(&user)
if err != nil && err != sql.ErrNoRows {
log.Errorf("%+v", err)
}
@ -90,6 +90,7 @@ func (s *Session) AuthPlain(username, pwd string) error {
s.Ctx.UserAccount = user.Account
s.Ctx.UserID = user.ID
s.Ctx.UserName = user.Name
s.Ctx.IsAdmin = user.IsAdmin == 1
log.WithContext(s.Ctx).Debugf("Auth Success %+v", user)
return nil

View File

@ -15,6 +15,7 @@ type Context struct {
UserName string
Values map[string]any
Lang string
IsAdmin bool
}
func (c *Context) SetValue(key string, value any) {

View File

@ -0,0 +1,11 @@
package password
import (
"fmt"
"testing"
)
func TestEncode(t *testing.T) {
fmt.Println(Encode("user2"))
fmt.Println(Encode("user2New"))
}