Feature/v2.8.10 (#323)
v2.8.10 1、bug fix #317 #320 2、支持token方式验签,使用参考 PMail_Github/server/main_test.go:135 3、587端口 starttls 问题修复 4、imap 协议功能补全 Tnanks for @HanaKDev
This commit is contained in:
parent
9753b2e09f
commit
826ac26636
@ -89,7 +89,7 @@ use [https://www.mail-tester.com/](https://www.mail-tester.com/) for checking.
|
||||
"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
|
||||
"spamFilterLevel": 0,// Spam Filtering Levels: 0: No filtering. 1: Filter when both SPF and DKIM fail (in the absence of valid recipient filtering). 2: Filter when SPF check fails (in the absence of valid recipient filtering). 3: Filter when DKIM check fails (in the absence of valid recipient filtering)
|
||||
"isInit": true // If false, it will enter the bootstrap process.
|
||||
}
|
||||
```
|
||||
@ -151,6 +151,4 @@ 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
|
||||
|
||||
Thanks to [Jetbrains](http://jetbrains.com/) for donating licenses to the project.
|
||||
|
||||
@ -92,7 +92,7 @@ PMail是一个追求极简部署流程、极致资源占用的个人域名邮箱
|
||||
"dbDSN": "./config/pmail.db", // 数据库连接DSN
|
||||
"dbType": "sqlite", //数据库类型,支持sqlite 和 mysql
|
||||
"httpsEnabled": 0, // web后台是否启用https 0默认(启用),1启用,2不启用
|
||||
"spamFilterLevel": 0,// 垃圾邮件过滤级别,0不过滤、1 spf dkim 校验均失败时过滤,2 spf校验不通过时过滤
|
||||
"spamFilterLevel": 0,// 垃圾邮件过滤级别,0不过滤、1 spf dkim 校验均失败时且无有效收件人过滤,2 spf校验不通过时且无有效收件人过滤 3,dkim 校验不过的时候且无有效收件人过滤
|
||||
"httpPort": 80, // http 端口 . 默认 80
|
||||
"httpsPort": 443, // https 端口 . 默认 443
|
||||
"isInit": true // 为false的时候会进入安装引导流程
|
||||
@ -157,6 +157,4 @@ IMAP端口: 993(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/) 为本项目免费提供开发工具。
|
||||
|
||||
@ -33,7 +33,7 @@ type Config struct {
|
||||
DbDSN string `json:"dbDSN"`
|
||||
DbType string `json:"dbType"`
|
||||
HttpsEnabled int `json:"httpsEnabled"` //后台页面是否启用https,0默认(启用),1启用,2不启用
|
||||
SpamFilterLevel int `json:"spamFilterLevel"` //垃圾邮件过滤级别,0不过滤、1 spf dkim 校验均失败时过滤,2 spf校验不通过时过滤
|
||||
SpamFilterLevel int `json:"spamFilterLevel"` //垃圾邮件过滤级别,0不过滤、1 spf dkim 校验均失败时过滤,2 spf校验不通过时过滤 3,dkim 校验不过的时候过滤
|
||||
HttpPort int `json:"httpPort"` //http服务端口设置,默认80
|
||||
HttpsPort int `json:"httpsPort"` //https服务端口,默认443
|
||||
WeChatPushAppId string `json:"weChatPushAppId"`
|
||||
|
||||
@ -30,7 +30,7 @@ func ModifyPassword(ctx *context.Context, w http.ResponseWriter, req *http.Reque
|
||||
if retData.Password != "" {
|
||||
encodePwd := password.Encode(retData.Password)
|
||||
|
||||
_, err := db.Instance.Exec(db.WithContext(ctx, "update user set password = ? where id =?"), encodePwd, ctx.UserID)
|
||||
_, err := db.Instance.Table("user").Where("id=?", ctx.UserID).Update(map[string]interface{}{"password": encodePwd})
|
||||
if err != nil {
|
||||
response.NewErrorResponse(response.ServerError, i18n.GetText(ctx.Lang, "unknowError"), "").FPrint(w)
|
||||
return
|
||||
|
||||
@ -3,19 +3,22 @@ package http_server
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/Jinnrry/pmail/config"
|
||||
"github.com/Jinnrry/pmail/controllers"
|
||||
"github.com/Jinnrry/pmail/db"
|
||||
"github.com/Jinnrry/pmail/dto/response"
|
||||
"github.com/Jinnrry/pmail/i18n"
|
||||
"github.com/Jinnrry/pmail/models"
|
||||
"github.com/Jinnrry/pmail/session"
|
||||
"github.com/Jinnrry/pmail/utils/context"
|
||||
"github.com/Jinnrry/pmail/utils/id"
|
||||
"github.com/Jinnrry/pmail/utils/password"
|
||||
olog "log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"errors"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cast"
|
||||
@ -96,17 +99,33 @@ func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
if config.IsInit {
|
||||
user := cast.ToString(session.Instance.Get(ctx, "user"))
|
||||
var userInfo *models.User
|
||||
var userInfo models.User
|
||||
if user != "" {
|
||||
_ = json.Unmarshal([]byte(user), &userInfo)
|
||||
}
|
||||
if userInfo != nil && userInfo.ID > 0 {
|
||||
if userInfo.ID > 0 {
|
||||
ctx.UserID = userInfo.ID
|
||||
ctx.UserName = userInfo.Name
|
||||
ctx.UserAccount = userInfo.Account
|
||||
ctx.IsAdmin = userInfo.IsAdmin == 1
|
||||
}
|
||||
|
||||
if userInfo.ID == 0 {
|
||||
token := r.Header.Get("Token")
|
||||
if token != "" {
|
||||
user, err := getLoginInfoByToken(token)
|
||||
if user.ID >= 0 && err == nil {
|
||||
ctx.UserID = user.ID
|
||||
ctx.UserName = user.Name
|
||||
ctx.UserAccount = user.Account
|
||||
ctx.IsAdmin = user.IsAdmin == 1
|
||||
} else {
|
||||
response.NewErrorResponse(response.NeedLogin, err.Error(), "").FPrint(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@ -120,3 +139,32 @@ func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc {
|
||||
h(ctx, w, r)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Token 格式: {account}:md5(md5(md5(password+"pmail")+"pmail2023")+{Unix timestamp}):{Unix timestamp}
|
||||
比如: admin::
|
||||
*/
|
||||
func getLoginInfoByToken(token string) (models.User, error) {
|
||||
ret := models.User{}
|
||||
data := strings.Split(token, ":")
|
||||
if len(data) != 3 {
|
||||
return ret, errors.New("token format error")
|
||||
}
|
||||
account := data[0]
|
||||
encodePwd := data[1]
|
||||
requestTimeStamp := cast.ToInt64(data[2])
|
||||
if time.Now().Unix()-requestTimeStamp >= 5 {
|
||||
return ret, errors.New("token expired")
|
||||
}
|
||||
var user models.User
|
||||
db.Instance.Table("user").Where("account =? and disabled=0", account).Get(&user)
|
||||
if user.ID == 0 {
|
||||
return ret, errors.New("account or password error")
|
||||
}
|
||||
tokenPwd := password.Md5Encode(fmt.Sprintf("%s%d", user.Password, requestTimeStamp))
|
||||
if tokenPwd != encodePwd {
|
||||
return ret, errors.New("account or password error")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@ -156,17 +156,6 @@ func (s *Session) Data(r io.Reader) error {
|
||||
}
|
||||
log.WithContext(ctx).Debugf("开始执行插件ReceiveParseAfter!End")
|
||||
|
||||
// 垃圾过滤
|
||||
if config.Instance.SpamFilterLevel == 1 && !SPFStatus && !dkimStatus {
|
||||
log.WithContext(ctx).Infoln("垃圾邮件,拒信")
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.Instance.SpamFilterLevel == 2 && !SPFStatus {
|
||||
log.WithContext(ctx).Infoln("垃圾邮件,拒信")
|
||||
return nil
|
||||
}
|
||||
|
||||
_, formDomain := email.From.GetDomainAccount()
|
||||
// 伪造邮件
|
||||
if array.InArray(formDomain, config.Instance.Domains) && SPFStatus == false {
|
||||
@ -278,7 +267,7 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
|
||||
if account != nil {
|
||||
acc, domain := account.GetDomainAccount()
|
||||
if array.InArray(domain, config.Instance.Domains) && acc != "" {
|
||||
accounts = append(accounts, acc)
|
||||
accounts = append(accounts, strings.ToLower(acc))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -286,12 +275,16 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
|
||||
for _, user := range append(append(email.To, email.Cc...), email.Bcc...) {
|
||||
account, _ := user.GetDomainAccount()
|
||||
if account != "" {
|
||||
accounts = append(accounts, account)
|
||||
accounts = append(accounts, strings.ToLower(account))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
where, params, _ := ToSQL(In("account", accounts))
|
||||
/** 这里会导致索引失效,可以尝试对lower结果加索引
|
||||
PostgreSQL: CREATE INDEX idx_user_account_lower ON "user" (LOWER(account));
|
||||
MySQL8+: ALTER TABLE user ADD INDEX ((LOWER(account)));
|
||||
*/
|
||||
where, params, _ := ToSQL(In("LOWER(account)", accounts))
|
||||
|
||||
err = db.Instance.Table(&models.User{}).Where(where, params...).Find(&users)
|
||||
if err != nil {
|
||||
@ -308,10 +301,15 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
|
||||
}
|
||||
} else {
|
||||
// 找不到收件人
|
||||
// 如果启用了保护功能,且 DKIM 验证失败,则丢弃邮件
|
||||
// DKIM 验证通过的邮件即使收件人不存在也应交给管理员,避免误丢弃合法邮件
|
||||
if DropUnknownRecipientEmails && !dkimStatus {
|
||||
// 启用了丢弃未知收件人邮件的保护功能,且 DKIM 未通过
|
||||
// 如果启用了保护功能,且 验证失败,则丢弃邮件
|
||||
// 验证通过的邮件即使收件人不存在也应交给管理员,避免误丢弃合法邮件
|
||||
|
||||
// 垃圾过滤
|
||||
if DropUnknownRecipientEmails &&
|
||||
((config.Instance.SpamFilterLevel == 1 && !SPFStatus && !dkimStatus) ||
|
||||
(config.Instance.SpamFilterLevel == 2 && !SPFStatus) ||
|
||||
(config.Instance.SpamFilterLevel == 3 && !dkimStatus)) {
|
||||
log.WithContext(ctx).Infoln("垃圾邮件,拒信")
|
||||
// 直接删除已插入的邮件记录,不关联任何用户
|
||||
log.WithContext(ctx).Infof("收件人不存在且DKIM验证失败,丢弃邮件: %s -> %v", email.From.EmailAddress, accounts)
|
||||
_, delErr := db.Instance.Delete(&models.Email{Id: modelEmail.Id})
|
||||
@ -320,10 +318,10 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// DKIM 验证通过或未启用保护功能时,邮件丢给管理员账号
|
||||
if dkimStatus {
|
||||
log.WithContext(ctx).Infof("收件人不存在但DKIM验证通过,转交管理员: %s -> %v", email.From.EmailAddress, accounts)
|
||||
}
|
||||
log.WithContext(ctx).Infof("收件人不存在但DKIM验证通过,转交管理员: %s -> %v", email.From.EmailAddress, accounts)
|
||||
|
||||
err = db.Instance.Table(&models.User{}).Where("is_admin=1").Find(&users)
|
||||
for _, user := range users {
|
||||
ue := models.UserEmail{EmailID: modelEmail.Id, UserID: user.ID, Status: cast.ToInt8(email.Status)}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
@ -114,6 +116,54 @@ func TestMaster(t *testing.T) {
|
||||
// 检查规则执行
|
||||
t.Run("testCheckRule", testCheckRule)
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
t.Run("testTokenLogin", testTokenLogin)
|
||||
}
|
||||
|
||||
func md5Encode(str string) string {
|
||||
h := md5.New()
|
||||
h.Write([]byte(str))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func genToken(account, password string) string {
|
||||
now := cast.ToString(time.Now().Unix())
|
||||
return fmt.Sprintf("%s:%s:%s", account, md5Encode(md5Encode(md5Encode(password+"pmail")+"pmail2023")+now), now)
|
||||
}
|
||||
|
||||
func testTokenLogin(t *testing.T) {
|
||||
|
||||
req, _ := http.NewRequest("POST", TestHost+"/api/email/list", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Token", genToken("testCase", "testCase"))
|
||||
|
||||
ret, err := http.DefaultClient.Do(req)
|
||||
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!")
|
||||
t.Errorf("%+v", data)
|
||||
}
|
||||
dt := data.Data.(map[string]interface{})
|
||||
if dt["list"] == nil || len(dt["list"].([]interface{})) == 0 {
|
||||
t.Error("Email List Is Empty!")
|
||||
return
|
||||
}
|
||||
|
||||
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("testTokenLogin Success! Response: %+v", data)
|
||||
|
||||
}
|
||||
|
||||
func testCheckRule(t *testing.T) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user