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:
Jinnrry 2026-02-01 16:40:24 +08:00 committed by GitHub
parent 9753b2e09f
commit 826ac26636
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 124 additions and 32 deletions

View File

@ -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.

View File

@ -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/) 为本项目免费提供开发工具。

View File

@ -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"`

View File

@ -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

View File

@ -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
}

View File

@ -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)}

View File

@ -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) {