Merge pull request #21 from Jinnrry/v2.2.0

v2.2.0
This commit is contained in:
木木的木头 2023-09-07 21:35:15 +08:00 committed by GitHub
commit a2e5c3afd6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 1912 additions and 2857 deletions

View File

@ -49,7 +49,11 @@ beautiful and cute Logo for this project!
## 2、Run
`double-click to open` Or `execute command to run`
`./pmail`
Or
`docker run -p 25:25 -p 80:80 -p 443:443 -p 465:465 -v $(pwd)/config:/work/config ghcr.io/jinnrry/pmail:latest`
## 3、Configuration
@ -70,6 +74,36 @@ and restart the service.
Create bot and get token from [BotFather](https://t.me/BotFather)
Open the `config/config.json` file in the run directory, edit a few configuration items at the beginning of `tg`and restart the service.
# Configuration file format description
```json
{
"logLevel": "info", //log output level
"domain": "domain.com", // Your domain
"webDomain": "mail.domain.com", // web domain
"dkimPrivateKeyPath": "config/dkim/dkim.priv", // dkim key path
"sslType": "0", // ssl certificate update mode, 0 automatic, 1 manual
"SSLPrivateKeyPath": "config/ssl/private.key", // ssl certificate path
"SSLPublicKeyPath": "config/ssl/public.crt", // ssl certificate path
"dbDSN": "./config/pmail.db", // database connect DSN
"dbType": "sqlite", //database type `sqlite` or `mysql`
"httpsEnabled": 0, // enabled https , 0:enabled 1:enablde 2:disenabled
"httpPort": 80, // http port . default 80
"httpsPort": 443, // https port . default 443
"spamFilterLevel": 0,// Spam filter level, 0: no filter, 1: filtering when `spf` and `dkim` don't pass, 2: filtering when `spf` don't pass
"weChatPushAppId": "", // wechat appid
"weChatPushSecret": "", // weChat Secret
"weChatPushTemplateId": "", // weChat TemplateId
"weChatPushUserId": "", // weChat UserId
"tgChatId": "", // telegram chatid
"tgBotToken": "", // telegram token
"isInit": true // If false, it will enter the bootstrap process.
}
```
# For Developer
## Project Framework

View File

@ -53,7 +53,11 @@ PMail是一个追求极简部署流程、极致资源占用的个人域名邮箱
## 2、运行
双击打开 OR 执行命令运行
`./pmail`
或者
`docker run -p 25:25 -p 80:80 -p 443:443 -p 465:465 -v $(pwd)/config:/work/config ghcr.io/jinnrry/pmail:latest`
## 3、配置
@ -70,6 +74,34 @@ PMail是一个追求极简部署流程、极致资源占用的个人域名邮箱
## 6、Telegram推送
从 [BotFather](https://t.me/BotFather) 创建并获取令牌机器人。 打开运行目录下的 config/config.json 文件,编辑 `tg` 开头的几个配置项,重启服务即可。
# 配置文件说明
```json
{
"logLevel": "info", //日志输出级别
"domain": "domain.com", // 你的域名
"webDomain": "mail.domain.com", // web域名
"dkimPrivateKeyPath": "config/dkim/dkim.priv", // dkim 私钥地址
"sslType": "0", // ssl证书更新模式0自动1手动
"SSLPrivateKeyPath": "config/ssl/private.key", // ssl 证书地址
"SSLPublicKeyPath": "config/ssl/public.crt", // ssl 证书地址
"dbDSN": "./config/pmail.db", // 数据库连接DSN
"dbType": "sqlite", //数据库类型支持sqlite 和 mysql
"httpsEnabled": 0, // web后台是否启用https 0默认启用1启用2不启用
"spamFilterLevel": 0,// 垃圾邮件过滤级别0不过滤、1 spf dkim 校验均失败时过滤2 spf校验不通过时过滤
"httpPort": 80, // http 端口 . 默认 80
"httpsPort": 443, // https 端口 . 默认 443
"weChatPushAppId": "", // 微信推送appid
"weChatPushSecret": "", // 微信推送秘钥
"weChatPushTemplateId": "", // 微信推送模板id
"weChatPushUserId": "", // 微信推送用户id
"tgChatId": "", // telegram 推送chatid
"tgBotToken": "", // telegram 推送 token
"isInit": true // 为false的时候会进入安装引导流程
}
```
# 参与开发
## 项目架构

2505
fe/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,13 +9,17 @@
</el-icon>
</div>
<el-drawer v-model="openSettings" size="80%" :title="lang.settings">
<el-tabs tab-position="left" >
<el-tabs tab-position="left">
<el-tab-pane :label="lang.security">
<SecuritySettings/>
<SecuritySettings />
</el-tab-pane>
<el-tab-pane :label="lang.group_settings">
<GroupSettings/>
<GroupSettings />
</el-tab-pane>
<el-tab-pane :label="lang.rule_setting">
<RuleSettings />
</el-tab-pane>
</el-tabs>
</el-drawer>
@ -30,7 +34,7 @@ import { ElMessageBox } from 'element-plus'
import SecuritySettings from '@/components/SecuritySettings.vue'
import lang from '../i18n/i18n';
import GroupSettings from './GroupSettings.vue';
import RuleSettings from './RuleSettings.vue';
const openSettings = ref(false)
const settings = function () {

View File

@ -0,0 +1,237 @@
<template>
<el-table :data="data" :show-header="true">
<el-table-column prop="id" label="id" />
<el-table-column prop="name" :label="lang.rule_name" />
<el-table-column prop="action" :label="lang.rule_do">
<template #default="scope">
{{ ActionName[scope.row.action] }}
</template>
</el-table-column>
<el-table-column prop="params" :label="lang.rule_params" />
<el-table-column prop="sort" :label="lang.rule_priority" />
<el-table-column>
<template #default="scope">
<div style="display: flex; align-items: center">
<el-button size="small" type="primary" :icon="Edit" circle @click="editRule(scope.row)" />
<el-popconfirm confirm-button-text="Yes" cancel-button-text="No, Thanks" :icon="InfoFilled"
@confirm="delRule(scope.row.id)" icon-color="#626AEF" :title="lang.del_rule_confirm">
<template #reference>
<el-button size="small" type="danger" :icon="Delete" circle />
</template>
</el-popconfirm>
</div>
</template>
</el-table-column>
</el-table>
<div>
<el-button @click="dialogVisible = true">{{ lang.new_rule }}</el-button>
</div>
<el-dialog v-model="dialogVisible" :title="lang.new_rule" width="60%">
<div style="text-align: left; padding-left: 20px;">
<el-form v-model="addRuleForm" :inline="true" label-position="top">
<el-form-item style="width: 400px;" :label="lang.rule_name">
<el-input v-model="addRuleForm.name" />
</el-form-item>
<el-form-item :label="lang.rule_priority">
<el-input v-model="addRuleForm.sort" type="number" oninput="value=value.replace(/[^\-\d]/g, '')" />
</el-form-item>
<el-divider />
<div style="width: 100%;">{{ lang.rule_desc }}</div>
<div style="width: 100%;">
<div v-for="(rule, index) in addRuleForm.rules">
<el-select v-model="rule.field" placeholder="Select">
<el-option key="From" :label="lang.from" value="From" />
<el-option key="Subject" :label="lang.subject" value="Subject" />
<el-option key="To" :label="lang.to" value="To" />
<el-option key="Cc" :label="lang.cc" value="Cc" />
<el-option key="Content" :label="lang.content" value="Content" />
</el-select>
<el-select v-model="rule.type" placeholder="Select">
<el-option key="equal" :label="lang.equal" value="equal" />
<el-option key="contains" :label="lang.contains" value="contains" />
<el-option key="regex" :label="lang.regex" value="regex" />
</el-select>
<el-input v-model="rule.rule" style="width: 350px;" />
<el-button size="small" type="danger" :icon="Delete" @click="removeRuleLine(index)" circle />
</div>
</div>
<div style="padding-top: 7px;">
<el-button size="small" type="primary" :icon="Plus" circle @click="addRule()" />
</div>
<el-divider />
<div style="width: 100%;">{{ lang.rule_do }}</div>
<el-form-item>
<el-select v-model="addRuleForm.action" placeholder="Select" @change="ruleTypeChange()">
<el-option key="mark_read" :label="lang.mark_read" :value="READ" />
<el-option key="move" :label="lang.move" :value="MOVE" />
<el-option key="delete" :label="lang.delete" :value="DELETE" />
<el-option key="forward" :label="lang.forward" :value="FORWARD" />
</el-select>
<el-select v-if="addRuleForm.action == 4" v-model="addRuleForm.params" @click="reflushGroupInfos">
<el-option v-for="gp in groupData.list" :key="gp.id" :label="gp.name" :value="gp.id" />
</el-select>
<el-input v-if="addRuleForm.action == 2" v-model="addRuleForm.params" style="width: 250px;"
placeholder="Forward Email Address" />
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button type="primary" @click="submitRule()">
{{ lang.submit }}
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive } from 'vue';
import $http from "../http/http";
import lang from '../i18n/i18n';
import {
Plus,
Delete,
Edit,
InfoFilled
} from '@element-plus/icons-vue'
const data = ref([])
const dialogVisible = ref(false)
const READ = 1
const FORWARD = 2
const DELETE = 3
const MOVE = 4
const ActionName = {
1: lang.mark_read,
2: lang.forward,
3: lang.delete,
4: lang.move
}
const init = function () {
$http.post("/api/rule/get").then((res) => {
data.value = res.data
})
}
init()
const groupData = reactive({
list: []
})
const reflushGroupInfos = function () {
$http.get('/api/group/list').then(res => {
groupData.list = res.data
for (let i = 0; i < groupData.list.length; i++) {
groupData.list[i].id += ""
}
})
}
reflushGroupInfos()
const addRuleForm = reactive({
"id": 0,
"name": "",
"sort": 0,
"rules": [
{
"field": "",
"type": "",
"rule": ""
}
],
"action": "",
"params": ""
})
const delRule = function (id) {
$http.post("/api/rule/del", { "id": id }).then((res) => {
ElNotification({
title: res.errorNo == 0 ? lang.succ : lang.fail,
message: res.data,
type: res.errorNo == 0 ? 'success' : 'error',
})
init()
})
}
const editRule = function (ruleInfo) {
addRuleForm.id = ruleInfo.id
addRuleForm.name = ruleInfo.name
addRuleForm.rules = ruleInfo.rules
addRuleForm.action = ruleInfo.action
addRuleForm.params = ruleInfo.params
addRuleForm.sort = ruleInfo.sort
dialogVisible.value = true
}
const removeRuleLine = function (index) {
addRuleForm.rules.splice(index, 1);
}
const addRule = function () {
addRuleForm.rules.push(
{
"field": "",
"type": "",
"rule": ""
}
)
}
const submitRule = function () {
let api = "/api/rule/add"
if (addRuleForm.id > 0) {
api = "/api/rule/update"
}
addRuleForm.sort = parseInt(addRuleForm.sort)
$http.post(api, addRuleForm).then((res) => {
if (res.errorNo != 0) {
ElNotification({
title: lang.fail,
message: res.data,
type: 'error',
})
} else {
init()
dialogVisible.value = false
addRuleForm.id = 0
addRuleForm.name = ""
addRuleForm.sort = 0
addRuleForm.rules = [
{
"field": "",
"type": "",
"rule": ""
}
]
addRuleForm.action = ""
addRuleForm.params = ""
}
})
}
const ruleTypeChange = function () {
addRuleForm.params = ''
}
</script>

View File

@ -33,6 +33,11 @@ const rules = reactive({
})
const submit = function () {
if (ruleForm.new_pwd == ""){
return
}
if (ruleForm.new_pwd != ruleForm.new_pwd2) {
ElNotification({
title: 'Error',

View File

@ -64,7 +64,28 @@ var lang = {
"move_email_confirm": "Are you sure you want to move them?",
"del_btn": "Delete",
"move_btn": "Move",
"read_btn": "Readed"
"read_btn": "Readed",
"dangerous":"The content of this email is not secure!",
"rule_setting":"Rules",
"new_rule":"New mail receiving rules",
"rule_name":"Rule Name",
"rule_priority":"Rule Priority(Larger values are executed first)",
"rule_desc":"When a new message arrives that meets all these conditions:",
"rule_do":"Do the following:",
"from":"From Email Address",
"subject":"Email Subject",
"to":"Recipient's address",
"cc":"Cc's address",
"content":"Email Content",
"equal":"Equal",
"regex":"Regex Match",
"contains":"Contains",
"mark_read":"Mark Read",
"delete":"Delete",
"forward":"Forward",
"move":"Move to group",
"del_rule_confirm":"Are you sure to delete this?",
"rule_params":"Executed params",
};
@ -135,7 +156,28 @@ var zhCN = {
"move_email_confirm": "你确定要移动这些邮件吗?",
"del_btn": "删除",
"move_btn": "移动",
"read_btn": "已读"
"read_btn": "已读",
"dangerous":"该邮件内容不安全!",
"rule_setting":"规则",
"new_rule":"新建收信规则",
"rule_name":"规则名称",
"rule_priority":"优先级(值越大越先执行)",
"rule_desc":"以下规则全部满足时:",
"rule_do":"执行操作:",
"from":"发件人地址",
"subject":"邮件主题",
"to":"收件人地址",
"cc":"抄送地址",
"content":"邮件内容",
"equal":"等于",
"regex":"正则匹配",
"contains":"包含",
"mark_read":"标记已读",
"delete":"删除",
"forward":"转发",
"move":"移动分组",
"del_rule_confirm":"确定要删除吗?",
"rule_params":"执行参数",
}
switch (navigator.language) {

View File

@ -32,6 +32,14 @@
<span v-if="!scope.row.is_read">
{{ lang.new }}
</span>
<span style="font-weight: 900;color: #FF0000;" v-if="scope.row.dangerous">
<el-tooltip effect="dark"
:content="lang.dangerous"
placement="top-start">
!
</el-tooltip>
</span>
</div>
</template>
</el-table-column>

View File

@ -28,7 +28,7 @@
<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">
<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>
@ -40,7 +40,7 @@
</el-form-item>
<el-form-item :label="lang.sqlite_db_path" v-if="dbSettings.type == 'sqlite'">
<el-input v-model="dbSettings.dsn" placeholder="./pmail.db"></el-input>
<el-input v-model="dbSettings.dsn" placeholder="./config/pmail.db"></el-input>
</el-form-item>
</el-form>
</div>
@ -170,7 +170,7 @@ const adminSettings = reactive({
const dbSettings = reactive({
"type": "sqlite",
"dsn": "./pmail.db",
"dsn": "./config/pmail.db",
"lable": ""
})

View File

@ -6,8 +6,11 @@
"sslType": "0",
"SSLPrivateKeyPath": "config/ssl/private.key",
"SSLPublicKeyPath": "config/ssl/public.crt",
"dbDSN": "./pmail.db",
"dbDSN": "./config/pmail.db",
"dbType": "sqlite",
"spamFilterLevel": 2,
"httpPort": 80,
"httpsPort": 443,
"weChatPushAppId": "",
"weChatPushSecret": "",
"weChatPushTemplateId": "",

View File

@ -20,6 +20,10 @@ type Config struct {
SSLPublicKeyPath string `json:"SSLPublicKeyPath"`
DbDSN string `json:"dbDSN"`
DbType string `json:"dbType"`
HttpsEnabled int `json:"httpsEnabled"` //后台页面是否启用https0默认启用1启用2不启用
SpamFilterLevel int `json:"spamFilterLevel"` //垃圾邮件过滤级别0不过滤、1 spf dkim 校验均失败时过滤2 spf校验不通过时过滤
HttpPort int `json:"httpPort"` //http服务端口设置默认80
HttpsPort int `json:"httpsPort"` //https服务端口默认443
WeChatPushAppId string `json:"weChatPushAppId"`
WeChatPushSecret string `json:"weChatPushSecret"`
WeChatPushTemplateId string `json:"weChatPushTemplateId"`
@ -27,7 +31,6 @@ type Config struct {
TgBotToken string `json:"tgBotToken"`
TgChatId string `json:"tgChatId"`
IsInit bool `json:"isInit"`
HttpsEnabled int `json:"httpsEnabled"` //后台页面是否启用https0默认启用1启用2不启用
Tables map[string]string `json:"-"`
TablesInitData map[string]string `json:"-"`
}
@ -35,7 +38,7 @@ type Config struct {
//go:embed tables/*
var tableConfig embed.FS
const Version = "2.1.2"
const Version = "2.2.0"
const DBTypeMySQL = "mysql"
const DBTypeSQLite = "sqlite"

View File

@ -6,8 +6,11 @@
"sslType": "0",
"SSLPrivateKeyPath": "config/ssl/private.key",
"SSLPublicKeyPath": "config/ssl/public.crt",
"dbDSN": "./pmail.db",
"dbDSN": "./config/pmail.db",
"dbType": "sqlite",
"spamFilterLevel": 2,
"httpPort": 80,
"httpsPort": 443,
"weChatPushAppId": "",
"weChatPushSecret": "",
"weChatPushTemplateId": "",
@ -15,5 +18,5 @@
"tgChatId": "",
"tgBotToken": "",
"isInit": true,
"httpsEnabled": 2
"httpsEnabled": 1
}

View File

@ -8,6 +8,9 @@
"SSLPublicKeyPath": "config/ssl/public.crt",
"dbDSN": "root:root@tcp(127.0.0.1:3306)/pmail?parseTime=True&loc=Local",
"dbType": "mysql",
"spamFilterLevel": 2,
"httpPort": 80,
"httpsPort": 443,
"weChatPushAppId": "",
"weChatPushSecret": "",
"weChatPushTemplateId": "",

View File

@ -16,7 +16,7 @@ CREATE table email
attachments json COMMENT '附件内容',
spf_check tinyint(1) DEFAULT 0 COMMENT '0未校验1校验通过2校验未通过',
dkim_check tinyint(1) DEFAULT 0 COMMENT '0未校验1校验通过2校验未通过',
status tinyint(4) NOT NULL DEFAULT 0 COMMENT '0未发送1已发送2发送失败',
status tinyint(4) NOT NULL DEFAULT 0 COMMENT '0未发送1已发送2发送失败3删除',
send_user_id int unsigned NOT NULL DEFAULT 0 COMMENT '发件人用户id',
is_read tinyint(1) NOT NULL DEFAULT 0 COMMENT '未读0已读1',
error text COMMENT '错误信息记录',

View File

@ -0,0 +1,10 @@
CREATE TABLE `rule`
(
id INT unsigned AUTO_INCREMENT PRIMARY KEY COMMENT '自增id',
user_id int NOT NULL DEFAULT 0 COMMENT '用户id',
`name` varchar(255) NOT NULL DEFAULT '' COMMENT '规则名称',
`value` json NOT NULL COMMENT '规则内容',
action int not null default 0 comment '执行动作,1已读2转发3删除',
params varchar(255) not null default '' comment '执行参数',
sort int not null default 0 COMMENT '排序,越大约优先'
) COMMENT '收信规则表'

View File

@ -0,0 +1,10 @@
create table rule
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id int,
name varchar(255) default '' not null,
value json not null,
action int default 0 not null,
params varchar(255) default '' not null,
sort int default 0 not null
)

View File

@ -4,13 +4,13 @@ import (
"fmt"
"github.com/spf13/cast"
"net/http"
"pmail/dto"
"pmail/dto/response"
"pmail/services/attachments"
"pmail/utils/context"
"strings"
)
func GetAttachments(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func GetAttachments(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
urlInfos := strings.Split(req.RequestURI, "/")
if len(urlInfos) != 4 {
response.NewErrorResponse(response.ParamsError, "", "").FPrint(w)
@ -29,7 +29,7 @@ func GetAttachments(ctx *dto.Context, w http.ResponseWriter, req *http.Request)
w.Write(content)
}
func Download(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func Download(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
urlInfos := strings.Split(req.RequestURI, "/")
if len(urlInfos) != 5 {
response.NewErrorResponse(response.ParamsError, "", "").FPrint(w)

View File

@ -2,7 +2,7 @@ package controllers
import (
"net/http"
"pmail/dto"
"pmail/utils/context"
)
type HandlerFunc func(*dto.Context, http.ResponseWriter, *http.Request)
type HandlerFunc func(*context.Context, http.ResponseWriter, *http.Request)

View File

@ -5,16 +5,16 @@ import (
log "github.com/sirupsen/logrus"
"io"
"net/http"
"pmail/dto"
"pmail/dto/response"
"pmail/services/del_email"
"pmail/utils/context"
)
type emailDeleteRequest struct {
IDs []int `json:"ids"`
}
func EmailDelete(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func EmailDelete(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
log.WithContext(ctx).Errorf("%+v", err)

View File

@ -5,17 +5,17 @@ import (
log "github.com/sirupsen/logrus"
"io"
"net/http"
"pmail/dto"
"pmail/dto/response"
"pmail/services/auth"
"pmail/services/detail"
"pmail/utils/context"
)
type emailDetailRequest struct {
ID int `json:"id"`
}
func EmailDetail(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func EmailDetail(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
log.WithContext(ctx).Errorf("%+v", err)

View File

@ -7,9 +7,9 @@ import (
"io"
"math"
"net/http"
"pmail/dto"
"pmail/dto/response"
"pmail/services/list"
"pmail/utils/context"
)
type emailListResponse struct {
@ -19,12 +19,13 @@ type emailListResponse struct {
}
type emilItem struct {
ID int `json:"id"`
Title string `json:"title"`
Desc string `json:"desc"`
Datetime string `json:"datetime"`
IsRead bool `json:"is_read"`
Sender User `json:"sender"`
ID int `json:"id"`
Title string `json:"title"`
Desc string `json:"desc"`
Datetime string `json:"datetime"`
IsRead bool `json:"is_read"`
Sender User `json:"sender"`
Dangerous bool `json:"dangerous"`
}
type User struct {
@ -39,7 +40,7 @@ type emailRequest struct {
PageSize int `json:"page_size"`
}
func EmailList(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func EmailList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var lst []*emilItem
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
@ -67,12 +68,13 @@ func EmailList(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
_ = json.Unmarshal([]byte(email.Sender), &sender)
lst = append(lst, &emilItem{
ID: email.Id,
Title: email.Subject,
Desc: email.Text.String,
Datetime: email.SendDate.Format("2006-01-02 15:04:05"),
IsRead: email.IsRead == 1,
Sender: sender,
ID: email.Id,
Title: email.Subject,
Desc: email.Text.String,
Datetime: email.SendDate.Format("2006-01-02 15:04:05"),
IsRead: email.IsRead == 1,
Sender: sender,
Dangerous: email.SPFCheck == 0 && email.DKIMCheck == 0,
})
}

View File

@ -5,9 +5,9 @@ import (
log "github.com/sirupsen/logrus"
"io"
"net/http"
"pmail/dto"
"pmail/dto/response"
"pmail/services/group"
"pmail/utils/context"
)
type moveRequest struct {
@ -15,7 +15,7 @@ type moveRequest struct {
IDs []int `json:"ids"`
}
func Move(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func Move(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
log.WithContext(ctx).Errorf("%+v", err)

View File

@ -5,16 +5,16 @@ import (
log "github.com/sirupsen/logrus"
"io"
"net/http"
"pmail/dto"
"pmail/dto/response"
"pmail/services/detail"
"pmail/utils/context"
)
type markReadRequest struct {
IDs []int `json:"ids"`
}
func MarkRead(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func MarkRead(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
log.WithContext(ctx).Errorf("%+v", err)

View File

@ -8,13 +8,13 @@ import (
"net/http"
"pmail/config"
"pmail/db"
"pmail/dto"
"pmail/dto/parsemail"
"pmail/dto/response"
"pmail/hooks"
"pmail/i18n"
"pmail/smtp_server"
"pmail/utils/async"
"pmail/utils/context"
"pmail/utils/send"
"strings"
"time"
)
@ -43,7 +43,7 @@ type attachment struct {
Data string `json:"data"`
}
func Send(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
log.WithContext(ctx).Errorf("%+v", err)
@ -157,7 +157,7 @@ func Send(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
1,
1,
time.Now(),
ctx.UserInfo.ID,
ctx.UserID,
"",
)
emailId, _ := sqlRes.LastInsertId()
@ -170,7 +170,7 @@ func Send(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
async.New(ctx).Process(func(p any) {
errMsg := ""
err, sendErr := smtp_server.Send(ctx, e)
err, sendErr := send.Send(ctx, e)
as2 := async.New(ctx)
for _, hook := range hooks.HookList {

View File

@ -11,14 +11,15 @@ import (
"pmail/i18n"
"pmail/services/group"
"pmail/utils/array"
"pmail/utils/context"
)
func GetUserGroupList(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func GetUserGroupList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
infos := group.GetGroupList(ctx)
response.NewSuccessResponse(infos).FPrint(w)
}
func GetUserGroup(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func GetUserGroup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
retData := []*group.GroupItem{
{
@ -50,7 +51,7 @@ type addGroupRequest struct {
ParentId int `json:"parent_id"`
}
func AddGroup(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func AddGroup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var reqData *addGroupRequest
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
@ -61,7 +62,7 @@ func AddGroup(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
log.WithContext(ctx).Errorf("%+v", err)
}
res, err := db.Instance.Exec(db.WithContext(ctx, "insert into `group` (name,parent_id,user_id) values (?,?,?)"), reqData.Name, reqData.ParentId, ctx.UserInfo.ID)
res, err := db.Instance.Exec(db.WithContext(ctx, "insert into `group` (name,parent_id,user_id) values (?,?,?)"), reqData.Name, reqData.ParentId, ctx.UserID)
if err != nil {
response.NewErrorResponse(response.ServerError, "DBError", err.Error()).FPrint(w)
return
@ -78,7 +79,7 @@ type delGroupRequest struct {
Id int `json:"id"`
}
func DelGroup(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func DelGroup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
var reqData *delGroupRequest
reqBytes, err := io.ReadAll(req.Body)
if err != nil {

View File

@ -7,11 +7,11 @@ import (
"io"
"net/http"
"pmail/db"
"pmail/dto"
"pmail/dto/response"
"pmail/i18n"
"pmail/models"
"pmail/session"
"pmail/utils/context"
"pmail/utils/password"
)
@ -20,7 +20,7 @@ type loginRequest struct {
Password string `json:"password"`
}
func Login(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func Login(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
reqBytes, err := io.ReadAll(req.Body)
if err != nil {

View File

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

View File

@ -0,0 +1,89 @@
package controllers
import (
"encoding/json"
log "github.com/sirupsen/logrus"
"io"
"net/http"
"pmail/db"
"pmail/dto"
"pmail/dto/response"
"pmail/i18n"
"pmail/services/rule"
"pmail/utils/address"
"pmail/utils/array"
"pmail/utils/context"
)
func GetRule(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
res := rule.GetAllRules(ctx)
response.NewSuccessResponse(res).FPrint(w)
}
func UpsertRule(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
requestBody, err := io.ReadAll(req.Body)
if err != nil {
log.WithContext(ctx).Errorf("ReadError:%v", err)
return
}
var data *dto.Rule
err = json.Unmarshal(requestBody, &data)
if err != nil {
response.NewErrorResponse(response.ParamsError, "params error", err).FPrint(w)
return
}
if data.Action == dto.FORWARD && !address.IsValidEmailAddress(data.Params) {
response.NewErrorResponse(response.ParamsError, "ParamsError error", i18n.GetText(ctx.Lang, "invalid_email_address")).FPrint(w)
return
}
for _, r := range data.Rules {
if !array.InArray(r.Field, []string{"From", "Subject", "To", "Cc", "Text", "Html", "Content"}) {
response.NewErrorResponse(response.ParamsError, "ParamsError error", "params error! Rule Field Error!").FPrint(w)
return
}
}
err = data.Encode().Save(ctx)
if err != nil {
response.NewErrorResponse(response.ServerError, "server error", err).FPrint(w)
return
}
response.NewSuccessResponse("succ").FPrint(w)
}
type delRuleReq struct {
Id int `json:"id"`
}
func DelRule(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
requestBody, err := io.ReadAll(req.Body)
if err != nil {
log.WithContext(ctx).Errorf("ReadError:%v", err)
return
}
var data delRuleReq
err = json.Unmarshal(requestBody, &data)
if err != nil {
response.NewErrorResponse(response.ParamsError, "params error", err).FPrint(w)
return
}
if data.Id <= 0 {
response.NewErrorResponse(response.ParamsError, "params error", "id is empty").FPrint(w)
return
}
_, err = db.Instance.Exec(db.WithContext(ctx, "delete from rule where id =? and user_id =?"), data.Id, ctx.UserID)
if err != nil {
response.NewErrorResponse(response.ServerError, "unknown error", err).FPrint(w)
return
}
response.NewSuccessResponse("succ").FPrint(w)
}

View File

@ -6,9 +6,9 @@ import (
"io"
"net/http"
"pmail/db"
"pmail/dto"
"pmail/dto/response"
"pmail/i18n"
"pmail/utils/context"
"pmail/utils/password"
)
@ -16,7 +16,7 @@ type modifyPasswordRequest struct {
Password string `json:"password"`
}
func ModifyPassword(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
log.Errorf("%+v", err)
@ -30,7 +30,7 @@ func ModifyPassword(ctx *dto.Context, w http.ResponseWriter, req *http.Request)
if retData.Password != "" {
encodePwd := password.Encode(retData.Password)
_, err := db.Instance.Exec(db.WithContext(ctx, "update user set password = ? where id =?"), encodePwd, ctx.UserInfo.ID)
_, err := db.Instance.Exec(db.WithContext(ctx, "update user set password = ? where id =?"), encodePwd, ctx.UserID)
if err != nil {
response.NewErrorResponse(response.ServerError, i18n.GetText(ctx.Lang, "unknowError"), "").FPrint(w)
return

View File

@ -5,10 +5,10 @@ import (
"io"
"net/http"
"pmail/config"
"pmail/dto"
"pmail/dto/response"
"pmail/services/setup"
"pmail/services/setup/ssl"
"pmail/utils/context"
"strings"
)
@ -23,7 +23,7 @@ func AcmeChallenge(w http.ResponseWriter, r *http.Request) {
}
}
func Setup(ctx *dto.Context, w http.ResponseWriter, req *http.Request) {
func Setup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
reqBytes, err := io.ReadAll(req.Body)
if err != nil {
response.NewSuccessResponse("").FPrint(w)

View File

@ -7,7 +7,7 @@ import (
log "github.com/sirupsen/logrus"
_ "modernc.org/sqlite"
"pmail/config"
"pmail/dto"
"pmail/utils/context"
"pmail/utils/errors"
"strings"
)
@ -38,9 +38,9 @@ func Init() error {
return nil
}
func WithContext(ctx *dto.Context, sql string) string {
func WithContext(ctx *context.Context, sql string) string {
if ctx != nil {
logId := ctx.GetValue(dto.LogID)
logId := ctx.GetValue(context.LogID)
return fmt.Sprintf("/* %s */ %s", logId, sql)
}
return sql

View File

@ -8,8 +8,8 @@ import (
log "github.com/sirupsen/logrus"
"io"
"net/textproto"
"pmail/dto"
"pmail/utils/array"
"pmail/utils/context"
"regexp"
"strings"
"time"
@ -42,6 +42,9 @@ type Email struct {
Attachments []*Attachment
ReadReceipt []string
Date string
IsRead int
Status int // 0未发送1已发送2发送失败3删除
GroupId int // 分组id
}
func NewEmailFromReader(r io.Reader) *Email {
@ -166,7 +169,85 @@ func buildUsers(str []string) []*User {
return ret
}
func (e *Email) BuildBytes(ctx *dto.Context) []byte {
func (e *Email) ForwardBuildBytes(ctx *context.Context, forwardAddress string) []byte {
var b bytes.Buffer
from := []*mail.Address{{e.From.Name, e.From.EmailAddress}}
to := []*mail.Address{
{
Address: forwardAddress,
},
}
// Create our mail header
var h mail.Header
h.SetDate(time.Now())
h.SetAddressList("From", from)
h.SetAddressList("To", to)
h.SetText("Subject", e.Subject)
if len(e.Cc) != 0 {
cc := []*mail.Address{}
for _, user := range e.Cc {
cc = append(cc, &mail.Address{
Name: user.Name,
Address: user.EmailAddress,
})
}
h.SetAddressList("Cc", cc)
}
// Create a new mail writer
mw, err := mail.CreateWriter(&b, h)
if err != nil {
log.WithContext(ctx).Fatal(err)
}
// Create a text part
tw, err := mw.CreateInline()
if err != nil {
log.WithContext(ctx).Fatal(err)
}
var th mail.InlineHeader
th.Set("Content-Type", "text/plain")
w, err := tw.CreatePart(th)
if err != nil {
log.Fatal(err)
}
io.WriteString(w, string(e.Text))
w.Close()
var html mail.InlineHeader
html.Set("Content-Type", "text/html")
w, err = tw.CreatePart(html)
if err != nil {
log.Fatal(err)
}
io.WriteString(w, string(e.HTML))
w.Close()
tw.Close()
// Create an attachment
for _, attachment := range e.Attachments {
var ah mail.AttachmentHeader
ah.Set("Content-Type", attachment.ContentType)
ah.SetFilename(attachment.Filename)
w, err = mw.CreateAttachment(ah)
if err != nil {
log.WithContext(ctx).Fatal(err)
continue
}
w.Write(attachment.Content)
w.Close()
}
mw.Close()
// dkim 签名后返回
return instance.Sign(b.String())
}
func (e *Email) BuildBytes(ctx *context.Context) []byte {
var b bytes.Buffer
from := []*mail.Address{{e.From.Name, e.From.EmailAddress}}

54
server/dto/rule.go Normal file
View File

@ -0,0 +1,54 @@
package dto
import (
"encoding/json"
"pmail/models"
)
type RuleType int
// 1已读2转发3删除
var (
READ RuleType = 1
FORWARD RuleType = 2
DELETE RuleType = 3
MOVE RuleType = 4
)
type Rule struct {
Id int `json:"id"`
Name string `json:"name"`
Rules []*Value `json:"rules"`
Action RuleType `json:"action"`
Params string `json:"params"`
Sort int `json:"sort"`
}
type Value struct {
Field string `json:"field"`
Type string `json:"type"`
Rule string `json:"rule"`
}
func (p *Rule) Decode(data *models.Rule) *Rule {
json.Unmarshal([]byte(data.Value), &p.Rules)
p.Id = data.Id
p.Name = data.Name
p.Action = RuleType(data.Action)
p.Sort = data.Sort
p.Params = data.Params
return p
}
func (p *Rule) Encode() *models.Rule {
v, _ := json.Marshal(p.Rules)
ret := &models.Rule{
Id: p.Id,
Name: p.Name,
Value: string(v),
Action: int(p.Action),
Sort: p.Sort,
Params: p.Params,
}
return ret
}

View File

@ -1,17 +1,17 @@
package hooks
import (
"pmail/dto"
"pmail/dto/parsemail"
"pmail/hooks/telegram_push"
"pmail/hooks/wechat_push"
"pmail/utils/context"
)
type EmailHook interface {
// SendBefore 邮件发送前的数据
SendBefore(ctx *dto.Context, email *parsemail.Email)
SendBefore(ctx *context.Context, email *parsemail.Email)
// SendAfter 邮件发送后的数据err是每个收信服务器的错误信息
SendAfter(ctx *dto.Context, email *parsemail.Email, err map[string]error)
SendAfter(ctx *context.Context, email *parsemail.Email, err map[string]error)
// ReceiveParseBefore 接收到邮件,解析之前的原始数据
ReceiveParseBefore(email []byte)
// ReceiveParseAfter 接收到邮件,解析之后的结构化数据

View File

@ -5,8 +5,8 @@ import (
"fmt"
"net/http"
"pmail/config"
"pmail/dto"
"pmail/dto/parsemail"
"pmail/utils/context"
"strings"
log "github.com/sirupsen/logrus"
@ -19,11 +19,11 @@ type TelegramPushHook struct {
webDomain string
}
func (w *TelegramPushHook) SendBefore(ctx *dto.Context, email *parsemail.Email) {
func (w *TelegramPushHook) SendBefore(ctx *context.Context, email *parsemail.Email) {
}
func (w *TelegramPushHook) SendAfter(ctx *dto.Context, email *parsemail.Email, err map[string]error) {
func (w *TelegramPushHook) SendAfter(ctx *context.Context, email *parsemail.Email, err map[string]error) {
}
@ -55,7 +55,7 @@ type InlineKeyboardButton struct {
URL string `json:"url"`
}
func (w *TelegramPushHook) sendUserMsg(ctx *dto.Context, email *parsemail.Email) {
func (w *TelegramPushHook) sendUserMsg(ctx *context.Context, email *parsemail.Email) {
url := w.webDomain
if w.httpsEnabled > 1 {
url = "http://" + url

View File

@ -8,8 +8,8 @@ import (
"io"
"net/http"
"pmail/config"
"pmail/dto"
"pmail/dto/parsemail"
"pmail/utils/context"
"strings"
"time"
)
@ -28,11 +28,11 @@ type WeChatPushHook struct {
pushUser string
}
func (w *WeChatPushHook) SendBefore(ctx *dto.Context, email *parsemail.Email) {
func (w *WeChatPushHook) SendBefore(ctx *context.Context, email *parsemail.Email) {
}
func (w *WeChatPushHook) SendAfter(ctx *dto.Context, email *parsemail.Email, err map[string]error) {
func (w *WeChatPushHook) SendAfter(ctx *context.Context, email *parsemail.Email, err map[string]error) {
}
@ -45,7 +45,13 @@ func (w *WeChatPushHook) ReceiveParseAfter(email *parsemail.Email) {
return
}
w.sendUserMsg(nil, w.pushUser, string(email.Text))
content := string(email.Text)
if content == "" {
content = email.Subject
}
w.sendUserMsg(nil, w.pushUser, content)
}
func (w *WeChatPushHook) getWxAccessToken() string {
@ -80,11 +86,19 @@ type DataItem struct {
Color string `json:"color"`
}
func (w *WeChatPushHook) sendUserMsg(ctx *dto.Context, userId string, content string) {
func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, content string) {
url := config.Instance.WebDomain
if config.Instance.HttpsEnabled > 1 {
url = "http://" + url
} else {
url = "https://" + url
}
sendMsgReq, _ := json.Marshal(sendMsgRequest{
Touser: userId,
Template_id: w.templateId,
Url: "http://mail." + config.Instance.Domain,
Url: url,
Data: SendData{Content: DataItem{Value: content, Color: "#000000"}},
})

View File

@ -11,8 +11,6 @@ import (
"time"
)
const HttpPort = 80
// 这个服务是为了拦截http请求转发到https
var httpServer *http.Server
@ -25,6 +23,11 @@ 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{
@ -52,6 +55,10 @@ func HttpStart() {
mux.HandleFunc("/api/email/move", contextIterceptor(email.Move))
mux.HandleFunc("/api/email/send", contextIterceptor(email.Send))
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))
httpServer = &http.Server{

View File

@ -17,18 +17,17 @@ import (
"pmail/config"
"pmail/controllers"
"pmail/controllers/email"
"pmail/dto"
"pmail/dto/response"
"pmail/i18n"
"pmail/models"
"pmail/session"
"pmail/utils/context"
"time"
)
//go:embed dist/*
var local embed.FS
const HttpsPort = 443
var httpsServer *http.Server
type nullWrite struct {
@ -62,12 +61,21 @@ func HttpsStart() {
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))
// go http server会打一堆没用的日志写一个空的日志处理器屏蔽掉日志输出
nullLog := olog.New(&nullWrite{}, "", olog.Ldate)
HttpsPort := 443
if config.Instance.HttpsPort > 0 {
HttpsPort = config.Instance.HttpsPort
}
if config.Instance.HttpsEnabled != 2 {
httpsServer = &http.Server{
Addr: fmt.Sprintf(":%d", HttpsPort),
@ -117,9 +125,9 @@ func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc {
w.Header().Set("Content-Type", "application/json")
}
ctx := &dto.Context{}
ctx := &context.Context{}
ctx.Context = r.Context()
ctx.SetValue(dto.LogID, genLogID())
ctx.SetValue(context.LogID, genLogID())
lang := r.Header.Get("Lang")
if lang == "" {
lang = "en"
@ -128,10 +136,17 @@ func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc {
if config.IsInit {
user := cast.ToString(session.Instance.Get(ctx, "user"))
var userInfo *models.User
if user != "" {
_ = json.Unmarshal([]byte(user), &ctx.UserInfo)
_ = json.Unmarshal([]byte(user), &userInfo)
}
if ctx.UserInfo == nil || ctx.UserInfo.ID == 0 {
if userInfo != nil && userInfo.ID > 0 {
ctx.UserID = userInfo.ID
ctx.UserName = userInfo.Name
ctx.UserAccount = userInfo.Account
}
if ctx.UserID == 0 {
if r.URL.Path != "/api/ping" && r.URL.Path != "/api/login" {
response.NewErrorResponse(response.NeedLogin, i18n.GetText(ctx.Lang, "login_exp"), "").FPrint(w)
return

View File

@ -5,6 +5,7 @@ import (
"io/fs"
"net"
"net/http"
"pmail/config"
"pmail/controllers"
"time"
)
@ -25,6 +26,11 @@ func SetupStart() {
// 挑战请求类似这样 /.well-known/acme-challenge/QPyMAyaWw9s5JvV1oruyqWHG7OqkHMJEHPoUz2046KM
mux.HandleFunc("/.well-known/", controllers.AcmeChallenge)
HttpPort := 80
if config.Instance != nil && config.Instance.HttpPort > 0 {
HttpPort = config.Instance.HttpPort
}
setupServer = &http.Server{
Addr: fmt.Sprintf(":%d", HttpPort),
Handler: mux,

View File

@ -2,30 +2,32 @@ package i18n
var (
cn = map[string]string{
"all_email": "全部邮件数据",
"inbox": "收件箱",
"outbox": "发件箱",
"sketch": "草稿箱",
"aperror": "账号或密码错误",
"unknowError": "未知错误",
"succ": "成功",
"send_fail": "发送失败",
"att_err": "附件解码错误",
"login_exp": "登录已失效",
"ip_taps": "这是你服务器IP确保这个IP正确",
"all_email": "全部邮件数据",
"inbox": "收件箱",
"outbox": "发件箱",
"sketch": "草稿箱",
"aperror": "账号或密码错误",
"unknowError": "未知错误",
"succ": "成功",
"send_fail": "发送失败",
"att_err": "附件解码错误",
"login_exp": "登录已失效",
"ip_taps": "这是你服务器IP确保这个IP正确",
"invalid_email_address": "无效的邮箱地址!",
}
en = map[string]string{
"all_email": "All Email",
"inbox": "Inbox",
"outbox": "Outbox",
"sketch": "Sketch",
"aperror": "Incorrect account number or password",
"unknowError": "Unknow Error",
"succ": "Success",
"send_fail": "Send Failure",
"att_err": "Attachment decoding error",
"login_exp": "Login has expired.",
"ip_taps": "This is your server's IP, make sure it is correct.",
"all_email": "All Email",
"inbox": "Inbox",
"outbox": "Outbox",
"sketch": "Sketch",
"aperror": "Incorrect account number or password",
"unknowError": "Unknow Error",
"succ": "Success",
"send_fail": "Send Failure",
"att_err": "Attachment decoding error",
"login_exp": "Login has expired.",
"ip_taps": "This is your server's IP, make sure it is correct.",
"invalid_email_address": "Invalid e-mail address!",
}
)

View File

@ -6,8 +6,9 @@ import (
log "github.com/sirupsen/logrus"
"os"
"pmail/config"
"pmail/dto"
"pmail/cron_server"
"pmail/res_init"
"pmail/utils/context"
"time"
)
@ -21,7 +22,7 @@ func (l *logFormatter) Format(entry *log.Entry) ([]byte, error) {
b.WriteString(fmt.Sprintf("[%s]", entry.Level.String()))
b.WriteString(fmt.Sprintf("[%s]", entry.Time.Format("2006-01-02 15:04:05")))
if entry.Context != nil {
b.WriteString(fmt.Sprintf("[%s]", entry.Context.(*dto.Context).GetValue(dto.LogID)))
b.WriteString(fmt.Sprintf("[%s]", entry.Context.(*context.Context).GetValue(context.LogID)))
}
b.WriteString(fmt.Sprintf("[%s:%d]", entry.Caller.File, entry.Caller.Line))
b.WriteString(entry.Message)
@ -38,8 +39,6 @@ var (
func main() {
// 设置日志格式为json格式
//log.SetFormatter(&log.JSONFormatter{})
log.SetFormatter(&logFormatter{})
log.SetReportCaller(true)
@ -79,7 +78,7 @@ func main() {
log.Infoln("***************************************************")
// 定时任务启动
//go cron_server.Start()
go cron_server.Start()
// 核心服务启动
res_init.Init()

View File

@ -23,7 +23,7 @@ type Email struct {
Attachments string `db:"attachments" json:"attachments"`
SPFCheck int8 `db:"spf_check" json:"spf_check"`
DKIMCheck int8 `db:"dkim_check" json:"dkim_check"`
Status int8 `db:"status" json:"status"`
Status int8 `db:"status" json:"status"` // 0未发送1已发送2发送失败3删除
CronSendTime time.Time `db:"cron_send_time" json:"cron_send_time"`
UpdateTime time.Time `db:"update_time" json:"update_time"`
SendUserID int `db:"send_user_id" json:"send_user_id"`

35
server/models/rule.go Normal file
View File

@ -0,0 +1,35 @@
package models
import (
"pmail/db"
"pmail/utils/context"
"pmail/utils/errors"
)
type Rule struct {
Id int `db:"id" json:"id"`
UserId string `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
Value string `db:"value" json:"value"`
Action int `db:"action" json:"action"`
Params string `db:"params" json:"params"`
Sort int `db:"sort" json:"sort"`
}
func (p *Rule) Save(ctx *context.Context) error {
if p.Id > 0 {
_, err := db.Instance.Exec(db.WithContext(ctx, "update rule set name=? ,value = ? ,action = ?,params = ?,sort = ? where id = ?"), p.Name, p.Value, p.Action, p.Params, p.Sort, p.Id)
if err != nil {
return errors.Wrap(err)
}
return nil
} else {
_, err := db.Instance.Exec(db.WithContext(ctx, "insert into rule (name,value,user_id,action,params,sort) values (?,?,?,?,?,?)"), p.Name, p.Value, ctx.UserID, p.Action, p.Params, p.Sort)
if err != nil {
return errors.Wrap(err)
}
return nil
}
}

View File

@ -4,13 +4,13 @@ import (
"encoding/json"
log "github.com/sirupsen/logrus"
"pmail/db"
"pmail/dto"
"pmail/dto/parsemail"
"pmail/models"
"pmail/services/auth"
"pmail/utils/context"
)
func GetAttachments(ctx *dto.Context, emailId int, cid string) (string, []byte) {
func GetAttachments(ctx *context.Context, emailId int, cid string) (string, []byte) {
// 获取邮件内容
var email models.Email
@ -35,7 +35,7 @@ func GetAttachments(ctx *dto.Context, emailId int, cid string) (string, []byte)
return "", nil
}
func GetAttachmentsByIndex(ctx *dto.Context, emailId int, index int) (string, []byte) {
func GetAttachmentsByIndex(ctx *context.Context, emailId int, index int) (string, []byte) {
// 获取邮件内容
var email models.Email

View File

@ -10,16 +10,16 @@ import (
log "github.com/sirupsen/logrus"
"os"
"pmail/db"
"pmail/dto"
"pmail/models"
"pmail/utils/context"
"strings"
)
// HasAuth 检查当前用户是否有某个邮件的auth
func HasAuth(ctx *dto.Context, email *models.Email) bool {
func HasAuth(ctx *context.Context, email *models.Email) bool {
// 获取当前用户的auth
var auth []models.UserAuth
err := db.Instance.Select(&auth, db.WithContext(ctx, "select * from user_auth where user_id = ?"), ctx.UserInfo.ID)
err := db.Instance.Select(&auth, db.WithContext(ctx, "select * from user_auth where user_id = ?"), ctx.UserID)
if err != nil {
log.WithContext(ctx).Errorf("SQL error:%+v", err)
return false
@ -30,7 +30,7 @@ func HasAuth(ctx *dto.Context, email *models.Email) bool {
if userAuth.EmailAccount == "*" {
hasAuth = true
break
} else if strings.Contains(email.Bcc, ctx.UserInfo.Account) || strings.Contains(email.Cc, ctx.UserInfo.Account) || strings.Contains(email.To, ctx.UserInfo.Account) {
} else if strings.Contains(email.Bcc, ctx.UserAccount) || strings.Contains(email.Cc, ctx.UserAccount) || strings.Contains(email.To, ctx.UserAccount) {
hasAuth = true
break
}

View File

@ -3,14 +3,14 @@ package del_email
import (
"fmt"
"pmail/db"
"pmail/dto"
"pmail/models"
"pmail/services/auth"
"pmail/utils/array"
"pmail/utils/context"
"pmail/utils/errors"
)
func DelEmail(ctx *dto.Context, ids []int) error {
func DelEmail(ctx *context.Context, ids []int) error {
var emails []*models.Email
db.Instance.Select(&emails, db.WithContext(ctx, fmt.Sprintf("select * from email where id in (%s)", array.Join(ids, ","))))
@ -23,7 +23,8 @@ func DelEmail(ctx *dto.Context, ids []int) error {
}
}
_, err := db.Instance.Exec(db.WithContext(ctx, fmt.Sprintf("delete from email where id in (%s)", array.Join(ids, ","))))
//_, err := db.Instance.Exec(db.WithContext(ctx, fmt.Sprintf("delete from email where id in (%s)", array.Join(ids, ","))))
_, err := db.Instance.Exec(db.WithContext(ctx, fmt.Sprintf("update email set status = 3 where id in (%s)", array.Join(ids, ","))))
if err != nil {
return errors.Wrap(err)
}

View File

@ -6,13 +6,13 @@ import (
"fmt"
log "github.com/sirupsen/logrus"
"pmail/db"
"pmail/dto"
"pmail/dto/parsemail"
"pmail/models"
"pmail/utils/context"
"strings"
)
func GetEmailDetail(ctx *dto.Context, id int, markRead bool) (*models.Email, error) {
func GetEmailDetail(ctx *context.Context, id int, markRead bool) (*models.Email, error) {
// 获取邮件内容
var email models.Email
err := db.Instance.Get(&email, db.WithContext(ctx, "select * from email where id = ?"), id)

View File

@ -7,6 +7,7 @@ import (
"pmail/dto"
"pmail/models"
"pmail/utils/array"
"pmail/utils/context"
"pmail/utils/errors"
)
@ -17,7 +18,7 @@ type GroupItem struct {
Children []*GroupItem `json:"children"`
}
func DelGroup(ctx *dto.Context, groupId int) (bool, error) {
func DelGroup(ctx *context.Context, groupId int) (bool, error) {
allGroupIds := getAllChildId(ctx, groupId)
allGroupIds = append(allGroupIds, groupId)
@ -27,7 +28,7 @@ func DelGroup(ctx *dto.Context, groupId int) (bool, error) {
return false, errors.Wrap(err)
}
res, err := trans.Exec(db.WithContext(ctx, fmt.Sprintf("delete from `group` where id in (%s) and user_id =?", array.Join(allGroupIds, ","))), ctx.UserInfo.ID)
res, err := trans.Exec(db.WithContext(ctx, fmt.Sprintf("delete from `group` where id in (%s) and user_id =?", array.Join(allGroupIds, ","))), ctx.UserID)
if err != nil {
trans.Rollback()
return false, errors.Wrap(err)
@ -53,10 +54,10 @@ type id struct {
Id int `db:"id"`
}
func getAllChildId(ctx *dto.Context, rootId int) []int {
func getAllChildId(ctx *context.Context, rootId int) []int {
var ids []id
var ret []int
db.Instance.Select(&ids, db.WithContext(ctx, "select id from `group` where parent_id=? and user_id=?"), rootId, ctx.UserInfo.ID)
db.Instance.Select(&ids, db.WithContext(ctx, "select id from `group` where parent_id=? and user_id=?"), rootId, ctx.UserID)
for _, item := range ids {
ret = array.Merge(ret, getAllChildId(ctx, item.Id))
ret = append(ret, item.Id)
@ -65,12 +66,12 @@ func getAllChildId(ctx *dto.Context, rootId int) []int {
}
// GetGroupInfoList 获取全部的分组
func GetGroupInfoList(ctx *dto.Context) []*GroupItem {
func GetGroupInfoList(ctx *context.Context) []*GroupItem {
return buildChildren(ctx, 0)
}
// MoveMailToGroup 将某封邮件移动到某个分组中
func MoveMailToGroup(ctx *dto.Context, mailId []int, groupId int) bool {
func MoveMailToGroup(ctx *context.Context, mailId []int, groupId int) bool {
res, err := db.Instance.Exec(db.WithContext(ctx, fmt.Sprintf("update email set group_id=? where id in (%s)", array.Join(mailId, ","))), groupId)
if err != nil {
log.WithContext(ctx).Errorf("SQL Error:%+v", err)
@ -85,10 +86,10 @@ func MoveMailToGroup(ctx *dto.Context, mailId []int, groupId int) bool {
return rowNum > 0
}
func buildChildren(ctx *dto.Context, parentId int) []*GroupItem {
func buildChildren(ctx *context.Context, parentId int) []*GroupItem {
var ret []*GroupItem
var rootGroup []*models.Group
err := db.Instance.Select(&rootGroup, db.WithContext(ctx, "select * from `group` where parent_id=? and user_id=?"), parentId, ctx.UserInfo.ID)
err := db.Instance.Select(&rootGroup, db.WithContext(ctx, "select * from `group` where parent_id=? and user_id=?"), parentId, ctx.UserID)
if err != nil {
log.WithContext(ctx).Errorf("SQL Error:%v", err)
@ -107,8 +108,8 @@ func buildChildren(ctx *dto.Context, parentId int) []*GroupItem {
}
func GetGroupList(ctx *dto.Context) []*models.Group {
func GetGroupList(ctx *context.Context) []*models.Group {
var ret []*models.Group
db.Instance.Select(&ret, db.WithContext(ctx, "select * from `group` where user_id=?"), ctx.UserInfo.ID)
db.Instance.Select(&ret, db.WithContext(ctx, "select * from `group` where user_id=?"), ctx.UserID)
return ret
}

View File

@ -6,19 +6,20 @@ import (
"pmail/db"
"pmail/dto"
"pmail/models"
"pmail/utils/context"
)
func GetEmailList(ctx *dto.Context, tag string, keyword string, offset, limit int) (emailList []*models.Email, total int) {
func GetEmailList(ctx *context.Context, tag string, keyword string, offset, limit int) (emailList []*models.Email, total int) {
querySQL, queryParams := genSQL(ctx, false, tag, keyword, offset, limit)
counterSQL, counterParams := genSQL(ctx, true, tag, keyword, offset, limit)
err := db.Instance.Select(&emailList, querySQL, queryParams...)
err := db.Instance.Select(&emailList, db.WithContext(ctx, querySQL), queryParams...)
if err != nil {
log.Errorf("SQL ERROR: %s ,Error:%s", querySQL, err)
}
err = db.Instance.Get(&total, counterSQL, counterParams...)
err = db.Instance.Get(&total, db.WithContext(ctx, counterSQL), counterParams...)
if err != nil {
log.Errorf("SQL ERROR: %s ,Error:%s", querySQL, err)
}
@ -26,7 +27,7 @@ func GetEmailList(ctx *dto.Context, tag string, keyword string, offset, limit in
return
}
func genSQL(ctx *dto.Context, counter bool, tag, keyword string, offset, limit int) (string, []any) {
func genSQL(ctx *context.Context, counter bool, tag, keyword string, offset, limit int) (string, []any) {
sql := "select * from email where 1=1 "
if counter {
@ -46,6 +47,8 @@ func genSQL(ctx *dto.Context, counter bool, tag, keyword string, offset, limit i
if tagInfo.Status != -1 {
sql += " and status =? "
sqlParams = append(sqlParams, tagInfo.Status)
} else {
sql += " and status != 3"
}
if tagInfo.GroupId != -1 {
@ -58,7 +61,7 @@ func genSQL(ctx *dto.Context, counter bool, tag, keyword string, offset, limit i
sqlParams = append(sqlParams, "%"+keyword+"%", "%"+keyword+"%")
}
sql += " limit ? offset ?"
sql += " order by id desc limit ? offset ?"
sqlParams = append(sqlParams, limit, offset)
return sql, sqlParams

View File

@ -0,0 +1,51 @@
package match
import (
"encoding/json"
"pmail/dto/parsemail"
"pmail/utils/context"
)
const (
RuleTypeRegex = "regex"
RuleTypeContains = "contains"
RuleTypeEq = "equal"
)
type Match interface {
Match(ctx *context.Context, email *parsemail.Email) bool
}
func getFieldContent(field string, email *parsemail.Email) string {
switch field {
case "ReplyTo":
b, _ := json.Marshal(email.ReplyTo)
return string(b)
case "From":
b, _ := json.Marshal(email.From)
return string(b)
case "Subject":
return email.Subject
case "To":
b, _ := json.Marshal(email.To)
return string(b)
case "Bcc":
b, _ := json.Marshal(email.Bcc)
return string(b)
case "Cc":
b, _ := json.Marshal(email.Cc)
return string(b)
case "Text":
return string(email.Text)
case "Html":
return string(email.HTML)
case "Sender":
b, _ := json.Marshal(email.Sender)
return string(b)
case "Content":
b := string(email.HTML)
b2 := string(email.Text)
return b + b2
}
return ""
}

View File

@ -0,0 +1,24 @@
package match
import (
"pmail/dto/parsemail"
"pmail/utils/context"
"strings"
)
type ContainsMatch struct {
Rule string
Field string
}
func NewContainsMatch(field, rule string) *ContainsMatch {
return &ContainsMatch{
Rule: rule,
Field: field,
}
}
func (r *ContainsMatch) Match(ctx *context.Context, email *parsemail.Email) bool {
content := getFieldContent(r.Field, email)
return strings.Contains(content, r.Rule)
}

View File

@ -0,0 +1,23 @@
package match
import (
"pmail/dto/parsemail"
"pmail/utils/context"
)
type EqualMatch struct {
Rule string
Field string
}
func NewEqualMatch(field, rule string) *EqualMatch {
return &EqualMatch{
Rule: rule,
Field: field,
}
}
func (r *EqualMatch) Match(ctx *context.Context, email *parsemail.Email) bool {
content := getFieldContent(r.Field, email)
return content == r.Rule
}

View File

@ -0,0 +1,32 @@
package match
import (
log "github.com/sirupsen/logrus"
"pmail/dto/parsemail"
"pmail/utils/context"
"regexp"
)
type RegexMatch struct {
Rule string
Field string
}
func NewRegexMatch(field, rule string) *RegexMatch {
return &RegexMatch{
Rule: rule,
Field: field,
}
}
func (r *RegexMatch) Match(ctx *context.Context, email *parsemail.Email) bool {
content := getFieldContent(r.Field, email)
match, err := regexp.MatchString(r.Rule, content)
if err != nil {
log.WithContext(ctx).Errorf("rule regex error %v", err)
}
return match
}

View File

@ -0,0 +1,18 @@
package match
import (
"pmail/models"
"testing"
)
func TestRegexMatch_Match(t *testing.T) {
r := NewRegexMatch("Subject", "\\d+")
ret := r.Match(nil, &models.Email{
Subject: "111",
})
if !ret {
t.Errorf("失败")
}
}

View File

@ -0,0 +1,81 @@
package rule
import (
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
"pmail/config"
"pmail/db"
"pmail/dto"
"pmail/dto/parsemail"
"pmail/models"
"pmail/services/rule/match"
"pmail/utils/context"
"pmail/utils/send"
"strings"
)
func GetAllRules(ctx *context.Context) []*dto.Rule {
var res []*models.Rule
var err error
if ctx == nil {
err = db.Instance.Select(&res, "select * from rule order by sort desc")
} else {
err = db.Instance.Select(&res, db.WithContext(ctx, "select * from rule where user_id=? order by sort desc"), ctx.UserID)
}
if err != nil {
log.WithContext(ctx).Errorf("sqlERror :%v", err)
}
var ret []*dto.Rule
for _, rule := range res {
ret = append(ret, (&dto.Rule{}).Decode(rule))
}
return ret
}
func MatchRule(ctx *context.Context, rule *dto.Rule, email *parsemail.Email) bool {
for _, r := range rule.Rules {
var m match.Match
switch r.Type {
case match.RuleTypeRegex:
m = match.NewRegexMatch(r.Field, r.Rule)
case match.RuleTypeContains:
m = match.NewContainsMatch(r.Field, r.Rule)
case match.RuleTypeEq:
m = match.NewEqualMatch(r.Field, r.Rule)
}
if m == nil {
continue
}
if !m.Match(ctx, email) {
return false
}
}
return true
}
func DoRule(ctx *context.Context, rule *dto.Rule, email *parsemail.Email) {
switch rule.Action {
case dto.READ:
email.IsRead = 1
case dto.DELETE:
email.Status = 3
case dto.FORWARD:
if strings.Contains(rule.Params, config.Instance.Domain) {
log.WithContext(ctx).Errorf("Forward Error! loop forwarding!")
return
}
err := send.Forward(nil, email, rule.Params)
if err != nil {
log.WithContext(ctx).Errorf("Forward Error:%v", err)
}
case dto.MOVE:
email.GroupId = cast.ToInt(rule.Params)
}
}

View File

@ -5,15 +5,15 @@ import (
"os"
"pmail/config"
"pmail/db"
"pmail/dto"
"pmail/models"
"pmail/utils/array"
"pmail/utils/context"
"pmail/utils/errors"
"pmail/utils/file"
"pmail/utils/password"
)
func GetDatabaseSettings(ctx *dto.Context) (string, string, error) {
func GetDatabaseSettings(ctx *context.Context) (string, string, error) {
configData, err := ReadConfig()
if err != nil {
return "", "", errors.Wrap(err)
@ -26,7 +26,7 @@ func GetDatabaseSettings(ctx *dto.Context) (string, string, error) {
return configData.DbType, configData.DbDSN, nil
}
func GetAdminPassword(ctx *dto.Context) (string, error) {
func GetAdminPassword(ctx *context.Context) (string, error) {
users := []*models.User{}
err := db.Instance.Select(&users, "select * from user")
@ -41,7 +41,7 @@ func GetAdminPassword(ctx *dto.Context) (string, error) {
return "", nil
}
func SetAdminPassword(ctx *dto.Context, account, pwd 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 {
@ -58,7 +58,7 @@ func SetAdminPassword(ctx *dto.Context, account, pwd string) error {
return nil
}
func SetDatabaseSettings(ctx *dto.Context, dbType, dbDSN string) error {
func SetDatabaseSettings(ctx *context.Context, dbType, dbDSN string) error {
configData, err := ReadConfig()
if err != nil {
return errors.Wrap(err)
@ -68,6 +68,10 @@ func SetDatabaseSettings(ctx *dto.Context, dbType, dbDSN string) error {
return errors.New("dbtype error")
}
if dbDSN == "" {
return errors.New("DSN error")
}
configData.DbType = dbType
configData.DbDSN = dbDSN

View File

@ -5,9 +5,9 @@ import (
"fmt"
"io"
"net/http"
"pmail/dto"
"pmail/i18n"
"pmail/services/auth"
"pmail/utils/context"
"pmail/utils/errors"
)
@ -19,7 +19,7 @@ type DNSItem struct {
Tips string `json:"tips"`
}
func GetDNSSettings(ctx *dto.Context) ([]*DNSItem, error) {
func GetDNSSettings(ctx *context.Context) ([]*DNSItem, error) {
configData, err := ReadConfig()
if err != nil {
return nil, errors.Wrap(err)

View File

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

View File

@ -14,7 +14,7 @@ var Instance *scs.SessionManager
func Init() {
Instance = scs.New()
Instance.Lifetime = 24 * time.Hour
Instance.Lifetime = 7 * 24 * time.Hour
// 使用db存储session数据目前为了架构简单
// 暂不引入redis存储如果日后性能存在瓶颈可以将session迁移到redis
if config.Instance.DbType == "mysql" {

View File

@ -8,9 +8,11 @@ import (
"io"
"net"
"net/netip"
"pmail/config"
"pmail/db"
"pmail/dto/parsemail"
"pmail/hooks"
"pmail/services/rule"
"pmail/utils/async"
"strings"
"time"
@ -57,6 +59,17 @@ func (s *Session) Data(r io.Reader) error {
spfV = 1
}
// 垃圾过滤
if config.Instance.SpamFilterLevel == 1 && !SPFStatus && !dkimStatus {
log.Infoln("垃圾邮件,拒信")
return nil
}
if config.Instance.SpamFilterLevel == 2 && !SPFStatus {
log.Infoln("垃圾邮件,拒信")
return nil
}
as2 := async.New(nil)
for _, hook := range hooks.HookList {
if hook == nil {
@ -68,7 +81,19 @@ func (s *Session) Data(r io.Reader) error {
}
as2.Wait()
sql := "INSERT INTO email (send_date, subject, reply_to, from_name, from_address, `to`, bcc, cc, text, html, sender, attachments,spf_check, dkim_check, create_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
// 执行邮件规则
rs := rule.GetAllRules(nil)
for _, r := range rs {
if rule.MatchRule(nil, r, email) {
rule.DoRule(nil, r, email)
}
}
if email == nil {
return nil
}
sql := "INSERT INTO email (send_date, subject, reply_to, from_name, from_address, `to`, bcc, cc, text, html, sender, attachments,spf_check, dkim_check, create_time,is_read,status,group_id) VALUES (?,?,?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
_, err = db.Instance.Exec(sql,
email.Date,
email.Subject,
@ -84,7 +109,11 @@ func (s *Session) Data(r io.Reader) error {
json2string(email.Attachments),
spfV,
dkimV,
time.Now())
time.Now(),
email.IsRead,
email.Status,
email.GroupId,
)
if err != nil {
log.Println("mysql insert error:", err.Error())

View File

@ -70,3 +70,352 @@ func TestSession_DataGmail(t *testing.T) {
s.Data(bytes.NewReader(data))
}
func TestPmailEmail(t *testing.T) {
testInit()
emailData := `DKIM-Signature: a=rsa-sha256; bh=x7Rh+N2y2K9exccEAyKCTAGDgYKfnLZpMWc25ug5Ny4=;
c=simple/simple; d=domain.com;
h=Content-Type:Mime-Version:Subject:To:From:Date; s=default; t=1693831868;
v=1;
b=1PZEupYvSMtGyYx42b4G65YbdnRj4y2QFo9kS7GXiTVhUM5EYzJhZzknwRMN5RL5aFY26W4E
DmzJ85XvPPvrDtnU/B4jkc5xthE+KEsb1Go8HcL8WQqwvsE9brepeA0t0RiPnA/x7dbTo3u72SG
WqtviWbJH5lPFc9PkSbEPFtc=
Content-Type: multipart/mixed;
boundary=3c13260efb7bd8bad8315c21215489fe283f36cdf82813674f6e11215f6c
Mime-Version: 1.0
Subject: =?utf-8?q?=E6=8F=92=E4=BB=B6=E6=B5=8B=E8=AF=95?=
To: =?utf-8?q?=E5=90=8D?= <ok@jinnrry.com>
From: =?utf-8?q?=E5=8F=91=E9=80=81=E4=BA=BA?= <j@jinnrry.com>
Date: Mon, 04 Sep 2023 20:51:08 +0800
--3c13260efb7bd8bad8315c21215489fe283f36cdf82813674f6e11215f6c
Content-Type: multipart/alternative;
boundary=9ebf2f3c4f97c51dd9a285ae28a54d2d0d84aa6d0ad28b76547e2096bb66
--9ebf2f3c4f97c51dd9a285ae28a54d2d0d84aa6d0ad28b76547e2096bb66
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/plain
=E8=BF=99=E6=98=AFText
--9ebf2f3c4f97c51dd9a285ae28a54d2d0d84aa6d0ad28b76547e2096bb66
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/html
<div>=E8=BF=99=E6=98=AFHtml</div>
--9ebf2f3c4f97c51dd9a285ae28a54d2d0d84aa6d0ad28b76547e2096bb66--
--3c13260efb7bd8bad8315c21215489fe283f36cdf82813674f6e11215f6c--
`
s := Session{
RemoteAddress: net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.AddrFrom4([4]byte{}), 25)),
}
s.Data(bytes.NewReader([]byte(emailData)))
}
func TestRuleForward(t *testing.T) {
testInit()
forwardEmail := `DKIM-Signature: a=rsa-sha256; bh=bpOshF+iimuqAQijVxqkH6gPpWf8A+Ih30/tMjgEgS0=;
c=simple/simple; d=jinnrry.com;
h=Content-Type:Mime-Version:Subject:To:From:Date; s=default; t=1693992640;
v=1;
b=XiOgYL9iGrkuYzXBAf7DSO0sRbFr6aPOE4VikmselNKEF1UTjMPdiqpeHyx/i6BOQlJWWZEC
PzceHTDFIStcZE6a5Sc1nh8Fis+gRkrheBO/zK/P5P/euK+0Fj5+0T82keNTSCgo1ZtEIubaNR0
JvkwJ2ZC9g8xV6Yiq+ZhRriT8lZ6zeI55PPEFJIzFgZ7xDshDgx5E7J1xRXQqcEMV1rgVq04d3c
6wjU+LLtghmgtUToRp3ASn6DhVO+Bbc4QkmcQ/StQH3681+1GVMHvQSBhSSymSRA71SikE2u3a1
JnvbOP9fThP7h+6oFEIRuF7MwDb3JWY5BXiFFKCkecdFg==
Content-Type: multipart/mixed;
boundary=8e9d5abb6bdac11b8d7d6e13280af1a87d12b904a59368d6e852b0a4ce3e
Mime-Version: 1.0
Subject: forward
To: <t@jiangwei.one>
From: "i" <i@jinnrry.com>
Date: Wed, 06 Sep 2023 17:30:40 +0800
--8e9d5abb6bdac11b8d7d6e13280af1a87d12b904a59368d6e852b0a4ce3e
Content-Type: multipart/alternative;
boundary=a62ae91c159ea22e8196d57d344626eb00d1ddfa9c5064a39b01588aa992
--a62ae91c159ea22e8196d57d344626eb00d1ddfa9c5064a39b01588aa992
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/plain
hello pls Forward the email.
--a62ae91c159ea22e8196d57d344626eb00d1ddfa9c5064a39b01588aa992
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/html
<p>hello pls Forward the email.</p>
--a62ae91c159ea22e8196d57d344626eb00d1ddfa9c5064a39b01588aa992--
--8e9d5abb6bdac11b8d7d6e13280af1a87d12b904a59368d6e852b0a4ce3e--`
readEmail := `DKIM-Signature: a=rsa-sha256; bh=JcCDj6edb1bAwRbcFZ63plFZOeB5AdGWLE/PQ2FQ1Tc=;
c=simple/simple; d=jinnrry.com;
h=Content-Type:Mime-Version:Subject:To:From:Date; s=default; t=1693992600;
v=1;
b=rwlqSkDFKYH42pA1jsajemaw+4YdeLHPeqV4mLQrRdihgma1VSvXl5CEOur/KuwQuUarr2cu
SntWrHE6+RnDaQcPEHbkgoMjEJw5+VPwkIvE6VSlMIB7jg93mGzvN2yjheWTePZ+cVPjOaIrgir
wiT24hkrTHp+ONT8XoS0sDuY+ieyBZp/GCv/YvgE4t0JEkNozMAVWotrXxaICDzZoWP3NNmKLqg
6He6zwWAl51r3W5R5weGBi6A/FqlHgHZGroXnNi+wolDuN6pQiVAJ7MZ6hboPCbCCRrBQDTdor5
wEI2+MwlJ/d2f17wxoGmluCewbeYttuVcpUOVwACJKw3g==
Content-Type: multipart/mixed;
boundary=9e33a130a8a976102a93e296d6408d228e151f7841ca9ee0d777234fd6f3
Mime-Version: 1.0
Subject: read
To: <t@jiangwei.one>
From: "i" <i@jinnrry.com>
Date: Wed, 06 Sep 2023 17:30:00 +0800
--9e33a130a8a976102a93e296d6408d228e151f7841ca9ee0d777234fd6f3
Content-Type: multipart/alternative;
boundary=54a95f3429f3cdb342383db10293780bed341f8dc20d2f876eb0853e3884
--54a95f3429f3cdb342383db10293780bed341f8dc20d2f876eb0853e3884
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/plain
12 aRead 1sadf
--54a95f3429f3cdb342383db10293780bed341f8dc20d2f876eb0853e3884
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/html
<p>12 aRead 1sadf</p>
--54a95f3429f3cdb342383db10293780bed341f8dc20d2f876eb0853e3884--
--9e33a130a8a976102a93e296d6408d228e151f7841ca9ee0d777234fd6f3--`
moveEmail := `DKIM-Signature: a=rsa-sha256; bh=YQfG/wlHGhky6FNmpIwgDYDOc/uyivdBv+9S02Z04xY=;
c=simple/simple; d=jinnrry.com;
h=Content-Type:Mime-Version:Subject:To:From:Date; s=default; t=1693992542;
v=1;
b=IhxswOCq8I7CmCas1EMp+n8loR7illqlF0IJC6eN1+OLjI/E5BPzpP4HWkyqaAkd0Vn9i+Bn
MVb5kNHZ2S7qt0rqAAc6Atc0i9WpLEI3Cng+VDn+difcMZlJSAkhLLn2sUsS4Fzqqo3Cbw62qSO
TgnWRmlj9aM+5xfGcl/76WOvQQpahJbGg6Go51kFMeHVom/VeGKIgFBCeMe37T/LS03c3pAV8gA
i6Zy3GYE57W/qU3oCzaGeS3n5zom/i74H4VipiVIMX/OBNYhdHWrP8vyjvzLFpJlXp6RvzcRl0P
ytyiCZfE8G7fAFntp20LW70Y5Xgqqczk1jR578UDczVoA==
Content-Type: multipart/mixed;
boundary=c84d60b253aa6caee345c73e717ad59b1975448bbdfad7a23ac4d76e022d
Mime-Version: 1.0
Subject: Move
To: <t@jiangwei.one>
From: "i" <i@jinnrry.com>
Date: Wed, 06 Sep 2023 17:29:02 +0800
--c84d60b253aa6caee345c73e717ad59b1975448bbdfad7a23ac4d76e022d
Content-Type: multipart/alternative;
boundary=a69985ebcf3c1c44d6e69e5a29c1044743cd9e44d4bc9bb6886f83a73966
--a69985ebcf3c1c44d6e69e5a29c1044743cd9e44d4bc9bb6886f83a73966
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/plain
MOVE move Move
--a69985ebcf3c1c44d6e69e5a29c1044743cd9e44d4bc9bb6886f83a73966
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/html
<p>MOVE move Move</p>
--a69985ebcf3c1c44d6e69e5a29c1044743cd9e44d4bc9bb6886f83a73966--
--c84d60b253aa6caee345c73e717ad59b1975448bbdfad7a23ac4d76e022d--`
deleteEmail := `DKIM-Signature: a=rsa-sha256; bh=dNtHGqd1NbRj0WSwrJmPsqAcAy3h/4kZK2HFQ0Asld8=;
c=simple/simple; d=jinnrry.com;
h=Content-Type:Mime-Version:Subject:To:From:Date; s=default; t=1693992495;
v=1;
b=QllU8lqGdoOMaGYp8d13oWytb7+RebqKjq4y8Rs/kOeQxoE8dSEVliK3eBiXidsNTdDtkTqf
eiwjyRBK92NVCYprdJqLbu9qZ39BC2lk3NXttTSJ1+1ZZ/bGtIW5JIYn2pToED0MqVVkxGFUtl+
qFmc4mWo5a4Mbij7xaAB3uJtHpBDt7q4Ovr2hiMetQv7YrhZvCt/xrH8Q9YzZ6xzFUL5ekW40eH
oWElU1GyVBHWCKh31aweyhA+1XLPYojjREQYd4svRqTbSFSsBqFwFIUGdnyJh2WgmF8eucmttAw
oRhgzyZkHL1jAskKFBpO10SDReyk50Cvc+0kSLj+QcUpg==
Content-Type: multipart/mixed;
boundary=bdfa9bf94e22e218105281e06bd59bd6df3ce70e71367bf49fbe73301af3
Mime-Version: 1.0
Subject: test
To: <t@jiangwei.one>
From: "i" <i@jinnrry.com>
Date: Wed, 06 Sep 2023 17:28:15 +0800
--bdfa9bf94e22e218105281e06bd59bd6df3ce70e71367bf49fbe73301af3
Content-Type: multipart/alternative;
boundary=7352524eaae801790245f6bf095460fd1f4e01f5748b4dba48635bf59b04
--7352524eaae801790245f6bf095460fd1f4e01f5748b4dba48635bf59b04
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/plain
Delete
--7352524eaae801790245f6bf095460fd1f4e01f5748b4dba48635bf59b04
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/html
<p>Delete</p>
--7352524eaae801790245f6bf095460fd1f4e01f5748b4dba48635bf59b04--
--bdfa9bf94e22e218105281e06bd59bd6df3ce70e71367bf49fbe73301af3--`
s := Session{
RemoteAddress: net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.AddrFrom4([4]byte{}), 25)),
}
s.Data(bytes.NewReader([]byte(deleteEmail)))
s.Data(bytes.NewReader([]byte(readEmail)))
s.Data(bytes.NewReader([]byte(forwardEmail)))
s.Data(bytes.NewReader([]byte(moveEmail)))
}
func TestRuleRead(t *testing.T) {
testInit()
readEmail := `DKIM-Signature: a=rsa-sha256; bh=JcCDj6edb1bAwRbcFZ63plFZOeB5AdGWLE/PQ2FQ1Tc=;
c=simple/simple; d=jinnrry.com;
h=Content-Type:Mime-Version:Subject:To:From:Date; s=default; t=1693992600;
v=1;
b=rwlqSkDFKYH42pA1jsajemaw+4YdeLHPeqV4mLQrRdihgma1VSvXl5CEOur/KuwQuUarr2cu
SntWrHE6+RnDaQcPEHbkgoMjEJw5+VPwkIvE6VSlMIB7jg93mGzvN2yjheWTePZ+cVPjOaIrgir
wiT24hkrTHp+ONT8XoS0sDuY+ieyBZp/GCv/YvgE4t0JEkNozMAVWotrXxaICDzZoWP3NNmKLqg
6He6zwWAl51r3W5R5weGBi6A/FqlHgHZGroXnNi+wolDuN6pQiVAJ7MZ6hboPCbCCRrBQDTdor5
wEI2+MwlJ/d2f17wxoGmluCewbeYttuVcpUOVwACJKw3g==
Content-Type: multipart/mixed;
boundary=9e33a130a8a976102a93e296d6408d228e151f7841ca9ee0d777234fd6f3
Mime-Version: 1.0
Subject: read
To: <t@jiangwei.one>
From: "i" <i@jinnrry.com>
Date: Wed, 06 Sep 2023 17:30:00 +0800
--9e33a130a8a976102a93e296d6408d228e151f7841ca9ee0d777234fd6f3
Content-Type: multipart/alternative;
boundary=54a95f3429f3cdb342383db10293780bed341f8dc20d2f876eb0853e3884
--54a95f3429f3cdb342383db10293780bed341f8dc20d2f876eb0853e3884
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/plain
12 aRead 1sadf
--54a95f3429f3cdb342383db10293780bed341f8dc20d2f876eb0853e3884
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/html
<p>12 aRead 1sadf</p>
--54a95f3429f3cdb342383db10293780bed341f8dc20d2f876eb0853e3884--
--9e33a130a8a976102a93e296d6408d228e151f7841ca9ee0d777234fd6f3--`
s := Session{
RemoteAddress: net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.AddrFrom4([4]byte{}), 25)),
}
s.Data(bytes.NewReader([]byte(readEmail)))
}
func TestRuleDelete(t *testing.T) {
testInit()
deleteEmail := `DKIM-Signature: a=rsa-sha256; bh=dNtHGqd1NbRj0WSwrJmPsqAcAy3h/4kZK2HFQ0Asld8=;
c=simple/simple; d=jinnrry.com;
h=Content-Type:Mime-Version:Subject:To:From:Date; s=default; t=1693992495;
v=1;
b=QllU8lqGdoOMaGYp8d13oWytb7+RebqKjq4y8Rs/kOeQxoE8dSEVliK3eBiXidsNTdDtkTqf
eiwjyRBK92NVCYprdJqLbu9qZ39BC2lk3NXttTSJ1+1ZZ/bGtIW5JIYn2pToED0MqVVkxGFUtl+
qFmc4mWo5a4Mbij7xaAB3uJtHpBDt7q4Ovr2hiMetQv7YrhZvCt/xrH8Q9YzZ6xzFUL5ekW40eH
oWElU1GyVBHWCKh31aweyhA+1XLPYojjREQYd4svRqTbSFSsBqFwFIUGdnyJh2WgmF8eucmttAw
oRhgzyZkHL1jAskKFBpO10SDReyk50Cvc+0kSLj+QcUpg==
Content-Type: multipart/mixed;
boundary=bdfa9bf94e22e218105281e06bd59bd6df3ce70e71367bf49fbe73301af3
Mime-Version: 1.0
Subject: test
To: <t@jiangwei.one>
From: "i" <i@jinnrry.com>
Date: Wed, 06 Sep 2023 17:28:15 +0800
--bdfa9bf94e22e218105281e06bd59bd6df3ce70e71367bf49fbe73301af3
Content-Type: multipart/alternative;
boundary=7352524eaae801790245f6bf095460fd1f4e01f5748b4dba48635bf59b04
--7352524eaae801790245f6bf095460fd1f4e01f5748b4dba48635bf59b04
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/plain
Delete
--7352524eaae801790245f6bf095460fd1f4e01f5748b4dba48635bf59b04
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/html
<p>Delete</p>
--7352524eaae801790245f6bf095460fd1f4e01f5748b4dba48635bf59b04--
--bdfa9bf94e22e218105281e06bd59bd6df3ce70e71367bf49fbe73301af3--`
s := Session{
RemoteAddress: net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.AddrFrom4([4]byte{}), 25)),
}
s.Data(bytes.NewReader([]byte(deleteEmail)))
}
func TestRuleMove(t *testing.T) {
testInit()
moveEmail := `DKIM-Signature: a=rsa-sha256; bh=YQfG/wlHGhky6FNmpIwgDYDOc/uyivdBv+9S02Z04xY=;
c=simple/simple; d=jinnrry.com;
h=Content-Type:Mime-Version:Subject:To:From:Date; s=default; t=1693992542;
v=1;
b=IhxswOCq8I7CmCas1EMp+n8loR7illqlF0IJC6eN1+OLjI/E5BPzpP4HWkyqaAkd0Vn9i+Bn
MVb5kNHZ2S7qt0rqAAc6Atc0i9WpLEI3Cng+VDn+difcMZlJSAkhLLn2sUsS4Fzqqo3Cbw62qSO
TgnWRmlj9aM+5xfGcl/76WOvQQpahJbGg6Go51kFMeHVom/VeGKIgFBCeMe37T/LS03c3pAV8gA
i6Zy3GYE57W/qU3oCzaGeS3n5zom/i74H4VipiVIMX/OBNYhdHWrP8vyjvzLFpJlXp6RvzcRl0P
ytyiCZfE8G7fAFntp20LW70Y5Xgqqczk1jR578UDczVoA==
Content-Type: multipart/mixed;
boundary=c84d60b253aa6caee345c73e717ad59b1975448bbdfad7a23ac4d76e022d
Mime-Version: 1.0
Subject: Move
To: <t@jiangwei.one>
From: "i" <i@jinnrry.com>
Date: Wed, 06 Sep 2023 17:29:02 +0800
--c84d60b253aa6caee345c73e717ad59b1975448bbdfad7a23ac4d76e022d
Content-Type: multipart/alternative;
boundary=a69985ebcf3c1c44d6e69e5a29c1044743cd9e44d4bc9bb6886f83a73966
--a69985ebcf3c1c44d6e69e5a29c1044743cd9e44d4bc9bb6886f83a73966
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/plain
MOVE move Move
--a69985ebcf3c1c44d6e69e5a29c1044743cd9e44d4bc9bb6886f83a73966
Content-Transfer-Encoding: quoted-printable
Content-Disposition: inline
Content-Type: text/html
<p>MOVE move Move</p>
--a69985ebcf3c1c44d6e69e5a29c1044743cd9e44d4bc9bb6886f83a73966--
--c84d60b253aa6caee345c73e717ad59b1975448bbdfad7a23ac4d76e022d--`
s := Session{
RemoteAddress: net.TCPAddrFromAddrPort(netip.AddrPortFrom(netip.AddrFrom4([4]byte{}), 25)),
}
s.Data(bytes.NewReader([]byte(moveEmail)))
}

View File

@ -1,160 +0,0 @@
package smtp_server
import (
"crypto/tls"
"crypto/x509"
"errors"
log "github.com/sirupsen/logrus"
"net"
"pmail/dto"
"pmail/dto/parsemail"
"pmail/utils/array"
"pmail/utils/async"
"pmail/utils/smtp"
"strings"
)
type mxDomain struct {
domain string
mxHost string
}
func Send(ctx *dto.Context, e *parsemail.Email) (error, map[string]error) {
b := e.BuildBytes(ctx)
var to []*parsemail.User
to = append(append(append(to, e.To...), e.Cc...), e.Bcc...)
// 按域名整理
toByDomain := map[mxDomain][]*parsemail.User{}
for _, s := range to {
args := strings.Split(s.EmailAddress, "@")
if len(args) == 2 {
//查询dns mx记录
mxInfo, err := net.LookupMX(args[1])
address := mxDomain{
domain: "smtp." + args[1],
mxHost: "smtp." + args[1],
}
if err != nil {
log.WithContext(ctx).Errorf(s.EmailAddress, "域名mx记录查询失败")
}
if len(mxInfo) > 0 {
address = mxDomain{
domain: args[1],
mxHost: mxInfo[0].Host,
}
}
toByDomain[address] = append(toByDomain[address], s)
} else {
log.WithContext(ctx).Errorf("邮箱地址解析错误! %s", s)
continue
}
}
var errEmailAddress []string
errMap := map[string]error{}
as := async.New(ctx)
for domain, tos := range toByDomain {
domain := domain
tos := tos
as.WaitProcess(func(p any) {
err := smtp.SendMail("", domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
// 重新选取证书域名
if err != nil {
if certificateErr, ok := err.(*tls.CertificateVerificationError); ok {
if hostnameErr, is := certificateErr.Err.(x509.HostnameError); is {
if hostnameErr.Certificate != nil {
certificateHostName := hostnameErr.Certificate.DNSNames
err = smtp.SendMail(domainMatch(domain.domain, certificateHostName), domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
}
}
}
}
if err != nil {
log.WithContext(ctx).Errorf("%v 邮件投递失败%+v", tos, err)
for _, user := range tos {
errEmailAddress = append(errEmailAddress, user.EmailAddress)
}
}
errMap[domain.domain] = err
}, nil)
}
as.Wait()
if len(errEmailAddress) > 0 {
return errors.New("以下收件人投递失败:" + array.Join(errEmailAddress, ",")), errMap
}
return nil, errMap
}
func buildAddress(u []*parsemail.User) []string {
var ret []string
for _, user := range u {
ret = append(ret, user.EmailAddress)
}
return ret
}
func domainMatch(domain string, dnsNames []string) string {
secondMatch := ""
for _, name := range dnsNames {
if strings.Contains(name, "smtp") {
secondMatch = name
}
if name == domain {
return name
}
if strings.Contains(name, "*") {
nameArg := strings.Split(name, ".")
domainArg := strings.Split(domain, ".")
match := true
for i := 0; i < len(nameArg); i++ {
if nameArg[len(nameArg)-1-i] == "*" {
continue
}
if len(domainArg) > i {
if nameArg[len(nameArg)-1-i] == domainArg[len(domainArg)-1-i] {
continue
}
}
match = false
break
}
for i := 0; i < len(domainArg); i++ {
if len(nameArg) > i && nameArg[len(nameArg)-1-i] == domainArg[len(domainArg)-1-i] {
continue
}
if len(nameArg) > i && nameArg[len(nameArg)-1-i] == "*" {
continue
}
match = false
break
}
if match {
return domain
}
}
}
if secondMatch != "" {
return strings.ReplaceAll(secondMatch, "*.", "")
}
return strings.ReplaceAll(dnsNames[0], "*.", "")
}

View File

@ -1,24 +0,0 @@
package smtp_server
import (
"pmail/dto/parsemail"
"testing"
)
func TestSend(t *testing.T) {
testInit()
e := &parsemail.Email{
From: &parsemail.User{
Name: "发送人",
EmailAddress: "j@jinnrry.com",
},
To: []*parsemail.User{
{"ok@jinnrry.com", "名"},
{"ok@xjiangwei.cn", "字"},
},
Subject: "你好",
Text: []byte("这是Text"),
HTML: []byte("<div>这是Html</div>"),
}
Send(nil, e)
}

View File

@ -0,0 +1,12 @@
package address
import "strings"
// IsValidEmailAddress 检查是否是有效的邮箱地址
func IsValidEmailAddress(str string) bool {
ars := strings.Split(str, "@")
if len(ars) != 2 {
return false
}
return strings.Contains(ars[1], ".")
}

View File

@ -0,0 +1,42 @@
package address
import "testing"
func TestIsValidEmailAddress(t *testing.T) {
type args struct {
str string
}
tests := []struct {
name string
args args
want bool
}{
{
"",
args{"test@qq.com"},
true,
},
{
"",
args{"1000@qq.com"},
true,
},
{
"",
args{"1000@163.com"},
true,
},
{
"",
args{"1000@1631com"},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsValidEmailAddress(tt.args.str); got != tt.want {
t.Errorf("IsValidEmailAddress() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -4,7 +4,7 @@ import (
"errors"
log "github.com/sirupsen/logrus"
"github.com/spf13/cast"
"pmail/dto"
"pmail/utils/context"
"runtime/debug"
"sync"
)
@ -14,10 +14,10 @@ type Callback func(params any)
type Async struct {
wg *sync.WaitGroup
lastError error
ctx *dto.Context
ctx *context.Context
}
func New(ctx *dto.Context) *Async {
func New(ctx *context.Context) *Async {
return &Async{
ctx: ctx,
}

View File

@ -1,8 +1,7 @@
package dto
package context
import (
"context"
"pmail/models"
)
const (
@ -11,9 +10,11 @@ const (
type Context struct {
context.Context
UserInfo *models.User
values map[string]any
Lang string
UserID int
UserAccount string
UserName string
values map[string]any
Lang string
}
func (c *Context) SetValue(key string, value any) {

260
server/utils/send/send.go Normal file
View File

@ -0,0 +1,260 @@
package send
import (
"crypto/tls"
"crypto/x509"
"errors"
log "github.com/sirupsen/logrus"
"net"
"pmail/dto/parsemail"
"pmail/utils/array"
"pmail/utils/async"
"pmail/utils/context"
"pmail/utils/smtp"
"strings"
)
type mxDomain struct {
domain string
mxHost string
}
// Forward 转发邮件
func Forward(ctx *context.Context, e *parsemail.Email, forwardAddress string) error {
b := e.ForwardBuildBytes(ctx, forwardAddress)
var to []*parsemail.User
to = []*parsemail.User{
{EmailAddress: forwardAddress},
}
// 按域名整理
toByDomain := map[mxDomain][]*parsemail.User{}
for _, s := range to {
args := strings.Split(s.EmailAddress, "@")
if len(args) == 2 {
//查询dns mx记录
mxInfo, err := net.LookupMX(args[1])
address := mxDomain{
domain: "smtp." + args[1],
mxHost: "smtp." + args[1],
}
if err != nil {
log.WithContext(ctx).Errorf(s.EmailAddress, "域名mx记录查询失败")
}
if len(mxInfo) > 0 {
address = mxDomain{
domain: args[1],
mxHost: mxInfo[0].Host,
}
}
toByDomain[address] = append(toByDomain[address], s)
} else {
log.WithContext(ctx).Errorf("邮箱地址解析错误! %s", s)
continue
}
}
var errEmailAddress []string
errMap := map[string]error{}
as := async.New(ctx)
for domain, tos := range toByDomain {
domain := domain
tos := tos
as.WaitProcess(func(p any) {
// 先使用smtps协议尝试
err := smtp.SendMailWithTls("", domain.mxHost+":465", nil, e.From.EmailAddress, buildAddress(tos), b)
if err != nil {
// smtps发送失败尝试smtp
err = smtp.SendMail("", domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
}
// 重新选取证书域名
if err != nil {
if certificateErr, ok := err.(*tls.CertificateVerificationError); ok {
if hostnameErr, is := certificateErr.Err.(x509.HostnameError); is {
if hostnameErr.Certificate != nil {
certificateHostName := hostnameErr.Certificate.DNSNames
// 先使用smtps协议尝试
err = smtp.SendMailWithTls(domainMatch(domain.domain, certificateHostName), domain.mxHost+":465", nil, e.From.EmailAddress, buildAddress(tos), b)
if err != nil {
log.Infoln(err)
// smtps发送失败尝试smtp
err = smtp.SendMail(domainMatch(domain.domain, certificateHostName), domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
}
}
}
}
}
if err != nil {
log.WithContext(ctx).Errorf("%v 邮件投递失败%+v", tos, err)
for _, user := range tos {
errEmailAddress = append(errEmailAddress, user.EmailAddress)
}
}
errMap[domain.domain] = err
}, nil)
}
as.Wait()
if len(errEmailAddress) > 0 {
return errors.New("以下收件人投递失败:" + array.Join(errEmailAddress, ","))
}
return nil
}
func Send(ctx *context.Context, e *parsemail.Email) (error, map[string]error) {
b := e.BuildBytes(ctx)
var to []*parsemail.User
to = append(append(append(to, e.To...), e.Cc...), e.Bcc...)
// 按域名整理
toByDomain := map[mxDomain][]*parsemail.User{}
for _, s := range to {
args := strings.Split(s.EmailAddress, "@")
if len(args) == 2 {
//查询dns mx记录
mxInfo, err := net.LookupMX(args[1])
address := mxDomain{
domain: "smtp." + args[1],
mxHost: "smtp." + args[1],
}
if err != nil {
log.WithContext(ctx).Errorf(s.EmailAddress, "域名mx记录查询失败")
}
if len(mxInfo) > 0 {
address = mxDomain{
domain: args[1],
mxHost: mxInfo[0].Host,
}
}
toByDomain[address] = append(toByDomain[address], s)
} else {
log.WithContext(ctx).Errorf("邮箱地址解析错误! %s", s)
continue
}
}
var errEmailAddress []string
errMap := map[string]error{}
as := async.New(ctx)
for domain, tos := range toByDomain {
domain := domain
tos := tos
as.WaitProcess(func(p any) {
// 先使用smtps协议尝试
err := smtp.SendMailWithTls("", domain.mxHost+":465", nil, e.From.EmailAddress, buildAddress(tos), b)
if err != nil {
// smtps发送失败尝试smtp
err = smtp.SendMail("", domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
}
// 重新选取证书域名
if err != nil {
if certificateErr, ok := err.(*tls.CertificateVerificationError); ok {
if hostnameErr, is := certificateErr.Err.(x509.HostnameError); is {
if hostnameErr.Certificate != nil {
certificateHostName := hostnameErr.Certificate.DNSNames
// 先使用smtps协议尝试
err = smtp.SendMailWithTls(domainMatch(domain.domain, certificateHostName), domain.mxHost+":465", nil, e.From.EmailAddress, buildAddress(tos), b)
if err != nil {
// smtps发送失败尝试smtp
err = smtp.SendMail(domainMatch(domain.domain, certificateHostName), domain.mxHost+":25", nil, e.From.EmailAddress, buildAddress(tos), b)
}
}
}
}
}
if err != nil {
log.WithContext(ctx).Errorf("%v 邮件投递失败%+v", tos, err)
for _, user := range tos {
errEmailAddress = append(errEmailAddress, user.EmailAddress)
}
}
errMap[domain.domain] = err
}, nil)
}
as.Wait()
if len(errEmailAddress) > 0 {
return errors.New("以下收件人投递失败:" + array.Join(errEmailAddress, ",")), errMap
}
return nil, errMap
}
func buildAddress(u []*parsemail.User) []string {
var ret []string
for _, user := range u {
ret = append(ret, user.EmailAddress)
}
return ret
}
func domainMatch(domain string, dnsNames []string) string {
secondMatch := ""
for _, name := range dnsNames {
if strings.Contains(name, "smtp") {
secondMatch = name
}
if name == domain {
return name
}
if strings.Contains(name, "*") {
nameArg := strings.Split(name, ".")
domainArg := strings.Split(domain, ".")
match := true
for i := 0; i < len(nameArg); i++ {
if nameArg[len(nameArg)-1-i] == "*" {
continue
}
if len(domainArg) > i {
if nameArg[len(nameArg)-1-i] == domainArg[len(domainArg)-1-i] {
continue
}
}
match = false
break
}
for i := 0; i < len(domainArg); i++ {
if len(nameArg) > i && nameArg[len(nameArg)-1-i] == domainArg[len(domainArg)-1-i] {
continue
}
if len(nameArg) > i && nameArg[len(nameArg)-1-i] == "*" {
continue
}
match = false
break
}
if match {
return domain
}
}
}
if secondMatch != "" {
return strings.ReplaceAll(secondMatch, "*.", "")
}
return strings.ReplaceAll(dnsNames[0], "*.", "")
}

View File

@ -0,0 +1,51 @@
package send
import (
log "github.com/sirupsen/logrus"
"os"
"pmail/config"
"pmail/dto/parsemail"
"testing"
"time"
)
func testInit() {
// 设置日志格式为json格式
//log.SetFormatter(&log.JSONFormatter{})
log.SetReportCaller(true)
log.SetFormatter(&log.TextFormatter{
//以下设置只是为了使输出更美观
DisableColors: true,
TimestampFormat: "2006-01-02 15:03:04",
})
// 设置将日志输出到标准输出默认的输出为stderr,标准错误)
// 日志消息输出可以是任意的io.writer类型
log.SetOutput(os.Stdout)
// 设置日志级别为warn以上
log.SetLevel(log.TraceLevel)
var cst, _ = time.LoadLocation("Asia/Shanghai")
time.Local = cst
config.Init()
parsemail.Init()
}
func TestSend(t *testing.T) {
testInit()
e := &parsemail.Email{
From: &parsemail.User{
Name: "发送人",
EmailAddress: "j@jinnrry.com",
},
To: []*parsemail.User{
{"ok@jinnrry.com", "名"},
},
Subject: "插件测试",
Text: []byte("这是Text"),
HTML: []byte("<div>这是Html</div>"),
}
Send(nil, e)
}

View File

@ -15,6 +15,8 @@
// Some external packages provide more functionality. See:
//
// https://godoc.org/?q=smtp
//
// 在go原始SMTP协议的基础上修复了TLS验证错误、支持了SMTPS协议
package smtp
import (
@ -26,7 +28,6 @@ import (
"net"
"net/smtp"
"net/textproto"
"pmail/config"
"strings"
)
@ -61,6 +62,22 @@ func Dial(addr string) (*Client, error) {
return NewClient(conn, host)
}
// with tls
func DialTls(addr, domain string) (*Client, error) {
// TLS config
tlsconfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: domain,
}
conn, err := tls.Dial("tcp", addr, tlsconfig)
if err != nil {
return nil, err
}
host, _, _ := net.SplitHostPort(addr)
return NewClient(conn, host)
}
// NewClient returns a new Client using an existing connection and host as a
// server name to be used when authenticating.
func NewClient(conn net.Conn, host string) (*Client, error) {
@ -70,7 +87,7 @@ func NewClient(conn net.Conn, host string) (*Client, error) {
text.Close()
return nil, err
}
c := &Client{Text: text, conn: conn, serverName: host, localName: config.Instance.Domain}
c := &Client{Text: text, conn: conn, serverName: host, localName: "jinnrry.com"}
_, c.tls = conn.(*tls.Conn)
return c, nil
}
@ -306,7 +323,53 @@ func (c *Client) Data() (io.WriteCloser, error) {
return &dataCloser{c, c.Text.DotWriter()}, nil
}
var testHookStartTLS func(*tls.Config) // nil, except for tests
func SendMailWithTls(domain string, addr string, a smtp.Auth, from string, to []string, msg []byte) error {
if err := validateLine(from); err != nil {
return err
}
for _, recp := range to {
if err := validateLine(recp); err != nil {
return err
}
}
c, err := DialTls(addr, domain)
if err != nil {
return err
}
defer c.Close()
if err = c.hello(); err != nil {
return err
}
if a != nil && c.ext != nil {
if _, ok := c.ext["AUTH"]; !ok {
return errors.New("smtp: server doesn't support AUTH")
}
if err = c.Auth(a); err != nil {
return err
}
}
if err = c.Mail(from); err != nil {
return err
}
for _, addr := range to {
if err = c.Rcpt(addr); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
_, err = w.Write(msg)
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
return c.Quit()
}
// SendMail connects to the server at addr, switches to TLS if
// possible, authenticates with the optional mechanism a if possible,