diff --git a/README.md b/README.md index 9ee3369..582544e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README_CN.md b/README_CN.md index 5a94b1c..e2639a0 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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/) 为本项目免费提供开发工具。 diff --git a/server/config/config.go b/server/config/config.go index 0e3a832..3f5b45a 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -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"` diff --git a/server/controllers/settings.go b/server/controllers/settings.go index f416890..a5f560e 100644 --- a/server/controllers/settings.go +++ b/server/controllers/settings.go @@ -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 diff --git a/server/listen/http_server/https_server.go b/server/listen/http_server/https_server.go index 86d2816..ed00f07 100644 --- a/server/listen/http_server/https_server.go +++ b/server/listen/http_server/https_server.go @@ -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 +} diff --git a/server/listen/smtp_server/read_content.go b/server/listen/smtp_server/read_content.go index eeb850a..6bb6e98 100644 --- a/server/listen/smtp_server/read_content.go +++ b/server/listen/smtp_server/read_content.go @@ -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)} diff --git a/server/main_test.go b/server/main_test.go index 0b6c4db..97fae7c 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -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) {