瀏覽代碼

feature/v2.8.0

jinnrry 1 年之前
父節點
當前提交
59cc6ad4f5
共有 47 個文件被更改,包括 1815 次插入612 次删除
  1. 1 1
      .github/workflows/release.yml
  2. 2 2
      Dockerfile
  3. 1 1
      DockerfileGithubAction
  4. 3 3
      Makefile
  5. 5 2
      README.md
  6. 3 0
      README_CN.md
  7. 5 6
      fe/src/components/GroupSettings.vue
  8. 150 4
      server/config/config.go
  9. 1 1
      server/config/config.json
  10. 4 0
      server/db/init.go
  11. 3 1
      server/dto/parsemail/dkim.go
  12. 1 0
      server/go.mod
  13. 2 0
      server/go.sum
  14. 2 2
      server/listen/cron_server/ssl_update.go
  15. 153 19
      server/listen/imap_server/action.go
  16. 61 0
      server/listen/imap_server/action_test.go
  17. 0 395
      server/listen/imap_server/goimap/imap.go
  18. 3 2
      server/listen/imap_server/imap_server.go
  19. 50 0
      server/listen/imap_server/imap_server_test.go
  20. 4 3
      server/listen/pop3_server/action.go
  21. 2 0
      server/listen/pop3_server/action_test.go
  22. 2 2
      server/listen/smtp_server/read_content_test.go
  23. 0 56
      server/main.go
  24. 3 1
      server/main_test.go
  25. 24 0
      server/models/group.go
  26. 1 1
      server/models/user_email.go
  27. 3 0
      server/res_init/init.go
  28. 140 0
      server/services/group/group.go
  29. 22 0
      server/services/group/group_test.go
  30. 4 42
      server/services/setup/db.go
  31. 3 1
      server/services/setup/dns.go
  32. 4 3
      server/services/setup/domain.go
  33. 3 2
      server/services/setup/finish.go
  34. 0 37
      server/services/setup/ssl/dnsProvide_test.go
  35. 24 12
      server/services/setup/ssl/ssl.go
  36. 15 0
      server/services/setup/ssl/ssl_test.go
  37. 12 10
      server/utils/goimap/action.go
  38. 544 0
      server/utils/goimap/imap.go
  39. 66 0
      server/utils/goimap/imap_test.go
  40. 5 3
      server/utils/goimap/session.go
  41. 23 0
      server/utils/utf7/LICENSE
  42. 1 0
      server/utils/utf7/README.md
  43. 118 0
      server/utils/utf7/decoder.go
  44. 118 0
      server/utils/utf7/decoder_test.go
  45. 88 0
      server/utils/utf7/encoder.go
  46. 123 0
      server/utils/utf7/encoder_test.go
  47. 13 0
      server/utils/utf7/utf7.go

+ 1 - 1
.github/workflows/release.yml

@@ -54,7 +54,7 @@ jobs:
         run: cd fe && yarn && yarn build
       - name: BE Build
         run: |
-          cd server && cp -rf ../fe/dist http_server
+          cd server && cp -rf ../fe/dist listen/http_server
           go build -ldflags "-s -w -X 'main.version=${{ env.VERSION }}' -X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o ${{ env.FILENAME }}  main.go
           go build -ldflags "-s -w" -o ${{ env.TGFILENAME }} hooks/telegram_push/telegram_push.go 
           go build -ldflags "-s -w" -o ${{ env.WCFILENAME }} hooks/wechat_push/wechat_push.go

+ 2 - 2
Dockerfile

@@ -10,7 +10,7 @@ FROM golang:alpine as serverbuild
 ARG VERSION
 WORKDIR /work
 COPY . .
-COPY --from=febuild /work/dist /work/server/http_server/dist
+COPY --from=febuild /work/dist /work/server/listen/http_server/dist
 RUN apk update && apk add git
 RUN cd /work/server && go build -ldflags "-s -w -X 'main.version=${VERSION}' -X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail main.go
 RUN cd /work/server/hooks/telegram_push && go build -ldflags "-s -w" -o output/telegram_push telegram_push.go
@@ -34,6 +34,6 @@ COPY --from=serverbuild /work/server/hooks/telegram_push/output/* ./plugins/
 COPY --from=serverbuild /work/server/hooks/wechat_push/output/* ./plugins/
 COPY --from=serverbuild /work/server/hooks/spam_block/output/* ./plugins/
 
-EXPOSE 25 80 110 443 465 995
+EXPOSE 25 80 110 443 465 995 993
 
 CMD /work/pmail

+ 1 - 1
DockerfileGithubAction

@@ -28,6 +28,6 @@ COPY --from=serverbuild /work/hooks/telegram_push/output/* ./plugins/
 COPY --from=serverbuild /work/hooks/wechat_push/output/* ./plugins/
 COPY --from=serverbuild /work/hooks/spam_block/output/* ./plugins/
 
-EXPOSE 25 80 110 443 465 995
+EXPOSE 25 80 110 443 465 995 993
 
 CMD /work/pmail

+ 3 - 3
Makefile

@@ -6,8 +6,8 @@ clean:
 
 build_fe:
 	cd fe && yarn && yarn build
-	rm -rf server/http_server/dist
-	cd server && cp -rf ../fe/dist http_server
+	rm -rf server/listen/http_server/dist
+	cd server && cp -rf ../fe/dist listen/http_server
 
 build_server:
 	cd server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w -X 'main.goVersion=$(go version)' -X 'main.gitHash=$(git show -s --format=%H)' -X 'main.buildTime=$(TZ=UTC-8 date +%Y-%m-%d" "%H:%M:%S)'" -o pmail_linux_amd64  main.go
@@ -52,7 +52,7 @@ package: clean
 	cp README.md output/
 
 test:
-	export setup_port=17888 && cd server && go test -v ./...
+	export setup_port=17888 && cd server && export PMail_ROOT=$(CURDIR)/server/ && go test -v ./...
 
 test_mysql:
 	export setup_port=17888 && cd server && go test -args "mysql" -v ./...

+ 5 - 2
README.md

@@ -57,10 +57,10 @@ First go to [spamhaus](https://check.spamhaus.org/) and check your domain name a
 
 Or
 
-`docker run -p 25:25 -p 80:80 -p 443:443 -p 110:110 -p 465:465 -p 995:995 -v $(pwd)/config:/work/config ghcr.io/jinnrry/pmail:latest`
+`docker run -p 25:25 -p 80:80 -p 443:443 -p 110:110 -p 465:465 -p 995:995 -p 993:993 -v $(pwd)/config:/work/config ghcr.io/jinnrry/pmail:latest`
 
 > [!IMPORTANT]
-> If your server has a firewall turned on, you need to open ports 25, 80, 110, 443, 465, 995
+> If your server has a firewall turned on, you need to open ports 25, 80, 110, 443, 465, 993, 995
 
 ## 3、Configuration
 
@@ -104,6 +104,9 @@ SMTP Server Address : smtp.[Your Domain]
 
 SMTP Port: 25/465(SSL)
 
+IMAP Server Address : imap.[Your Domain]
+
+IMAP Port: 993(SSL)
 # Plugin
 
 [WeChat Push](server/hooks/wechat_push/README.md)

+ 3 - 0
README_CN.md

@@ -109,6 +109,9 @@ SMTP地址: smtp.[你的域名]
 
 SMTP端口: 25/465(SSL)
 
+IMAP地址: imap.[Your Domain]
+
+IMAP端口: 993(SSL)
 
 # 插件
 

+ 5 - 6
fe/src/components/GroupSettings.vue

@@ -53,7 +53,7 @@ http.get("/api/group").then((res) => {
 
 const del = function (node, data) {
   if (data.id !== -1) {
-    this.$axios.post("/api/group/del", { id: data.id }).then((res) => {
+    http.post("/api/group/del", { id: data.id }).then((res) => {
       if (res.errorNo !== 0) {
         ElMessage({
           message: res.errorMsg,
@@ -87,7 +87,7 @@ const add = function (item) {
   item.children.push({
     children: [],
     label: "",
-    id: "-1",
+    id: -1,
     parent_id: item.id,
   });
 };
@@ -96,15 +96,14 @@ const addRoot = function () {
   data.push({
     children: [],
     label: "",
-    id: "-1",
+    id: -1,
     parent_id: 0,
   });
 };
 
 const onInputBlur = function (item) {
   if (item.label !== "") {
-    http
-      .post("/api/group/add", { name: item.label, parent_id: item.parent_id })
+    http.post("/api/group/add", { name: item.label, parent_id: item.parent_id })
       .then((res) => {
         if (res.errorNo !== 0) {
           ElMessage({
@@ -112,7 +111,7 @@ const onInputBlur = function (item) {
             type: "error",
           });
         } else {
-          this.$axios.get("/api/group").then((res) => {
+          http.get("/api/group").then((res) => {
             data.splice(0, data.length);
             data.push(...res.data);
           });

+ 150 - 4
server/config/config.go

@@ -1,13 +1,22 @@
 package config
 
 import (
+	"bytes"
 	"crypto/ecdsa"
 	"crypto/elliptic"
 	"crypto/rand"
 	"crypto/x509"
 	"encoding/json"
 	"encoding/pem"
+	"fmt"
+	"github.com/Jinnrry/pmail/utils/context"
+	"github.com/Jinnrry/pmail/utils/errors"
+	"github.com/Jinnrry/pmail/utils/file"
+	log "github.com/sirupsen/logrus"
 	"os"
+	"path/filepath"
+	"strings"
+	"time"
 )
 
 var IsInit bool
@@ -41,6 +50,43 @@ type Config struct {
 	setupPort            int               // 初始化阶段端口
 }
 
+var ROOT_PATH = ""
+
+func init() {
+	envs := os.Environ()
+	for _, env := range envs {
+		if strings.HasPrefix(env, "PMail_ROOT=") {
+			ROOT_PATH = strings.TrimSpace(strings.ReplaceAll(env, "PMail_ROOT=", ""))
+			if !strings.HasSuffix(ROOT_PATH, "/") {
+				ROOT_PATH += "/"
+			}
+
+			fmt.Println("Env Root Path:", ROOT_PATH)
+			return
+		}
+	}
+
+	ex, err := os.Executable()
+	if err != nil {
+		panic(err)
+	}
+	exPath := filepath.Dir(ex)
+	realPath, err := filepath.EvalSymlinks(exPath)
+	if err != nil {
+		panic(err)
+	}
+	// 如果是Goland运行,不修改根路径
+	if strings.Contains(realPath, "GoLand") && strings.Contains(realPath, "JetBrains") {
+		return
+	}
+
+	if !strings.HasSuffix(realPath, "/") {
+		realPath += "/"
+	}
+	ROOT_PATH = realPath
+	fmt.Println("Root Path:", ROOT_PATH)
+}
+
 func (c *Config) GetSetupPort() int {
 	return c.setupPort
 }
@@ -60,24 +106,47 @@ var DBTypes []string = []string{DBTypeMySQL, DBTypeSQLite, DBTypePostgres}
 
 var Instance *Config = &Config{}
 
+type logFormatter struct {
+}
+
+// Format 定义日志输出格式
+func (l *logFormatter) Format(entry *log.Entry) ([]byte, error) {
+	b := bytes.Buffer{}
+
+	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 {
+		ctx := entry.Context.(*context.Context)
+		if ctx != nil {
+			b.WriteString(fmt.Sprintf("[%s]", ctx.GetValue(context.LogID)))
+		}
+	}
+	b.WriteString(fmt.Sprintf("[%s:%d]", entry.Caller.File, entry.Caller.Line))
+	b.WriteString(entry.Message)
+
+	b.WriteString("\n")
+	return b.Bytes(), nil
+}
 func Init() {
 	var cfgData []byte
 	var err error
 	args := os.Args
 
 	if len(args) >= 2 && args[len(args)-1] == "dev" {
-		cfgData, err = os.ReadFile("./config/config.dev.json")
+		cfgData, err = os.ReadFile(ROOT_PATH + "./config/config.dev.json")
 		if err != nil {
 			return
 		}
 	} else {
-		cfgData, err = os.ReadFile("./config/config.json")
+		cfgData, err = os.ReadFile(ROOT_PATH + "./config/config.json")
 		if err != nil {
+			log.Errorf("config file not found,%s", err.Error())
 			return
 		}
 	}
 
 	err = json.Unmarshal(cfgData, &Instance)
+	Instance.fixPath()
 	if err != nil {
 		return
 	}
@@ -90,10 +159,39 @@ func Init() {
 		IsInit = true
 	}
 
+	// 设置日志格式为json格式
+	log.SetFormatter(&logFormatter{})
+	log.SetReportCaller(true)
+
+	// 设置将日志输出到标准输出(默认的输出为stderr,标准错误)
+	// 日志消息输出可以是任意的io.writer类型
+	log.SetOutput(os.Stdout)
+
+	var cstZone = time.FixedZone("CST", 8*3600)
+	time.Local = cstZone
+	if Instance != nil {
+		switch Instance.LogLevel {
+		case "":
+			log.SetLevel(log.InfoLevel)
+		case "debug":
+			log.SetLevel(log.DebugLevel)
+		case "info":
+			log.SetLevel(log.InfoLevel)
+		case "warn":
+			log.SetLevel(log.WarnLevel)
+		case "error":
+			log.SetLevel(log.ErrorLevel)
+		default:
+			log.SetLevel(log.InfoLevel)
+		}
+	} else {
+		log.SetLevel(log.InfoLevel)
+	}
+
 }
 
 func ReadPrivateKey() (*ecdsa.PrivateKey, bool) {
-	key, err := os.ReadFile("./config/ssl/account_private.pem")
+	key, err := os.ReadFile(ROOT_PATH + "./config/ssl/account_private.pem")
 	if err != nil {
 		return createNewPrivateKey(), true
 	}
@@ -114,10 +212,58 @@ func createNewPrivateKey() *ecdsa.PrivateKey {
 	x509Encoded, _ := x509.MarshalECPrivateKey(privateKey)
 
 	// 将ec 密钥写入到 pem文件里
-	keypem, _ := os.OpenFile("./config/ssl/account_private.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
+	keypem, _ := os.OpenFile(ROOT_PATH+"./config/ssl/account_private.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666)
 	err = pem.Encode(keypem, &pem.Block{Type: "EC PRIVATE KEY", Bytes: x509Encoded})
 	if err != nil {
 		panic(err)
 	}
 	return privateKey
 }
+
+func WriteConfig(cfg *Config) error {
+	bytes, _ := json.Marshal(cfg)
+	err := os.WriteFile(ROOT_PATH+"./config/config.json", bytes, 0666)
+	if err != nil {
+		return errors.Wrap(err)
+	}
+	return nil
+}
+
+func ReadConfig() (*Config, error) {
+	configData := Config{
+		DkimPrivateKeyPath: ROOT_PATH + "config/dkim/dkim.priv",
+		SSLPrivateKeyPath:  ROOT_PATH + "config/ssl/private.key",
+		SSLPublicKeyPath:   ROOT_PATH + "config/ssl/public.crt",
+	}
+	if !file.PathExist(ROOT_PATH + "./config/config.json") {
+		bytes, _ := json.Marshal(configData)
+		err := os.WriteFile(ROOT_PATH+"./config/config.json", bytes, 0666)
+		if err != nil {
+			return nil, errors.Wrap(err)
+		}
+	} else {
+		cfgData, err := os.ReadFile(ROOT_PATH + "./config/config.json")
+		if err != nil {
+			return nil, errors.Wrap(err)
+		}
+
+		err = json.Unmarshal(cfgData, &configData)
+		configData.fixPath()
+		if err != nil {
+			return nil, errors.Wrap(err)
+		}
+	}
+	return &configData, nil
+}
+
+func (c *Config) fixPath() {
+	if c.DbType == DBTypeSQLite && !strings.HasPrefix(c.DbDSN, "/") {
+		c.DbDSN = ROOT_PATH + c.DbDSN
+	}
+	if !strings.HasPrefix(c.SSLPublicKeyPath, "/") {
+		c.SSLPublicKeyPath = ROOT_PATH + c.SSLPublicKeyPath
+	}
+	if !strings.HasPrefix(c.SSLPrivateKeyPath, "/") {
+		c.SSLPrivateKeyPath = ROOT_PATH + c.SSLPrivateKeyPath
+	}
+}

+ 1 - 1
server/config/config.json

@@ -1,5 +1,5 @@
 {
-  "logLevel": "",
+  "logLevel": "debug",
   "domain": "test.domain",
   "domains": null,
   "webDomain": "mail.test.domain",

+ 4 - 0
server/db/init.go

@@ -58,6 +58,10 @@ func Init(version string) error {
 		Instance.Update(&v)
 	}
 
+	//if config.Instance.LogLevel == "debug" {
+	//	Instance.ShowSQL(true)
+	//}
+
 	return nil
 }
 

+ 3 - 1
server/dto/parsemail/dkim.go

@@ -25,7 +25,9 @@ var instance *Dkim
 func Init() {
 	privateKey, err := loadPrivateKey(config.Instance.DkimPrivateKeyPath)
 	if err != nil {
-		panic("DKIM load fail! Please set dkim!  dkim私钥加载失败!请先设置dkim秘钥")
+		panic(config.Instance.DkimPrivateKeyPath +
+			" DKIM load fail! Please set dkim!  dkim私钥加载失败!请先设置dkim秘钥" +
+			err.Error())
 	}
 
 	instance = &Dkim{

+ 1 - 0
server/go.mod

@@ -30,6 +30,7 @@ require (
 	filippo.io/edwards25519 v1.1.0 // indirect
 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/emersion/go-imap/v2 v2.0.0-beta.4 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.4 // indirect
 	github.com/goccy/go-json v0.10.3 // indirect
 	github.com/golang/snappy v0.0.4 // indirect

+ 2 - 0
server/go.sum

@@ -21,6 +21,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
 github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/emersion/go-imap/v2 v2.0.0-beta.4 h1:BS7+kUVhe/jfuFWgn8li0AbCKBIDoNvqJWsRJppltcc=
+github.com/emersion/go-imap/v2 v2.0.0-beta.4/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
 github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
 github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
 github.com/emersion/go-msgauth v0.6.8 h1:kW/0E9E8Zx5CdKsERC/WnAvnXvX7q9wTHia1OA4944A=

+ 2 - 2
server/listen/cron_server/ssl_update.go

@@ -33,14 +33,14 @@ func Start() {
 // 每天检查一遍SSL证书是否更新,更新就重启
 func sslCheck() {
 	var err error
-	_, expiredTime, err = ssl.CheckSSLCrtInfo()
+	_, expiredTime, _, err = ssl.CheckSSLCrtInfo()
 	if err != nil {
 		panic(err)
 	}
 
 	for {
 		time.Sleep(24 * time.Hour)
-		_, newExpTime, err := ssl.CheckSSLCrtInfo()
+		_, newExpTime, _, err := ssl.CheckSSLCrtInfo()
 		if err != nil {
 			log.Errorf("SSL Check Error! %+v", err)
 		}

+ 153 - 19
server/listen/imap_server/action.go

@@ -1,10 +1,53 @@
 package imap_server
 
 import (
-	"github.com/Jinnrry/pmail/listen/imap_server/goimap"
+	"database/sql"
+	errors2 "errors"
+	"fmt"
+	"github.com/Jinnrry/pmail/db"
+	"github.com/Jinnrry/pmail/models"
+	"github.com/Jinnrry/pmail/services/group"
+	"github.com/Jinnrry/pmail/utils/context"
+	"github.com/Jinnrry/pmail/utils/errors"
+	"github.com/Jinnrry/pmail/utils/goimap"
+	"github.com/Jinnrry/pmail/utils/id"
+	"github.com/Jinnrry/pmail/utils/password"
 	log "github.com/sirupsen/logrus"
+	"strings"
+	"sync"
 )
 
+var idlePool sync.Map
+
+func init() {
+	idlePool = sync.Map{}
+}
+
+// PushMsgByIDLE 向IMAP客户端通知新邮件消息
+func PushMsgByIDLE(ctx *context.Context, account string, unionId string) error {
+	sess, ok := idlePool.Load(account)
+	if ok {
+		sSessions, ok2 := sess.([]*goimap.Session)
+		if !ok2 {
+			idlePool.Delete(account)
+			return nil
+		}
+		newPool := []*goimap.Session{}
+		for _, sSession := range sSessions {
+			if sSession.IN_IDLE && sSession.Conn != nil {
+				fmt.Fprintf(sSession.Conn, fmt.Sprintf("* %s EXISTS", unionId))
+				newPool = append(newPool, sSession)
+			}
+		}
+		if len(newPool) == 0 {
+			idlePool.Delete(account)
+		} else {
+			idlePool.Store(account, newPool)
+		}
+	}
+	return nil
+}
+
 type action struct{}
 
 func (a action) Create(session *goimap.Session, path string) error {
@@ -13,28 +56,53 @@ func (a action) Create(session *goimap.Session, path string) error {
 }
 
 func (a action) Delete(session *goimap.Session, path string) error {
-	log.Infof("%s,%s", "Create", path)
+	log.Infof("%s,%s", "Delete", path)
 	return nil
 }
 
 func (a action) Rename(session *goimap.Session, oldPath, newPath string) error {
-	log.Infof("%s,%s,%s", "Create", oldPath, newPath)
+	log.Infof("%s,%s,%s", "Rename", oldPath, newPath)
 	return nil
 }
 
 func (a action) List(session *goimap.Session, basePath, template string) ([]string, error) {
-	log.Infof("%s,%s,%s", "Create", basePath, template)
-	return nil, nil
+	log.Infof("%s,%s,%s", "List", basePath, template)
+	var ret []string
+	if basePath == "" && template == "" {
+		ret = append(ret, `* LIST (\NoSelect \HasChildren) "/" "[PMail]`)
+		return ret, nil
+	}
+
+	ret = group.MatchGroup(session.Ctx.(*context.Context), basePath, template)
+
+	return ret, nil
 }
 
 func (a action) Append(session *goimap.Session, item string) error {
-	log.Infof("%s,%s", "Create", item)
+	log.Infof("%s,%s", "Append", item)
 	return nil
 }
 
-func (a action) Select(session *goimap.Session, path string) error {
-	log.Infof("%s,%s", "Create", path)
-	return nil
+func (a action) Select(session *goimap.Session, path string) ([]string, error) {
+	log.Infof("%s,%s", "Select", path)
+	paths := strings.Split(path, "/")
+	session.CurrentPath = paths[len(paths)-1]
+	_, data := group.GetGroupStatus(session.Ctx.(*context.Context), session.CurrentPath, []string{"MESSAGES", "UNSEEN", "UIDNEXT", "UIDVALIDITY"})
+	ret := []string{}
+	allNum := data["MESSAGES"]
+	ret = append(ret, fmt.Sprintf("* %d EXISTS", allNum))
+	ret = append(ret, fmt.Sprintf("* 0 RECENT"))
+	unRead := data["UNSEEN"]
+	ret = append(ret, fmt.Sprintf("* OK [UNSEEN %d]", unRead))
+	unionID := data["UIDVALIDITY"]
+	ret = append(ret, fmt.Sprintf("* OK [UIDVALIDITY %d] UID validity status", unionID))
+	nextID := data["UIDNEXT"]
+	ret = append(ret, fmt.Sprintf("* OK [UIDNEXT %d] Predicted next UID", nextID))
+	ret = append(ret, `* FLAGS (\Answered \Flagged \Deleted \Draft \Seen)`)
+	ret = append(ret, `* OK [PERMANENTFLAGS (\* \Answered \Flagged \Deleted \Draft \Seen)] Permanent flags`)
+	ret = append(ret, `$$NUM OK [READ-WRITE] SELECT complete`)
+
+	return ret, nil
 }
 
 func (a action) Fetch(session *goimap.Session, mailIds, dataNames string) (string, error) {
@@ -77,9 +145,10 @@ func (a action) LSub(session *goimap.Session, path, mailbox string) ([]string, e
 	return nil, nil
 }
 
-func (a action) Status(session *goimap.Session, mailbox, category string) (string, error) {
-	log.Infof("%s,%s,%s", "Status", mailbox, category)
-	return "", nil
+func (a action) Status(session *goimap.Session, mailbox string, category []string) (string, error) {
+	log.Infof("%s,%s,%+v", "Status", mailbox, category)
+	ret, _ := group.GetGroupStatus(session.Ctx.(*context.Context), mailbox, category)
+	return fmt.Sprintf(`* STATUS "%s" %s`, mailbox, ret), nil
 }
 
 func (a action) Check(session *goimap.Session) error {
@@ -99,7 +168,38 @@ func (a action) Copy(session *goimap.Session, mailId, mailBoxName string) error
 
 func (a action) CapaBility(session *goimap.Session) ([]string, error) {
 	log.Infof("%s", "CapaBility")
-	return nil, nil
+	return []string{
+		"CAPABILITY",
+		"IMAP4rev1",
+		"UNSELECT",
+		"IDLE",
+		"AUTH=PLAIN",
+		"AUTH=LOGIN",
+	}, nil
+}
+
+func (a action) IDLE(session *goimap.Session) error {
+	pools, ok := idlePool.Load(session.Account)
+	if !ok {
+		idlePool.Store(session.Account, []*goimap.Session{
+			session,
+		})
+	} else {
+		sPools, ok := pools.([]*goimap.Session)
+		if !ok {
+			idlePool.Delete(session.Account)
+		} else {
+			sPools = append(sPools, session)
+			idlePool.Store(session.Account, sPools)
+		}
+	}
+	return nil
+}
+
+func (a action) Unselect(session *goimap.Session) error {
+	log.Infof("%s", "Unselect")
+	session.CurrentPath = ""
+	return nil
 }
 
 func (a action) Noop(session *goimap.Session) error {
@@ -107,17 +207,51 @@ func (a action) Noop(session *goimap.Session) error {
 	return nil
 }
 
-func (a action) Login(session *goimap.Session, username, password string) error {
-	log.Infof("%s,%s,%s", "Login", username, password)
-	return nil
+func (a action) Login(session *goimap.Session, username, pwd string) error {
+	log.WithContext(session.Ctx).Infof("%s,%s,%s", "Login", username, pwd)
+
+	if strings.Contains(username, "@") {
+		datas := strings.Split(username, "@")
+		username = datas[0]
+	}
+
+	if session.Ctx == nil {
+		tc := &context.Context{}
+		tc.SetValue(context.LogID, id.GenLogID())
+		session.Ctx = tc
+	}
+
+	var user models.User
+
+	encodePwd := password.Encode(pwd)
+
+	_, err := db.Instance.Where("account =? and password =? and disabled = 0", username, encodePwd).Get(&user)
+	if err != nil && !errors.Is(err, sql.ErrNoRows) {
+		log.WithContext(session.Ctx.(*context.Context)).Errorf("%+v", err)
+	}
+
+	if user.ID > 0 {
+		session.Status = goimap.AUTHORIZED
+
+		session.Ctx.(*context.Context).UserID = user.ID
+		session.Ctx.(*context.Context).UserName = user.Name
+		session.Ctx.(*context.Context).UserAccount = user.Account
+
+		return nil
+	}
+
+	return errors2.New("password error")
 }
 
 func (a action) Logout(session *goimap.Session) error {
-	log.Infof("%s", "Logout")
+	session.Status = goimap.UNAUTHORIZED
+	if session.Conn != nil {
+		_ = session.Conn.Close()
+	}
 	return nil
 }
 
-func (a action) Custom(session *goimap.Session, cmd string, args []string) ([]string, error) {
-	log.Infof("%s,%+v", cmd, args)
+func (a action) Custom(session *goimap.Session, cmd string, args string) ([]string, error) {
+	log.Infof("Custom  %s,%+v", cmd, args)
 	return nil, nil
 }

+ 61 - 0
server/listen/imap_server/action_test.go

@@ -0,0 +1,61 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/config"
+	"github.com/Jinnrry/pmail/db"
+	"github.com/Jinnrry/pmail/models"
+	"github.com/Jinnrry/pmail/utils/context"
+	"github.com/Jinnrry/pmail/utils/goimap"
+	"github.com/Jinnrry/pmail/utils/id"
+	"testing"
+)
+
+func Test_action_List(t *testing.T) {
+	config.Init()
+	db.Init("")
+	db.Instance.ShowSQL(true)
+
+	_, err := db.Instance.Exec("DELETE FROM `group`")
+
+	groupData1 := models.Group{
+		ID:     1,
+		Name:   "第一层",
+		UserId: 1,
+	}
+	groupData2 := models.Group{
+		ID:       2,
+		Name:     "第二层",
+		UserId:   1,
+		ParentId: 1,
+	}
+	db.Instance.Insert(&groupData1)
+	db.Instance.Insert(&groupData2)
+
+	session := goimap.Session{
+		Account: "admin",
+	}
+	tc := &context.Context{}
+	tc.UserID = 1
+	tc.SetValue(context.LogID, id.GenLogID())
+	session.Ctx = tc
+
+	ret, err := action{}.List(&session, "", "")
+	if err != nil {
+		t.Error(err)
+	}
+	if len(ret) == 1 && ret[0] == `* LIST (\NoSelect \HasChildren) "/" "[PMail]` {
+		t.Logf("%s", ret[0])
+	} else {
+		t.Errorf("%s", ret)
+	}
+
+	ret, err = action{}.List(&session, "", "*")
+	if err != nil {
+		t.Error(err)
+	}
+	if len(ret) == 8 && ret[7] == `* LIST (\HasNoChildren) "/" "&eyxOAFxC-/&eyxOjFxC-"` {
+		t.Logf("%s", ret[0])
+	} else {
+		t.Errorf("%s", ret)
+	}
+}

+ 0 - 395
server/listen/imap_server/goimap/imap.go

@@ -1,395 +0,0 @@
-package goimap
-
-import (
-	"bufio"
-	"crypto/tls"
-	"errors"
-	"fmt"
-	"io"
-	"log/slog"
-	"net"
-	"strings"
-	"sync"
-	"time"
-)
-
-var (
-	eol = "\r\n"
-)
-
-// Server Imap服务实例
-type Server struct {
-	Domain           string        // 域名
-	Port             int           // 端口
-	TlsEnabled       bool          //是否启用Tls
-	TlsConfig        *tls.Config   // tls配置
-	ConnectAliveTime time.Duration // 连接存活时间,默认不超时
-	Action           Action
-	stop             chan bool
-	close            bool
-	lck              sync.Mutex
-}
-
-// NewImapServer 新建一个服务实例
-func NewImapServer(port int, domain string, tlsEnabled bool, tlsConfig *tls.Config, action Action) *Server {
-	return &Server{
-		Domain:     domain,
-		Port:       port,
-		TlsEnabled: tlsEnabled,
-		TlsConfig:  tlsConfig,
-		Action:     action,
-		stop:       make(chan bool, 1),
-	}
-}
-
-// Start 启动服务
-func (s *Server) Start() error {
-	if !s.TlsEnabled {
-		return s.startWithoutTLS()
-	} else {
-		return s.startWithTLS()
-	}
-}
-
-func (s *Server) startWithTLS() error {
-	if s.lck.TryLock() {
-		listener, err := tls.Listen("tcp", fmt.Sprintf(":%d", s.Port), s.TlsConfig)
-		if err != nil {
-			return err
-		}
-		s.close = false
-		defer func() {
-			listener.Close()
-		}()
-
-		go func() {
-			for {
-				conn, err := listener.Accept()
-				if err != nil {
-					if s.close {
-						break
-					} else {
-						continue
-					}
-				}
-				go s.handleClient(conn)
-			}
-		}()
-		<-s.stop
-	} else {
-		return errors.New("Server Is Running")
-	}
-
-	return nil
-}
-
-func (s *Server) startWithoutTLS() error {
-	if s.lck.TryLock() {
-		listener, err := net.Listen("tcp", fmt.Sprintf(":%d", s.Port))
-		if err != nil {
-			return err
-		}
-		s.close = false
-		defer func() {
-			listener.Close()
-		}()
-
-		go func() {
-			for {
-				conn, err := listener.Accept()
-				if err != nil {
-					if s.close {
-						break
-					} else {
-						continue
-					}
-				}
-				go s.handleClient(conn)
-			}
-		}()
-		<-s.stop
-	} else {
-		return errors.New("Server Is Running")
-	}
-
-	return nil
-}
-
-// Stop 停止服务
-func (s *Server) Stop() {
-	s.close = true
-	s.stop <- true
-}
-
-func (s *Server) handleClient(conn net.Conn) {
-	slog.Debug("Imap conn")
-
-	defer conn.Close()
-
-	session := &Session{
-		Conn:      conn,
-		AliveTime: time.Now(),
-	}
-	if s.TlsEnabled && s.TlsConfig != nil {
-		session.InTls = true
-	}
-
-	// 检查连接是否超时
-	if s.ConnectAliveTime != 0 {
-		go func() {
-			for {
-				if time.Now().Sub(session.AliveTime) >= s.ConnectAliveTime {
-					session.Conn.Close()
-					return
-				}
-				time.Sleep(3 * time.Second)
-			}
-		}()
-	}
-
-	reader := bufio.NewReader(conn)
-
-	fmt.Fprintf(conn, "* OK %s Imap Server powered by goimap"+eol, s.Domain)
-
-	for {
-		rawLine, err := reader.ReadString('\n')
-		if err != nil {
-			conn.Close()
-			return
-		}
-		session.AliveTime = time.Now()
-
-		nub, cmd, args := getCommand(rawLine)
-		slog.Debug(fmt.Sprintf("nub:%s cmd:%s args:%v", nub, cmd, args))
-
-		switch cmd {
-		case "CAPABILITY":
-			commands, err := s.Action.CapaBility(session)
-			if err != nil {
-				fmt.Fprintf(conn, "* BAD %s%s", err.Error(), eol)
-			} else {
-				ret := fmt.Sprintf("%s ", nub)
-				for _, command := range commands {
-					ret += fmt.Sprintf("%s%s", command, " ")
-				}
-				ret += fmt.Sprintf("%s", eol)
-				fmt.Fprintf(conn, ret)
-			}
-
-		case "CREATE":
-			if len(args) != 1 {
-				paramsErr(conn, "RENAME")
-				break
-			}
-			err := s.Action.Create(session, args[0])
-			output(conn, nub, err)
-		case "DELETE":
-			if len(args) != 1 {
-				paramsErr(conn, "RENAME")
-				break
-			}
-			err := s.Action.Delete(session, args[0])
-			output(conn, nub, err)
-		case "RENAME":
-			if len(args) != 2 {
-				paramsErr(conn, "RENAME")
-			} else {
-				err := s.Action.Rename(session, args[0], args[1])
-				output(conn, nub, err)
-			}
-		case "LIST":
-			if len(args) != 2 {
-				paramsErr(conn, "RENAME")
-			} else {
-				rets, err := s.Action.List(session, args[0], args[1])
-				if err != nil {
-					showBad(conn, err)
-				} else {
-					ret := ""
-					for _, str := range rets {
-						ret += str + eol
-					}
-					fmt.Fprintf(conn, ret)
-					showSucc(conn, nub)
-				}
-			}
-		case "APPEND":
-			slog.Debug("Append %s", args)
-		case "SELECT":
-			if len(args) != 1 {
-				paramsErr(conn, "RENAME")
-			} else {
-				err := s.Action.Select(session, args[0])
-				output(conn, nub, err)
-			}
-		case "FETCH":
-			if len(args) != 2 {
-				paramsErr(conn, "RENAME")
-			} else {
-				ret, err := s.Action.Fetch(session, args[0], args[1])
-				if err != nil {
-					showBad(conn, err)
-				} else {
-					fmt.Fprintf(conn, ret)
-					showSucc(conn, ret)
-				}
-			}
-		case "STORE":
-			if len(args) != 2 {
-				paramsErr(conn, "RENAME")
-			} else {
-				err := s.Action.Store(session, args[0], args[1])
-				output(conn, nub, err)
-			}
-		case "CLOSE":
-			err := s.Action.Close(session)
-			output(conn, nub, err)
-		case "EXPUNGE":
-			err := s.Action.Expunge(session)
-			output(conn, nub, err)
-		case "EXAMINE":
-			if len(args) != 1 {
-				paramsErr(conn, "EXAMINE")
-			}
-			err := s.Action.Examine(session, args[0])
-			output(conn, nub, err)
-		case "SUBSCRIBE":
-			if len(args) != 1 {
-				paramsErr(conn, "SUBSCRIBE")
-			} else {
-				err := s.Action.Subscribe(session, args[0])
-				output(conn, nub, err)
-			}
-		case "UNSUBSCRIBE":
-			if len(args) != 1 {
-				paramsErr(conn, "UNSUBSCRIBE")
-			} else {
-				err := s.Action.UnSubscribe(session, args[0])
-				output(conn, nub, err)
-			}
-		case "LSUB":
-			if len(args) != 2 {
-				paramsErr(conn, "LSUB")
-			} else {
-				rets, err := s.Action.LSub(session, args[0], args[1])
-				if err != nil {
-					showBad(conn, err)
-				} else {
-					ret := ""
-					for _, str := range rets {
-						ret += str + eol
-					}
-					fmt.Fprintf(conn, ret)
-					showSucc(conn, nub)
-				}
-			}
-		case "STATUS":
-			if len(args) != 2 {
-				paramsErr(conn, "STATUS")
-			} else {
-				ret, err := s.Action.Status(session, args[0], args[1])
-				if err != nil {
-					showBad(conn, err)
-				} else {
-					fmt.Fprintf(conn, ret)
-					showSucc(conn, nub)
-				}
-			}
-		case "CHECK":
-			err := s.Action.Check(session)
-			output(conn, nub, err)
-		case "SEARCH":
-			if len(args) < 2 {
-				paramsErr(conn, "SEARCH")
-			} else {
-				ret, err := s.Action.Search(session, args[0], args[1])
-				if err != nil {
-					showBad(conn, err)
-				} else {
-					fmt.Fprintf(conn, ret)
-					showSucc(conn, nub)
-				}
-			}
-		case "COPY":
-			if len(args) != 2 {
-				paramsErr(conn, "COPY")
-			} else {
-				err := s.Action.Copy(session, args[0], args[1])
-				output(conn, nub, err)
-			}
-
-		case "NOOP":
-			err := s.Action.Noop(session)
-			output(conn, nub, err)
-		case "LOGIN":
-			if len(args) != 2 {
-				paramsErr(conn, "LOGIN")
-			} else {
-				err := s.Action.Login(session, args[0], args[1])
-				output(conn, nub, err)
-			}
-		case "LOGOUT":
-			err := s.Action.Logout(session)
-			output(conn, nub, err)
-			conn.Close()
-		default:
-			rets, err := s.Action.Custom(session, cmd, args)
-			if err != nil {
-				fmt.Fprintf(conn, "* BAD %s %s", err.Error(), eol)
-			} else {
-				if len(rets) == 0 {
-					fmt.Fprintf(conn, "%s OK %s", nub, eol)
-				} else if len(rets) == 1 {
-					fmt.Fprintf(conn, "%s OK %s%s", nub, rets[0], eol)
-				} else {
-					ret := fmt.Sprintf("%s OK %s", nub, eol)
-					for _, re := range rets {
-						ret += fmt.Sprintf("%s%s", re, eol)
-					}
-					ret += "." + eol
-					fmt.Fprintf(conn, ret)
-				}
-			}
-		}
-
-	}
-}
-
-// cuts the line into command and arguments
-func getCommand(line string) (string, string, []string) {
-	line = strings.Trim(line, "\r \n")
-	cmd := strings.Split(line, " ")
-
-	return strings.ToTitle(cmd[0]), strings.ToTitle(cmd[1]), cmd[2:]
-}
-
-func getSafeArg(args []string, nr int) string {
-	if nr < len(args) {
-		return args[nr]
-	}
-	return ""
-}
-
-func showSucc(w io.Writer, nub string) {
-	fmt.Fprintf(w, "%s OK success %s", nub, eol)
-}
-
-func showBad(w io.Writer, err error) {
-	if err == nil {
-		fmt.Fprintf(w, "* BAD %s", eol)
-		return
-	}
-	fmt.Fprintf(w, "* BAD %s%s", err.Error(), eol)
-}
-
-func output(w io.Writer, nub string, err error) {
-	if err != nil {
-		showSucc(w, nub)
-	} else {
-		showBad(w, err)
-	}
-}
-
-func paramsErr(w io.Writer, commend string) {
-	fmt.Fprintf(w, "* BAD %s parameters! %s", commend, eol)
-}

+ 3 - 2
server/listen/imap_server/imap_server.go

@@ -4,7 +4,7 @@ import (
 	"crypto/rand"
 	"crypto/tls"
 	"github.com/Jinnrry/pmail/config"
-	"github.com/Jinnrry/pmail/listen/imap_server/goimap"
+	"github.com/Jinnrry/pmail/utils/goimap"
 	log "github.com/sirupsen/logrus"
 	"time"
 )
@@ -22,7 +22,7 @@ func StarTLS() {
 	tlsConfig.Time = time.Now
 	tlsConfig.Rand = rand.Reader
 	instanceTLS = goimap.NewImapServer(993, "imap."+config.Instance.Domain, true, tlsConfig, action{})
-	instanceTLS.ConnectAliveTime = 5 * time.Minute
+	instanceTLS.ConnectAliveTime = 30 * time.Minute
 
 	log.Infof("IMAP With TLS Server Start On Port :993")
 
@@ -35,5 +35,6 @@ func StarTLS() {
 func Stop() {
 	if instanceTLS != nil {
 		instanceTLS.Stop()
+		instanceTLS = nil
 	}
 }

+ 50 - 0
server/listen/imap_server/imap_server_test.go

@@ -0,0 +1,50 @@
+package imap_server
+
+import (
+	"crypto/tls"
+	"fmt"
+	"github.com/Jinnrry/pmail/config"
+	"github.com/Jinnrry/pmail/db"
+	"github.com/emersion/go-imap/v2/imapclient"
+	"github.com/emersion/go-message/charset"
+	"mime"
+	"testing"
+	"time"
+)
+
+func TestStarTLS(t *testing.T) {
+	config.Init()
+	db.Init("")
+	go StarTLS()
+	time.Sleep(2 * time.Second)
+
+	options := &imapclient.Options{
+		WordDecoder: &mime.WordDecoder{CharsetReader: charset.Reader},
+		TLSConfig: &tls.Config{
+			InsecureSkipVerify: true,
+		},
+	}
+
+	client, err := imapclient.DialTLS("127.0.0.1:993", options)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	fmt.Println("_________")
+
+	res, err := client.Capability().Wait() // wait forever!
+	if err != nil {
+		t.Fatal(err)
+	}
+	fmt.Println("Response:", res)
+
+	time.Sleep(10 * time.Second)
+}
+
+/*
+Here Is My Server Input&Output Log:
+Output:	* OK [CAPABILITY IMAP4 IMAP4rev1 AUTH=PLAIN AUTH=LOGIN] PMail Server ready
+Input:	T1 CAPABILITY
+Output:	* CAPABILITY IMAP4rev1 UNSELECT IDLE AUTH=PLAIN AUTH=LOGIN
+Output:	T1 OK success
+*/

+ 4 - 3
server/listen/pop3_server/action.go

@@ -2,6 +2,7 @@ package pop3_server
 
 import (
 	"database/sql"
+	errors2 "errors"
 	"github.com/Jinnrry/gopop"
 	"github.com/Jinnrry/pmail/consts"
 	"github.com/Jinnrry/pmail/db"
@@ -122,7 +123,7 @@ func (a action) Pass(session *gopop.Session, pwd string) error {
 		return nil
 	}
 
-	return errors.New("password error")
+	return errors2.New("password error")
 }
 
 // Apop APOP登陆命令
@@ -159,7 +160,7 @@ func (a action) Apop(session *gopop.Session, username, digest string) error {
 		return nil
 	}
 
-	return errors.New("password error")
+	return errors2.New("password error")
 
 }
 
@@ -298,7 +299,7 @@ func (a action) Top(session *gopop.Session, id int64, n int) (string, error) {
 	email, err := detail.GetEmailDetail(session.Ctx.(*context.Context), cast.ToInt(id), false)
 	if err != nil {
 		log.WithContext(session.Ctx.(*context.Context)).Errorf("%+v", err)
-		return "", errors.New("server error")
+		return "", errors2.New("password error")
 	}
 
 	ret := parsemail.NewEmailFromModel(email.Email).BuildBytes(session.Ctx.(*context.Context), false)

+ 2 - 0
server/listen/pop3_server/action_test.go

@@ -14,6 +14,8 @@ import (
 
 func Test_action_Retr(t *testing.T) {
 	config.Init()
+	config.Instance.DbType = config.DBTypeSQLite
+	config.Instance.DbDSN = config.ROOT_PATH + "./config/pmail_temp.db"
 	db.Init("")
 
 	a := action{}

+ 2 - 2
server/listen/smtp_server/read_content_test.go

@@ -38,9 +38,9 @@ func testInit() {
 	time.Local = cst
 
 	config.Init()
-	config.Instance.DkimPrivateKeyPath = "../config/dkim/dkim.priv"
+	config.Instance.DkimPrivateKeyPath = config.ROOT_PATH + "./config/dkim/dkim.priv"
 	config.Instance.DbType = config.DBTypeSQLite
-	config.Instance.DbDSN = "../config/pmail_temp.db"
+	config.Instance.DbDSN = config.ROOT_PATH + "./config/pmail_temp.db"
 
 	parsemail2.Init()
 	db.Init("")

+ 0 - 56
server/main.go

@@ -1,39 +1,12 @@
 package main
 
 import (
-	"bytes"
-	"fmt"
 	"github.com/Jinnrry/pmail/config"
 	"github.com/Jinnrry/pmail/listen/cron_server"
 	"github.com/Jinnrry/pmail/res_init"
-	"github.com/Jinnrry/pmail/utils/context"
 	log "github.com/sirupsen/logrus"
-	"os"
-	"time"
 )
 
-type logFormatter struct {
-}
-
-// Format 定义日志输出格式
-func (l *logFormatter) Format(entry *log.Entry) ([]byte, error) {
-	b := bytes.Buffer{}
-
-	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 {
-		ctx := entry.Context.(*context.Context)
-		if ctx != nil {
-			b.WriteString(fmt.Sprintf("[%s]", ctx.GetValue(context.LogID)))
-		}
-	}
-	b.WriteString(fmt.Sprintf("[%s:%d]", entry.Caller.File, entry.Caller.Line))
-	b.WriteString(entry.Message)
-
-	b.WriteString("\n")
-	return b.Bytes(), nil
-}
-
 var (
 	gitHash   string
 	buildTime string
@@ -42,38 +15,9 @@ var (
 )
 
 func main() {
-	// 设置日志格式为json格式
-	log.SetFormatter(&logFormatter{})
-	log.SetReportCaller(true)
-
-	// 设置将日志输出到标准输出(默认的输出为stderr,标准错误)
-	// 日志消息输出可以是任意的io.writer类型
-	log.SetOutput(os.Stdout)
-
-	var cstZone = time.FixedZone("CST", 8*3600)
-	time.Local = cstZone
 
 	config.Init()
 
-	if config.Instance != nil {
-		switch config.Instance.LogLevel {
-		case "":
-			log.SetLevel(log.InfoLevel)
-		case "debug":
-			log.SetLevel(log.DebugLevel)
-		case "info":
-			log.SetLevel(log.InfoLevel)
-		case "warn":
-			log.SetLevel(log.WarnLevel)
-		case "error":
-			log.SetLevel(log.ErrorLevel)
-		default:
-			log.SetLevel(log.InfoLevel)
-		}
-	} else {
-		log.SetLevel(log.InfoLevel)
-	}
-
 	if version == "" {
 		version = "TestVersion"
 	}

+ 3 - 1
server/main_test.go

@@ -287,8 +287,10 @@ func testCreateUser(t *testing.T) {
 func testPort(t *testing.T) {
 	if !portCheck(TestPort) {
 		t.Error("port check failed")
+	} else {
+		t.Log("port check passed")
 	}
-	t.Log("port check passed")
+
 }
 
 func testDataBaseSet(t *testing.T) {

+ 24 - 0
server/models/group.go

@@ -7,6 +7,30 @@ type Group struct {
 	UserId   int    `xorm:"user_id int unsigned notnull default(0) comment('用户id')" json:"-"`
 }
 
+const (
+	INBOX   = 2000000000
+	Sent    = 2000000001
+	Drafts  = 2000000002
+	Deleted = 2000000003
+	Junk    = 2000000004
+)
+
+var GroupNameToCode = map[string]int{
+	"INBOX":            INBOX,
+	"Sent Messages":    Sent,
+	"Drafts":           Drafts,
+	"Deleted Messages": Deleted,
+	"Junk":             Junk,
+}
+
+var GroupCodeToName = map[int]string{
+	INBOX:   "INBOX",
+	Sent:    "Sent Messages",
+	Drafts:  "Drafts",
+	Deleted: "Deleted Messages",
+	Junk:    "Junk",
+}
+
 func (p *Group) TableName() string {
 	return "group"
 }

+ 1 - 1
server/models/user_email.go

@@ -6,7 +6,7 @@ type UserEmail struct {
 	EmailID int  `xorm:"email_id not null index('idx_eid') index comment('信件id')"`
 	IsRead  int8 `xorm:"is_read tinyint(1) comment('是否已读')" json:"is_read"`
 	GroupId int  `xorm:"group_id int notnull default(0) comment('分组id')'" json:"group_id"`
-	Status  int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送,1已发送,2发送失败,3删除')" json:"status"` // 0未发送,1已发送,2发送失败 3删除
+	Status  int8 `xorm:"status tinyint(4) notnull default(0) comment('0未发送或收件,1已发送,2发送失败,3删除')" json:"status"` // 0未发送或收件,1已发送,2发送失败 3删除
 }
 
 func (p UserEmail) TableName() string {

+ 3 - 0
server/res_init/init.go

@@ -16,6 +16,7 @@ import (
 	"github.com/Jinnrry/pmail/utils/file"
 	log "github.com/sirupsen/logrus"
 	"os"
+	"time"
 )
 
 func Init(serverVersion string) {
@@ -73,6 +74,8 @@ func Init(serverVersion string) {
 			hooks.Stop()
 			return
 		}
+		log.Infof("Server Stop Success!")
+		time.Sleep(5 * time.Second)
 
 	}
 

+ 140 - 0
server/services/group/group.go

@@ -8,7 +8,9 @@ import (
 	"github.com/Jinnrry/pmail/utils/array"
 	"github.com/Jinnrry/pmail/utils/context"
 	"github.com/Jinnrry/pmail/utils/errors"
+	"github.com/Jinnrry/pmail/utils/utf7"
 	log "github.com/sirupsen/logrus"
+	"strings"
 )
 
 type GroupItem struct {
@@ -113,3 +115,141 @@ func GetGroupList(ctx *context.Context) []*models.Group {
 	db.Instance.Table("group").Where("user_id=?", ctx.UserID).Find(&ret)
 	return ret
 }
+
+func hasChildren(ctx *context.Context, id int) bool {
+	var parent []*models.Group
+	db.Instance.Table("group").Where("parent_id=?", id).Find(&parent)
+	return len(parent) > 0
+}
+
+func getLayerName(ctx *context.Context, item *models.Group) string {
+	if item.ParentId == 0 {
+		return utf7.Encode(item.Name)
+	}
+	var parent models.Group
+	_, _ = db.Instance.Table("group").Where("id=?", item.ParentId).Get(&parent)
+	return getLayerName(ctx, &parent) + "/" + utf7.Encode(item.Name)
+}
+
+func MatchGroup(ctx *context.Context, basePath, template string) []string {
+	var groups []*models.Group
+	var ret []string
+	if basePath == "" {
+		db.Instance.Table("group").Where("user_id=?", ctx.UserID).Find(&groups)
+		ret = append(ret, `* LIST (\NoSelect \HasChildren) "/" "[PMail]"`)
+		ret = append(ret, `* LIST (\HasNoChildren) "/" "INBOX"`)
+		ret = append(ret, `* LIST (\HasNoChildren) "/" "Sent Messages"`)
+		ret = append(ret, `* LIST (\HasNoChildren) "/" "Drafts"`)
+		ret = append(ret, `* LIST (\HasNoChildren) "/" "Deleted Messages"`)
+		ret = append(ret, `* LIST (\HasNoChildren) "/" "Junk"`)
+	} else {
+		var parent *models.Group
+		db.Instance.Table("group").Where("user_id=? and name=?", ctx.UserID, basePath).Find(&groups)
+		if parent != nil && parent.ID > 0 {
+			db.Instance.Table("group").Where("user_id=? and parent_id=?", ctx.UserID, parent.ID).Find(&groups)
+		}
+	}
+	for _, group := range groups {
+		if hasChildren(ctx, group.ID) {
+			ret = append(ret, fmt.Sprintf(`* LIST (\HasChildren) "/" "[PMail]/%s"`, getLayerName(ctx, group)))
+		} else {
+			ret = append(ret, fmt.Sprintf(`* LIST (\HasNoChildren) "/" "[PMail]/%s"`, getLayerName(ctx, group)))
+		}
+	}
+	return ret
+}
+
+func GetGroupStatus(ctx *context.Context, groupName string, params []string) (string, map[string]int) {
+	retMap := map[string]int{}
+
+	if !array.InArray(groupName, []string{"INBOX", "Sent Messages", "Drafts", "Deleted Messages", "Junk"}) {
+		groupNames := strings.Split(groupName, "/")
+		groupName = groupNames[len(groupNames)-1]
+
+		var group models.Group
+		db.Instance.Table("group").Where("user_id=? and name=?", ctx.UserID, groupName).Get(&group)
+		if group.ID == 0 {
+			ret := ""
+			for _, param := range params {
+				if ret != "" {
+					ret += " "
+				}
+				retMap[param] = 0
+				ret += fmt.Sprintf("%s %d", param, 0)
+			}
+			return fmt.Sprintf("(%s)", ret), retMap
+		}
+		ret := ""
+		for _, param := range params {
+			if ret != "" {
+				ret += " "
+			}
+			var value int
+
+			switch param {
+			case "MESSAGES":
+				db.Instance.Table("user_email").Select("count(1)").Where("group_id=?", group.ID).Get(&value)
+			case "UIDNEXT":
+				db.Instance.Table("email").Select("count(1)").Get(&value)
+			case "UIDVALIDITY":
+				value = group.ID
+			case "UNSEEN":
+				db.Instance.Table("user_email").Select("count(1)").Where("group_id=? and is_read=0", group.ID).Get(&value)
+			}
+			retMap[param] = value
+			ret += fmt.Sprintf("%s %d", param, value)
+		}
+		return fmt.Sprintf("(%s)", ret), retMap
+	}
+
+	ret := ""
+	for _, param := range params {
+		if ret != "" {
+			ret += " "
+		}
+		var value int
+
+		switch param {
+		case "MESSAGES":
+			value = getGroupNum(ctx, groupName, false)
+		case "UIDNEXT":
+			db.Instance.Table("email").Select("count(1)").Get(&value)
+		case "UIDVALIDITY":
+			value = models.GroupNameToCode[groupName]
+		case "UNSEEN":
+			value = getGroupNum(ctx, groupName, true)
+		}
+		retMap[param] = value
+		ret += fmt.Sprintf("%s %d", param, value)
+	}
+	return fmt.Sprintf("(%s)", ret), retMap
+
+}
+
+func getGroupNum(ctx *context.Context, groupName string, mustUnread bool) int {
+	var count int
+	switch groupName {
+	case "INBOX":
+		if mustUnread {
+			db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and status=0 and is_read=0", ctx.UserID).Get(&count)
+		} else {
+			db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and status=0", ctx.UserID).Get(&count)
+		}
+	case "Sent Messages":
+		if mustUnread {
+			count = 0
+		} else {
+			db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and status=1", ctx.UserID).Get(&count)
+		}
+
+	case "Drafts":
+	case "Deleted Messages":
+		if mustUnread {
+			db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and status=3 and is_read=0", ctx.UserID).Get(&count)
+		} else {
+			db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and status=3", ctx.UserID).Get(&count)
+		}
+	case "Junk":
+	}
+	return count
+}

+ 22 - 0
server/services/group/group_test.go

@@ -0,0 +1,22 @@
+package group
+
+import (
+	"fmt"
+	"github.com/Jinnrry/pmail/config"
+	"github.com/Jinnrry/pmail/db"
+	"github.com/Jinnrry/pmail/utils/context"
+	"testing"
+)
+
+func TestGetGroupStatus(t *testing.T) {
+	config.Init()
+	db.Init("")
+	db.Instance.ShowSQL(true)
+	ctx := &context.Context{
+		UserID:      1,
+		UserName:    "admin",
+		UserAccount: "admin",
+	}
+	ret, _ := GetGroupStatus(ctx, "INBOX", []string{"MESSAGES", "UIDNEXT", "UIDVALIDITY", "UNSEEN"})
+	fmt.Println(ret)
+}

+ 4 - 42
server/services/setup/db.go

@@ -1,26 +1,23 @@
 package setup
 
 import (
-	"encoding/json"
 	"github.com/Jinnrry/pmail/config"
 	"github.com/Jinnrry/pmail/db"
 	"github.com/Jinnrry/pmail/models"
 	"github.com/Jinnrry/pmail/utils/array"
 	"github.com/Jinnrry/pmail/utils/context"
 	"github.com/Jinnrry/pmail/utils/errors"
-	"github.com/Jinnrry/pmail/utils/file"
 	"github.com/Jinnrry/pmail/utils/password"
-	"os"
 )
 
 func GetDatabaseSettings(ctx *context.Context) (string, string, error) {
-	configData, err := ReadConfig()
+	configData, err := config.ReadConfig()
 	if err != nil {
 		return "", "", errors.Wrap(err)
 	}
 
 	if configData.DbType == "" && configData.DbDSN == "" {
-		return config.DBTypeSQLite, "./config/pmail.db", nil
+		return config.DBTypeSQLite, config.ROOT_PATH + "./config/pmail.db", nil
 	}
 
 	return configData.DbType, configData.DbDSN, nil
@@ -59,7 +56,7 @@ func SetAdminPassword(ctx *context.Context, account, pwd string) error {
 }
 
 func SetDatabaseSettings(ctx *context.Context, dbType, dbDSN string) error {
-	configData, err := ReadConfig()
+	configData, err := config.ReadConfig()
 	if err != nil {
 		return errors.Wrap(err)
 	}
@@ -75,7 +72,7 @@ func SetDatabaseSettings(ctx *context.Context, dbType, dbDSN string) error {
 	configData.DbType = dbType
 	configData.DbDSN = dbDSN
 
-	err = WriteConfig(configData)
+	err = config.WriteConfig(configData)
 	if err != nil {
 		return errors.Wrap(err)
 	}
@@ -87,38 +84,3 @@ func SetDatabaseSettings(ctx *context.Context, dbType, dbDSN string) error {
 	}
 	return nil
 }
-
-func WriteConfig(cfg *config.Config) error {
-	bytes, _ := json.Marshal(cfg)
-	err := os.WriteFile("./config/config.json", bytes, 0666)
-	if err != nil {
-		return errors.Wrap(err)
-	}
-	return nil
-}
-
-func ReadConfig() (*config.Config, error) {
-	configData := config.Config{
-		DkimPrivateKeyPath: "config/dkim/dkim.priv",
-		SSLPrivateKeyPath:  "config/ssl/private.key",
-		SSLPublicKeyPath:   "config/ssl/public.crt",
-	}
-	if !file.PathExist("./config/config.json") {
-		bytes, _ := json.Marshal(configData)
-		err := os.WriteFile("./config/config.json", bytes, 0666)
-		if err != nil {
-			return nil, errors.Wrap(err)
-		}
-	} else {
-		cfgData, err := os.ReadFile("./config/config.json")
-		if err != nil {
-			return nil, errors.Wrap(err)
-		}
-
-		err = json.Unmarshal(cfgData, &configData)
-		if err != nil {
-			return nil, errors.Wrap(err)
-		}
-	}
-	return &configData, nil
-}

+ 3 - 1
server/services/setup/dns.go

@@ -2,6 +2,7 @@ package setup
 
 import (
 	"fmt"
+	"github.com/Jinnrry/pmail/config"
 	"strings"
 
 	"github.com/Jinnrry/pmail/i18n"
@@ -20,7 +21,7 @@ type DNSItem struct {
 }
 
 func GetDNSSettings(ctx *context.Context) (map[string][]*DNSItem, error) {
-	configData, err := ReadConfig()
+	configData, err := config.ReadConfig()
 	if err != nil {
 		return nil, errors.Wrap(err)
 	}
@@ -31,6 +32,7 @@ func GetDNSSettings(ctx *context.Context) (map[string][]*DNSItem, error) {
 		ret[domain] = []*DNSItem{
 			{Type: "A", Host: strings.ReplaceAll(configData.WebDomain, "."+configData.Domain, ""), Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
 			{Type: "A", Host: "smtp", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
+			{Type: "A", Host: "imap", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
 			{Type: "A", Host: "pop", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
 			{Type: "A", Host: "@", Value: ip.GetIp(), TTL: 3600, Tips: i18n.GetText(ctx.Lang, "ip_taps")},
 			{Type: "MX", Host: "@", Value: fmt.Sprintf("smtp.%s", domain), TTL: 3600},

+ 4 - 3
server/services/setup/domain.go

@@ -1,13 +1,14 @@
 package setup
 
 import (
+	"github.com/Jinnrry/pmail/config"
 	"github.com/Jinnrry/pmail/utils/array"
 	"github.com/Jinnrry/pmail/utils/errors"
 	"strings"
 )
 
 func GetDomainSettings() (string, string, []string, error) {
-	configData, err := ReadConfig()
+	configData, err := config.ReadConfig()
 	if err != nil {
 		return "", "", []string{}, errors.Wrap(err)
 	}
@@ -16,7 +17,7 @@ func GetDomainSettings() (string, string, []string, error) {
 }
 
 func SetDomainSettings(smtpDomain, webDomain, multiDomains string) error {
-	configData, err := ReadConfig()
+	configData, err := config.ReadConfig()
 	if err != nil {
 		return errors.Wrap(err)
 	}
@@ -45,7 +46,7 @@ func SetDomainSettings(smtpDomain, webDomain, multiDomains string) error {
 
 	// 检查域名是否指向本机 todo
 
-	err = WriteConfig(configData)
+	err = config.WriteConfig(configData)
 	if err != nil {
 		return errors.Wrap(err)
 	}

+ 3 - 2
server/services/setup/finish.go

@@ -1,19 +1,20 @@
 package setup
 
 import (
+	"github.com/Jinnrry/pmail/config"
 	"github.com/Jinnrry/pmail/signal"
 	"github.com/Jinnrry/pmail/utils/errors"
 )
 
 // Finish 标记初始化完成
 func Finish() error {
-	cfg, err := ReadConfig()
+	cfg, err := config.ReadConfig()
 	if err != nil {
 		return errors.Wrap(err)
 	}
 	cfg.IsInit = true
 
-	err = WriteConfig(cfg)
+	err = config.WriteConfig(cfg)
 	if err != nil {
 		return errors.Wrap(err)
 	}

+ 0 - 37
server/services/setup/ssl/dnsProvide_test.go

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

+ 24 - 12
server/services/setup/ssl/ssl.go

@@ -15,6 +15,7 @@ import (
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cast"
 	"os"
+	"strings"
 	"time"
 
 	"github.com/go-acme/lego/v4/certcrypto"
@@ -39,7 +40,7 @@ func (u *MyUser) GetPrivateKey() crypto.PrivateKey {
 }
 
 func GetSSL() string {
-	cfg, err := setup.ReadConfig()
+	cfg, err := config.ReadConfig()
 	if err != nil {
 		panic(err)
 	}
@@ -51,7 +52,7 @@ func GetSSL() string {
 }
 
 func SetSSL(sslType, priKey, crtKey string) error {
-	cfg, err := setup.ReadConfig()
+	cfg, err := config.ReadConfig()
 	if err != nil {
 		panic(err)
 	}
@@ -68,7 +69,7 @@ func SetSSL(sslType, priKey, crtKey string) error {
 		cfg.HttpsEnabled = 2
 	}
 
-	err = setup.WriteConfig(cfg)
+	err = config.WriteConfig(cfg)
 	if err != nil {
 		return errors.Wrap(err)
 	}
@@ -124,6 +125,7 @@ func renewCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config) error {
 	for _, domain := range cfg.Domains {
 		domains = append(domains, "smtp."+domain)
 		domains = append(domains, "pop."+domain)
+		domains = append(domains, "imap."+domain)
 	}
 
 	request := certificate.ObtainRequest{
@@ -203,6 +205,7 @@ func generateCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config, newAc
 	for _, domain := range cfg.Domains {
 		domains = append(domains, "smtp."+domain)
 		domains = append(domains, "pop."+domain)
+		domains = append(domains, "imap."+domain)
 	}
 
 	request := certificate.ObtainRequest{
@@ -243,7 +246,7 @@ func generateCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config, newAc
 
 func GenSSL(update bool) error {
 
-	cfg, err := setup.ReadConfig()
+	cfg, err := config.ReadConfig()
 	if err != nil {
 		panic(err)
 	}
@@ -267,38 +270,47 @@ func GenSSL(update bool) error {
 }
 
 // CheckSSLCrtInfo 返回证书过期剩余天数
-func CheckSSLCrtInfo() (int, time.Time, error) {
+func CheckSSLCrtInfo() (int, time.Time, bool, error) {
 
-	cfg, err := setup.ReadConfig()
+	cfg, err := config.ReadConfig()
 	if err != nil {
 		panic(err)
 	}
 	// load cert and key by tls.LoadX509KeyPair
 	tlsCert, err := tls.LoadX509KeyPair(cfg.SSLPublicKeyPath, cfg.SSLPrivateKeyPath)
 	if err != nil {
-		return -1, time.Now(), errors.Wrap(err)
+		return -1, time.Now(), true, errors.Wrap(err)
 	}
 
 	cert, err := x509.ParseCertificate(tlsCert.Certificate[0])
 
 	if err != nil {
-		return -1, time.Now(), errors.Wrap(err)
+		return -1, time.Now(), true, errors.Wrap(err)
+	}
+
+	nameMatchFail := true
+	for _, name := range cert.DNSNames {
+		if strings.Contains("imap", name) {
+			nameMatchFail = false
+			break
+		}
 	}
 
 	// 检查过期时间
 	hours := cert.NotAfter.Sub(time.Now()).Hours()
 
 	if hours <= 0 {
-		return -1, time.Now(), errors.New("Certificate has expired")
+		return -1, time.Now(), nameMatchFail, errors.New("Certificate has expired")
 	}
 
-	return cast.ToInt(hours / 24), cert.NotAfter, nil
+	return cast.ToInt(hours / 24), cert.NotAfter, nameMatchFail, nil
 }
 
 func Update(needRestart bool) {
 	if config.Instance != nil && config.Instance.IsInit && (config.Instance.SSLType == config.SSLTypeAutoHTTP || config.Instance.SSLType == config.SSLTypeAutoDNS) {
-		days, _, err := CheckSSLCrtInfo()
-		if days < 30 || err != nil {
+		days, _, nameMatchFail, err := CheckSSLCrtInfo()
+
+		if days < 30 || err != nil || nameMatchFail {
 			if err != nil {
 				log.Errorf("SSL Check Error, Update SSL Certificate. Error Info :%+v", err)
 			} else {

+ 15 - 0
server/services/setup/ssl/ssl_test.go

@@ -0,0 +1,15 @@
+package ssl
+
+import (
+	"fmt"
+	"github.com/Jinnrry/pmail/config"
+	"testing"
+)
+
+func TestCheckSSLCrtInfo(t *testing.T) {
+	config.Init()
+
+	got, got1, _, err := CheckSSLCrtInfo()
+
+	fmt.Println(got, got1, err)
+}

+ 12 - 10
server/listen/imap_server/goimap/action.go → server/utils/goimap/action.go

@@ -6,7 +6,7 @@ type Action interface {
 	Rename(session *Session, oldPath, newPath string) error             // 重命名邮箱
 	List(session *Session, basePath, template string) ([]string, error) // 浏览邮箱
 	Append(session *Session, item string) error                         // 上传邮件
-	Select(session *Session, path string) error                         // 选择邮箱
+	Select(session *Session, path string) ([]string, error)             // 选择邮箱
 	/*
 		读取邮件的文本信息,且仅用于显示的目的。
 			ALL:只返回按照一定格式的邮件摘要,包括邮件标志、RFC822.SIZE、自身的时间和信封信息。IMAP客户机能够将标准邮件解析成这些信息并显示出来。
@@ -45,13 +45,15 @@ type Action interface {
 			UIDVALIDITY	邮箱的UID有效性标志
 			UNSEEN	邮箱中没有被标志为\UNSEEN的邮件数
 	*/
-	Status(session *Session, mailbox, category string) (string, error) // 查询邮箱的当前状态
-	Check(session *Session) error                                      // sync数据
-	Search(session *Session, keyword, criteria string) (string, error) // 命令可以根据搜索条件在处于活动状态的邮箱中搜索邮件,然后显示匹配的邮件编号
-	Copy(session *Session, mailId, mailBoxName string) error           // 把邮件从一个邮箱复制到另一个邮箱
-	CapaBility(session *Session) ([]string, error)                     // 返回IMAP服务器支持的功能列表
-	Noop(session *Session) error                                       // 什么都不做,连接保活
-	Login(session *Session, username, password string) error           // 登录
-	Logout(session *Session) error                                     // 注销登录
-	Custom(session *Session, cmd string, args []string) ([]string, error)
+	Status(session *Session, mailbox string, category []string) (string, error) // 查询邮箱的当前状态
+	Check(session *Session) error                                               // sync数据
+	Search(session *Session, keyword, criteria string) (string, error)          // 命令可以根据搜索条件在处于活动状态的邮箱中搜索邮件,然后显示匹配的邮件编号
+	Copy(session *Session, mailId, mailBoxName string) error                    // 把邮件从一个邮箱复制到另一个邮箱
+	CapaBility(session *Session) ([]string, error)                              // 返回IMAP服务器支持的功能列表
+	Noop(session *Session) error                                                // 什么都不做,连接保活
+	Login(session *Session, username, password string) error                    // 登录
+	Logout(session *Session) error                                              // 注销登录
+	IDLE(session *Session) error                                                // 进入IDLE状态
+	Unselect(session *Session) error                                            // 取消邮箱选择
+	Custom(session *Session, cmd string, args string) ([]string, error)
 }

+ 544 - 0
server/utils/goimap/imap.go

@@ -0,0 +1,544 @@
+package goimap
+
+import (
+	"bufio"
+	"crypto/tls"
+	"errors"
+	"fmt"
+	log "github.com/sirupsen/logrus"
+	"io"
+	"log/slog"
+	"net"
+	"strings"
+	"sync"
+	"time"
+)
+
+var (
+	eol = "\r\n"
+)
+
+// Server Imap服务实例
+type Server struct {
+	Domain           string        // 域名
+	Port             int           // 端口
+	TlsEnabled       bool          //是否启用Tls
+	TlsConfig        *tls.Config   // tls配置
+	ConnectAliveTime time.Duration // 连接存活时间,默认不超时
+	Action           Action
+	stop             chan bool
+	close            bool
+	lck              sync.Mutex
+}
+
+// NewImapServer 新建一个服务实例
+func NewImapServer(port int, domain string, tlsEnabled bool, tlsConfig *tls.Config, action Action) *Server {
+	return &Server{
+		Domain:     domain,
+		Port:       port,
+		TlsEnabled: tlsEnabled,
+		TlsConfig:  tlsConfig,
+		Action:     action,
+		stop:       make(chan bool, 1),
+	}
+}
+
+// Start 启动服务
+func (s *Server) Start() error {
+	if !s.TlsEnabled {
+		return s.startWithoutTLS()
+	} else {
+		return s.startWithTLS()
+	}
+}
+
+func (s *Server) startWithTLS() error {
+	if s.lck.TryLock() {
+		listener, err := tls.Listen("tcp", fmt.Sprintf(":%d", s.Port), s.TlsConfig)
+		if err != nil {
+			return err
+		}
+		s.close = false
+		defer func() {
+			listener.Close()
+		}()
+
+		go func() {
+			for {
+				conn, err := listener.Accept()
+				if err != nil {
+					if s.close {
+						break
+					} else {
+						continue
+					}
+				}
+				go s.handleClient(conn)
+			}
+		}()
+		<-s.stop
+	} else {
+		return errors.New("Server Is Running")
+	}
+
+	return nil
+}
+
+func (s *Server) startWithoutTLS() error {
+	if s.lck.TryLock() {
+		listener, err := net.Listen("tcp", fmt.Sprintf(":%d", s.Port))
+		if err != nil {
+			return err
+		}
+		s.close = false
+		defer func() {
+			listener.Close()
+		}()
+
+		go func() {
+			for {
+				conn, err := listener.Accept()
+				if err != nil {
+					if s.close {
+						break
+					} else {
+						continue
+					}
+				}
+				go s.handleClient(conn)
+			}
+		}()
+		<-s.stop
+	} else {
+		return errors.New("Server Is Running")
+	}
+
+	return nil
+}
+
+// Stop 停止服务
+func (s *Server) Stop() {
+	s.close = true
+	s.stop <- true
+}
+
+func (s *Server) handleClient(conn net.Conn) {
+	slog.Debug("Imap conn")
+
+	defer func() {
+		if conn != nil {
+			_ = conn.Close()
+		}
+	}()
+
+	session := &Session{
+		Conn:      conn,
+		Status:    UNAUTHORIZED,
+		AliveTime: time.Now(),
+	}
+	if s.TlsEnabled && s.TlsConfig != nil {
+		session.InTls = true
+	}
+
+	// 检查连接是否超时
+	if s.ConnectAliveTime != 0 {
+		go func() {
+			for {
+				if time.Now().Sub(session.AliveTime) >= s.ConnectAliveTime {
+					if session.Conn != nil {
+						write(session.Conn, "* BYE AutoLogout; idle for too long", "")
+						_ = session.Conn.Close()
+					}
+					session.Conn = nil
+					session.IN_IDLE = false
+					return
+				}
+				time.Sleep(3 * time.Second)
+			}
+		}()
+	}
+
+	reader := bufio.NewReader(conn)
+	write(conn, `* OK [CAPABILITY IMAP4 IMAP4rev1 AUTH=PLAIN AUTH=LOGIN] PMail Server ready`, "")
+
+	for {
+		rawLine, err := reader.ReadString('\n')
+		if err != nil {
+			if conn != nil {
+				_ = conn.Close()
+			}
+			session.Conn = nil
+			session.IN_IDLE = false
+			return
+		}
+		session.AliveTime = time.Now()
+
+		nub, cmd, args := getCommand(rawLine)
+		log.Debugf("Imap Input:\t %s", rawLine)
+		if cmd != "IDLE" {
+			session.IN_IDLE = false
+		}
+
+		switch cmd {
+		case "":
+			if conn != nil {
+				conn.Close()
+				conn = nil
+			}
+			break
+
+		case "CAPABILITY":
+			commands, err := s.Action.CapaBility(session)
+			if err != nil {
+				write(conn, fmt.Sprintf("* BAD %s%s", err.Error(), eol), nub)
+			} else {
+				ret := "*"
+				for _, command := range commands {
+					ret += " " + command
+				}
+				write(conn, ret, nub)
+				showSucc(conn, nub)
+			}
+
+		case "CREATE":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "CREATE", nub)
+				break
+			}
+			err := s.Action.Create(session, args)
+			output(conn, nub, err)
+		case "DELETE":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "DELETE", nub)
+				break
+			}
+			err := s.Action.Delete(session, args)
+			output(conn, nub, err)
+		case "RENAME":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "RENAME", nub)
+			} else {
+				dt := strings.Split(args, " ")
+				err := s.Action.Rename(session, dt[0], dt[1])
+				output(conn, nub, err)
+			}
+		case "LIST":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "LIST", nub)
+			} else {
+				dt := strings.Split(args, " ")
+				dt[0] = strings.ReplaceAll(dt[0], `"`, "")
+				rets, err := s.Action.List(session, dt[0], dt[1])
+				if err != nil {
+					showBad(conn, err, nub)
+				} else {
+					ret := ""
+					for _, str := range rets {
+						ret += str + eol
+					}
+					write(conn, ret, nub)
+					showSucc(conn, nub)
+				}
+			}
+		case "APPEND":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			log.Debugf("Append: %+v", args)
+		case "SELECT":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			ret, err := s.Action.Select(session, args)
+			args = strings.ReplaceAll(args, `"`, "")
+			if err != nil {
+				showBad(conn, err, nub)
+			} else {
+				for _, s2 := range ret {
+					write(conn, s2, nub)
+				}
+			}
+
+		case "FETCH":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "RENAME", nub)
+			} else {
+				dt := strings.Split(args, " ")
+				ret, err := s.Action.Fetch(session, dt[0], dt[1])
+				if err != nil {
+					showBad(conn, err, nub)
+				} else {
+					write(conn, ret, nub)
+					showSucc(conn, ret)
+				}
+			}
+		case "STORE":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "RENAME", nub)
+			} else {
+				dt := strings.Split(args, " ")
+				err := s.Action.Store(session, dt[0], dt[1])
+				output(conn, nub, err)
+			}
+		case "CLOSE":
+			err := s.Action.Close(session)
+			output(conn, nub, err)
+		case "EXPUNGE":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			err := s.Action.Expunge(session)
+			output(conn, nub, err)
+		case "EXAMINE":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "EXAMINE", nub)
+			}
+			err := s.Action.Examine(session, args)
+			output(conn, nub, err)
+		case "SUBSCRIBE":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "SUBSCRIBE", nub)
+			} else {
+				err := s.Action.Subscribe(session, args)
+				output(conn, nub, err)
+			}
+		case "UNSUBSCRIBE":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "UNSUBSCRIBE", nub)
+			} else {
+				err := s.Action.UnSubscribe(session, args)
+				output(conn, nub, err)
+			}
+		case "LSUB":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "LSUB", nub)
+			} else {
+				dt := strings.Split(args, " ")
+				rets, err := s.Action.LSub(session, dt[0], dt[1])
+				if err != nil {
+					showBad(conn, err, nub)
+				} else {
+					ret := ""
+					for _, str := range rets {
+						ret += str + eol
+					}
+					write(conn, ret, nub)
+					showSucc(conn, nub)
+				}
+			}
+		case "STATUS":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "STATUS", nub)
+			} else {
+				dt := strings.SplitN(args, " ", 2)
+				dt[0] = strings.ReplaceAll(dt[0], `"`, "")
+				dt[1] = strings.Trim(dt[1], "()")
+				params := strings.Split(dt[1], " ")
+
+				ret, err := s.Action.Status(session, dt[0], params)
+				if err != nil {
+					showBad(conn, err, nub)
+				} else {
+					write(conn, ret, nub)
+					showSucc(conn, nub)
+				}
+			}
+		case "CHECK":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			err := s.Action.Check(session)
+			output(conn, nub, err)
+		case "SEARCH":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "SEARCH", nub)
+			} else {
+				dt := strings.SplitN(args, " ", 2)
+				ret, err := s.Action.Search(session, dt[0], dt[1])
+				if err != nil {
+					showBad(conn, err, nub)
+				} else {
+					write(conn, ret, nub)
+					showSucc(conn, nub)
+				}
+			}
+		case "COPY":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			if args == "" {
+				paramsErr(conn, "COPY", nub)
+			} else {
+				dt := strings.SplitN(args, " ", 2)
+				err := s.Action.Copy(session, dt[0], dt[1])
+				output(conn, nub, err)
+			}
+
+		case "NOOP":
+			err := s.Action.Noop(session)
+			output(conn, nub, err)
+		case "LOGIN":
+			if args == "" {
+				paramsErr(conn, "LOGIN", nub)
+			} else {
+				dt := strings.SplitN(args, " ", 2)
+				err := s.Action.Login(session, dt[0], dt[1])
+				output(conn, nub, err)
+			}
+		case "LOGOUT":
+			err := s.Action.Logout(session)
+			write(conn, "* BYE PMail Server logging out", nub)
+			output(conn, nub, err)
+			if conn != nil {
+				_ = conn.Close()
+			}
+		case "UNSELECT":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			err := s.Action.Unselect(session)
+			output(conn, nub, err)
+		case "IDLE":
+			if session.Status != AUTHORIZED {
+				showBad(conn, errors.New("Need Login"), nub)
+				break
+			}
+			session.IN_IDLE = true
+			err := s.Action.IDLE(session)
+			if err != nil {
+				write(conn, fmt.Sprintf("+ idling%s", eol), nub)
+			} else {
+				showBad(conn, err, nub)
+			}
+		default:
+			rets, err := s.Action.Custom(session, cmd, args)
+			if err != nil {
+				write(conn, fmt.Sprintf("* BAD %s %s", err.Error(), eol), nub)
+			} else {
+				if len(rets) == 0 {
+					write(conn, fmt.Sprintf("%s OK %s", nub, eol), nub)
+				} else if len(rets) == 1 {
+					write(conn, fmt.Sprintf("%s OK %s%s", nub, rets[0], eol), nub)
+				} else {
+					ret := fmt.Sprintf("%s OK %s", nub, eol)
+					for _, re := range rets {
+						ret += fmt.Sprintf("%s%s", re, eol)
+					}
+					ret += "." + eol
+					write(conn, fmt.Sprintf(ret), nub)
+				}
+			}
+		}
+
+	}
+}
+
+// cuts the line into command and arguments
+func getCommand(line string) (string, string, string) {
+	line = strings.Trim(line, "\r \n")
+	cmd := strings.SplitN(line, " ", 3)
+	if len(cmd) == 1 {
+		return "", "", ""
+	}
+
+	for i, s := range cmd {
+		cmd[i] = s
+	}
+
+	if len(cmd) == 3 {
+		return strings.ToTitle(cmd[0]), strings.ToTitle(cmd[1]), cmd[2]
+	}
+
+	return strings.ToTitle(cmd[0]), strings.ToTitle(cmd[1]), ""
+}
+
+func getSafeArg(args []string, nr int) string {
+	if nr < len(args) {
+		return args[nr]
+	}
+	return ""
+}
+
+func showSucc(w io.Writer, nub string) {
+	write(w, fmt.Sprintf("%s OK success %s", nub, eol), nub)
+}
+
+func showBad(w io.Writer, err error, nub string) {
+	if err == nil {
+		write(w, fmt.Sprintf("* BAD %s", eol), nub)
+		return
+	}
+	write(w, fmt.Sprintf("* BAD %s%s", err.Error(), eol), nub)
+}
+
+func output(w io.Writer, nub string, err error) {
+	if err != nil {
+		showBad(w, err, nub)
+	} else {
+		showSucc(w, nub)
+	}
+}
+
+func paramsErr(w io.Writer, commend string, nub string) {
+	write(w, fmt.Sprintf("* BAD %s parameters! %s", commend, eol), nub)
+}
+
+func write(w io.Writer, content string, nub string) {
+	content = strings.ReplaceAll(content, "$$NUM", nub)
+	log.Debugf("Imap Out:\t |%s", content)
+	fmt.Fprintf(w, content)
+}

+ 66 - 0
server/utils/goimap/imap_test.go

@@ -0,0 +1,66 @@
+package goimap
+
+import (
+	"reflect"
+	"testing"
+)
+
+func Test_paramsErr(t *testing.T) {
+
+}
+
+func Test_getCommand(t *testing.T) {
+	type args struct {
+		line string
+	}
+	tests := []struct {
+		name  string
+		args  args
+		want  string
+		want1 string
+		want2 string
+	}{
+		{
+			"STATUS命令测试",
+			args{`15.64 STATUS "Deleted Messages" (MESSAGES UIDNEXT UIDVALIDITY UNSEEN)`},
+			"15.64",
+			"STATUS",
+			`"Deleted Messages" (MESSAGES UIDNEXT UIDVALIDITY UNSEEN)`,
+		},
+		{
+			"SELECT命令测试",
+			args{`9.79 SELECT INBOX`},
+			"9.79",
+			"SELECT",
+			`INBOX`,
+		},
+		{
+			"CAPABILITY命令测试",
+			args{`1.81 CAPABILITY`},
+			"1.81",
+			"CAPABILITY",
+			``,
+		},
+		{
+			"异常命令测试",
+			args{`GET/HTTP/1.0`},
+			"",
+			"",
+			``,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, got1, got2 := getCommand(tt.args.line)
+			if got != tt.want {
+				t.Errorf("getCommand() got = %v, want %v", got, tt.want)
+			}
+			if got1 != tt.want1 {
+				t.Errorf("getCommand() got1 = %v, want %v", got1, tt.want1)
+			}
+			if !reflect.DeepEqual(got2, tt.want2) {
+				t.Errorf("getCommand() got2 = %v, want %v", got2, tt.want2)
+			}
+		})
+	}
+}

+ 5 - 3
server/listen/imap_server/goimap/session.go → server/utils/goimap/session.go

@@ -10,17 +10,19 @@ type Status int8
 
 const (
 	UNAUTHORIZED Status = 1
-	TRANSACTION  Status = 2
-	UPDATE       Status = 3
+	AUTHORIZED   Status = 2
+	SELECTED     Status = 3
+	LOGOUT       Status = 4
 )
 
 type Session struct {
 	Status      Status
-	User        string
+	Account     string
 	DeleteIds   []int64
 	Ctx         context.Context
 	Conn        net.Conn
 	InTls       bool
 	AliveTime   time.Time
 	CurrentPath string //当前选择的文件夹
+	IN_IDLE     bool   // 是否处在IDLE中
 }

+ 23 - 0
server/utils/utf7/LICENSE

@@ -0,0 +1,23 @@
+The MIT License (MIT)
+
+Copyright (c) 2013 The Go-IMAP Authors
+Copyright (c) 2016 Proton Technologies AG
+Copyright (c) 2023 Simon Ser
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 1 - 0
server/utils/utf7/README.md

@@ -0,0 +1 @@
+COPY from https://github.com/emersion/go-imap/tree/v2/internal/utf7

+ 118 - 0
server/utils/utf7/decoder.go

@@ -0,0 +1,118 @@
+package utf7
+
+import (
+	"errors"
+	"strings"
+	"unicode/utf16"
+	"unicode/utf8"
+)
+
+// ErrInvalidUTF7 means that a decoder encountered invalid UTF-7.
+var ErrInvalidUTF7 = errors.New("utf7: invalid UTF-7")
+
+// Decode decodes a string encoded with modified UTF-7.
+//
+// Note, raw UTF-8 is accepted.
+func Decode(src string) (string, error) {
+	if !utf8.ValidString(src) {
+		return "", errors.New("invalid UTF-8")
+	}
+
+	var sb strings.Builder
+	sb.Grow(len(src))
+
+	ascii := true
+	for i := 0; i < len(src); i++ {
+		ch := src[i]
+
+		if ch < min || (ch > max && ch < utf8.RuneSelf) {
+			// Illegal code point in ASCII mode. Note, UTF-8 codepoints are
+			// always allowed.
+			return "", ErrInvalidUTF7
+		}
+
+		if ch != '&' {
+			sb.WriteByte(ch)
+			ascii = true
+			continue
+		}
+
+		// Find the end of the Base64 or "&-" segment
+		start := i + 1
+		for i++; i < len(src) && src[i] != '-'; i++ {
+			if src[i] == '\r' || src[i] == '\n' { // base64 package ignores CR and LF
+				return "", ErrInvalidUTF7
+			}
+		}
+
+		if i == len(src) { // Implicit shift ("&...")
+			return "", ErrInvalidUTF7
+		}
+
+		if i == start { // Escape sequence "&-"
+			sb.WriteByte('&')
+			ascii = true
+		} else { // Control or non-ASCII code points in base64
+			if !ascii { // Null shift ("&...-&...-")
+				return "", ErrInvalidUTF7
+			}
+
+			b := decode([]byte(src[start:i]))
+			if len(b) == 0 { // Bad encoding
+				return "", ErrInvalidUTF7
+			}
+			sb.Write(b)
+
+			ascii = false
+		}
+	}
+
+	return sb.String(), nil
+}
+
+// Extracts UTF-16-BE bytes from base64 data and converts them to UTF-8.
+// A nil slice is returned if the encoding is invalid.
+func decode(b64 []byte) []byte {
+	var b []byte
+
+	// Allocate a single block of memory large enough to store the Base64 data
+	// (if padding is required), UTF-16-BE bytes, and decoded UTF-8 bytes.
+	// Since a 2-byte UTF-16 sequence may expand into a 3-byte UTF-8 sequence,
+	// double the space allocation for UTF-8.
+	if n := len(b64); b64[n-1] == '=' {
+		return nil
+	} else if n&3 == 0 {
+		b = make([]byte, b64Enc.DecodedLen(n)*3)
+	} else {
+		n += 4 - n&3
+		b = make([]byte, n+b64Enc.DecodedLen(n)*3)
+		copy(b[copy(b, b64):n], []byte("=="))
+		b64, b = b[:n], b[n:]
+	}
+
+	// Decode Base64 into the first 1/3rd of b
+	n, err := b64Enc.Decode(b, b64)
+	if err != nil || n&1 == 1 {
+		return nil
+	}
+
+	// Decode UTF-16-BE into the remaining 2/3rds of b
+	b, s := b[:n], b[n:]
+	j := 0
+	for i := 0; i < n; i += 2 {
+		r := rune(b[i])<<8 | rune(b[i+1])
+		if utf16.IsSurrogate(r) {
+			if i += 2; i == n {
+				return nil
+			}
+			r2 := rune(b[i])<<8 | rune(b[i+1])
+			if r = utf16.DecodeRune(r, r2); r == utf8.RuneError {
+				return nil
+			}
+		} else if min <= r && r <= max {
+			return nil
+		}
+		j += utf8.EncodeRune(s[j:], r)
+	}
+	return s[:j]
+}

+ 118 - 0
server/utils/utf7/decoder_test.go

@@ -0,0 +1,118 @@
+package utf7_test
+
+import (
+	"github.com/Jinnrry/pmail/utils/utf7"
+	"strings"
+	"testing"
+)
+
+var decode = []struct {
+	in  string
+	out string
+	ok  bool
+}{
+	// Basics (the inverse test on encode checks other valid inputs)
+	{"", "", true},
+	{"abc", "abc", true},
+	{"&-abc", "&abc", true},
+	{"abc&-", "abc&", true},
+	{"a&-b&-c", "a&b&c", true},
+	{"&ABk-", "\x19", true},
+	{"&AB8-", "\x1F", true},
+	{"ABk-", "ABk-", true},
+	{"&-,&-&AP8-&-", "&,&\u00FF&", true},
+	{"&-&-,&AP8-&-", "&&,\u00FF&", true},
+	{"abc &- &AP8A,wD,- &- xyz", "abc & \u00FF\u00FF\u00FF & xyz", true},
+
+	// Illegal code point in ASCII
+	{"\x00", "", false},
+	{"\x1F", "", false},
+	{"abc\n", "", false},
+	{"abc\x7Fxyz", "", false},
+
+	// Invalid UTF-8
+	{"\xc3\x28", "", false},
+	{"\xe2\x82\x28", "", false},
+
+	// Invalid Base64 alphabet
+	{"&/+8-", "", false},
+	{"&*-", "", false},
+	{"&ZeVnLIqe -", "", false},
+
+	// CR and LF in Base64
+	{"&ZeVnLIqe\r\n-", "", false},
+	{"&ZeVnLIqe\r\n\r\n-", "", false},
+	{"&ZeVn\r\n\r\nLIqe-", "", false},
+
+	// Padding not stripped
+	{"&AAAAHw=-", "", false},
+	{"&AAAAHw==-", "", false},
+	{"&AAAAHwB,AIA=-", "", false},
+	{"&AAAAHwB,AIA==-", "", false},
+
+	// One byte short
+	{"&2A-", "", false},
+	{"&2ADc-", "", false},
+	{"&AAAAHwB,A-", "", false},
+	{"&AAAAHwB,A=-", "", false},
+	{"&AAAAHwB,A==-", "", false},
+	{"&AAAAHwB,A===-", "", false},
+	{"&AAAAHwB,AI-", "", false},
+	{"&AAAAHwB,AI=-", "", false},
+	{"&AAAAHwB,AI==-", "", false},
+
+	// Implicit shift
+	{"&", "", false},
+	{"&Jjo", "", false},
+	{"Jjo&", "", false},
+	{"&Jjo&", "", false},
+	{"&Jjo!", "", false},
+	{"&Jjo+", "", false},
+	{"abc&Jjo", "", false},
+
+	// Null shift
+	{"&AGE-&Jjo-", "", false},
+	{"&U,BTFw-&ZeVnLIqe-", "", false},
+
+	// Long input with Base64 at the end
+	{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa &2D3eCg- &2D3eCw- &2D3eDg-",
+		"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \U0001f60a \U0001f60b \U0001f60e", true},
+
+	// Long input in Base64 between short ASCII
+	{"00000000000000000000 &MEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEIwQjBCMEI- 00000000000000000000",
+		"00000000000000000000 " + strings.Repeat("\U00003042", 37) + " 00000000000000000000", true},
+
+	// ASCII in Base64
+	{"&AGE-", "", false},            // "a"
+	{"&ACY-", "", false},            // "&"
+	{"&AGgAZQBsAGwAbw-", "", false}, // "hello"
+	{"&JjoAIQ-", "", false},         // "\u263a!"
+
+	// Bad surrogate
+	{"&2AA-", "", false},    // U+D800
+	{"&2AD-", "", false},    // U+D800
+	{"&3AA-", "", false},    // U+DC00
+	{"&2AAAQQ-", "", false}, // U+D800 'A'
+	{"&2AD,,w-", "", false}, // U+D800 U+FFFF
+	{"&3ADYAA-", "", false}, // U+DC00 U+D800
+
+	// Chinese
+	{"&V4NXPpCuTvY-", "垃圾邮件", true},
+	{"&UXZO1mWHTvZZOQ-", "其他文件夹", true},
+}
+
+func TestDecoder(t *testing.T) {
+	for _, test := range decode {
+		out, err := utf7.Decode(test.in)
+		if out != test.out {
+			t.Errorf("UTF7Decode(%+q) expected %+q; got %+q", test.in, test.out, out)
+		}
+		if test.ok {
+			if err != nil {
+				t.Errorf("UTF7Decode(%+q) unexpected error; %v", test.in, err)
+			}
+		} else if err == nil {
+			t.Errorf("UTF7Decode(%+q) expected error", test.in)
+		}
+	}
+}

+ 88 - 0
server/utils/utf7/encoder.go

@@ -0,0 +1,88 @@
+package utf7
+
+import (
+	"strings"
+	"unicode/utf16"
+	"unicode/utf8"
+)
+
+// Encode encodes a string with modified UTF-7.
+func Encode(src string) string {
+	var sb strings.Builder
+	sb.Grow(len(src))
+
+	for i := 0; i < len(src); {
+		ch := src[i]
+
+		if min <= ch && ch <= max {
+			sb.WriteByte(ch)
+			if ch == '&' {
+				sb.WriteByte('-')
+			}
+
+			i++
+		} else {
+			start := i
+
+			// Find the next printable ASCII code point
+			i++
+			for i < len(src) && (src[i] < min || src[i] > max) {
+				i++
+			}
+
+			sb.Write(encode([]byte(src[start:i])))
+		}
+	}
+
+	return sb.String()
+}
+
+// Converts string s from UTF-8 to UTF-16-BE, encodes the result as base64,
+// removes the padding, and adds UTF-7 shifts.
+func encode(s []byte) []byte {
+	// len(s) is sufficient for UTF-8 to UTF-16 conversion if there are no
+	// control code points (see table below).
+	b := make([]byte, 0, len(s)+4)
+	for len(s) > 0 {
+		r, size := utf8.DecodeRune(s)
+		if r > utf8.MaxRune {
+			r, size = utf8.RuneError, 1 // Bug fix (issue 3785)
+		}
+		s = s[size:]
+		if r1, r2 := utf16.EncodeRune(r); r1 != utf8.RuneError {
+			b = append(b, byte(r1>>8), byte(r1))
+			r = r2
+		}
+		b = append(b, byte(r>>8), byte(r))
+	}
+
+	// Encode as base64
+	n := b64Enc.EncodedLen(len(b)) + 2
+	b64 := make([]byte, n)
+	b64Enc.Encode(b64[1:], b)
+
+	// Strip padding
+	n -= 2 - (len(b)+2)%3
+	b64 = b64[:n]
+
+	// Add UTF-7 shifts
+	b64[0] = '&'
+	b64[n-1] = '-'
+	return b64
+}
+
+// Escape passes through raw UTF-8 as-is and escapes the special UTF-7 marker
+// (the ampersand character).
+func Escape(src string) string {
+	var sb strings.Builder
+	sb.Grow(len(src))
+
+	for _, ch := range src {
+		sb.WriteRune(ch)
+		if ch == '&' {
+			sb.WriteByte('-')
+		}
+	}
+
+	return sb.String()
+}

+ 123 - 0
server/utils/utf7/encoder_test.go

@@ -0,0 +1,123 @@
+package utf7_test
+
+import (
+	"github.com/Jinnrry/pmail/utils/utf7"
+	"testing"
+)
+
+var encode = []struct {
+	in  string
+	out string
+	ok  bool
+}{
+	// Printable ASCII
+	{"", "", true},
+	{"a", "a", true},
+	{"ab", "ab", true},
+	{"-", "-", true},
+	{"&", "&-", true},
+	{"&&", "&-&-", true},
+	{"&&&-&", "&-&-&--&-", true},
+	{"-&*&-", "-&-*&--", true},
+	{"a&b", "a&-b", true},
+	{"a&", "a&-", true},
+	{"&b", "&-b", true},
+	{"-a&", "-a&-", true},
+	{"&b-", "&-b-", true},
+
+	// Unicode range
+	{"\u0000", "&AAA-", true},
+	{"\n", "&AAo-", true},
+	{"\r", "&AA0-", true},
+	{"\u001F", "&AB8-", true},
+	{"\u0020", " ", true},
+	{"\u0025", "%", true},
+	{"\u0026", "&-", true},
+	{"\u0027", "'", true},
+	{"\u007E", "~", true},
+	{"\u007F", "&AH8-", true},
+	{"\u0080", "&AIA-", true},
+	{"\u00FF", "&AP8-", true},
+	{"\u07FF", "&B,8-", true},
+	{"\u0800", "&CAA-", true},
+	{"\uFFEF", "&,+8-", true},
+	{"\uFFFF", "&,,8-", true},
+	{"\U00010000", "&2ADcAA-", true},
+	{"\U0010FFFF", "&2,,f,w-", true},
+
+	// Padding
+	{"\x00\x1F", "&AAAAHw-", true},                         // 2
+	{"\x00\x1F\x7F", "&AAAAHwB,-", true},                   // 0
+	{"\x00\x1F\x7F\u0080", "&AAAAHwB,AIA-", true},          // 1
+	{"\x00\x1F\x7F\u0080\u00FF", "&AAAAHwB,AIAA,w-", true}, // 2
+
+	// Mix
+	{"a\x00", "a&AAA-", true},
+	{"\x00a", "&AAA-a", true},
+	{"&\x00", "&-&AAA-", true},
+	{"\x00&", "&AAA-&-", true},
+	{"a\x00&", "a&AAA-&-", true},
+	{"a&\x00", "a&-&AAA-", true},
+	{"&a\x00", "&-a&AAA-", true},
+	{"&\x00a", "&-&AAA-a", true},
+	{"\x00&a", "&AAA-&-a", true},
+	{"\x00a&", "&AAA-a&-", true},
+	{"ab&\uFFFF", "ab&-&,,8-", true},
+	{"a&b\uFFFF", "a&-b&,,8-", true},
+	{"&ab\uFFFF", "&-ab&,,8-", true},
+	{"ab\uFFFF&", "ab&,,8-&-", true},
+	{"a\uFFFFb&", "a&,,8-b&-", true},
+	{"\uFFFFab&", "&,,8-ab&-", true},
+
+	{"\x20\x25&\x27\x7E", " %&-'~", true},
+	{"\x1F\x20&\x7E\x7F", "&AB8- &-~&AH8-", true},
+	{"&\x00\x19\x7F\u0080", "&-&AAAAGQB,AIA-", true},
+	{"\x00&\x19\x7F\u0080", "&AAA-&-&ABkAfwCA-", true},
+	{"\x00\x19&\x7F\u0080", "&AAAAGQ-&-&AH8AgA-", true},
+	{"\x00\x19\x7F&\u0080", "&AAAAGQB,-&-&AIA-", true},
+	{"\x00\x19\x7F\u0080&", "&AAAAGQB,AIA-&-", true},
+	{"&\x00\x1F\x7F\u0080", "&-&AAAAHwB,AIA-", true},
+	{"\x00&\x1F\x7F\u0080", "&AAA-&-&AB8AfwCA-", true},
+	{"\x00\x1F&\x7F\u0080", "&AAAAHw-&-&AH8AgA-", true},
+	{"\x00\x1F\x7F&\u0080", "&AAAAHwB,-&-&AIA-", true},
+	{"\x00\x1F\x7F\u0080&", "&AAAAHwB,AIA-&-", true},
+
+	// Russian
+	{"\u041C\u0430\u043A\u0441\u0438\u043C \u0425\u0438\u0442\u0440\u043E\u0432",
+		"&BBwEMAQ6BEEEOAQ8- &BCUEOARCBEAEPgQy-", true},
+
+	// RFC 3501
+	{"~peter/mail/\u53F0\u5317/\u65E5\u672C\u8A9E", "~peter/mail/&U,BTFw-/&ZeVnLIqe-", true},
+	{"~peter/mail/\u53F0\u5317/\u65E5\u672C\u8A9E", "~peter/mail/&U,BTFw-/&ZeVnLIqe-", true},
+	{"\u263A!", "&Jjo-!", true},
+	{"\u53F0\u5317\u65E5\u672C\u8A9E", "&U,BTF2XlZyyKng-", true},
+
+	// RFC 2152 (modified)
+	{"\u0041\u2262\u0391\u002E", "A&ImIDkQ-.", true},
+	{"Hi Mom -\u263A-!", "Hi Mom -&Jjo--!", true},
+	{"\u65E5\u672C\u8A9E", "&ZeVnLIqe-", true},
+
+	// 8->16 and 24->16 byte UTF-8 to UTF-16 conversion
+	{"\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007", "&AAAAAQACAAMABAAFAAYABw-", true},
+	{"\u0800\u0801\u0802\u0803\u0804\u0805\u0806\u0807", "&CAAIAQgCCAMIBAgFCAYIBw-", true},
+
+	// Invalid UTF-8 (bad bytes are converted to U+FFFD)
+	{"\xC0\x80", "&,,3,,Q-", false},                     // U+0000
+	{"\xF4\x90\x80\x80", "&,,3,,f,9,,0-", false},        // U+110000
+	{"\xF7\xBF\xBF\xBF", "&,,3,,f,9,,0-", false},        // U+1FFFFF
+	{"\xF8\x88\x80\x80\x80", "&,,3,,f,9,,3,,Q-", false}, // U+200000
+	{"\xF4\x8F\xBF\x3F", "&,,3,,f,9-?", false},          // U+10FFFF (bad byte)
+	{"\xF4\x8F\xBF", "&,,3,,f,9-", false},               // U+10FFFF (short)
+	{"\xF4\x8F", "&,,3,,Q-", false},
+	{"\xF4", "&,,0-", false},
+	{"\x00\xF4\x00", "&AAD,,QAA-", false},
+}
+
+func TestEncoder(t *testing.T) {
+	for _, test := range encode {
+		out := utf7.Encode(test.in)
+		if out != test.out {
+			t.Errorf("UTF7Encode(%+q) expected %+q; got %+q", test.in, test.out, out)
+		}
+	}
+}

+ 13 - 0
server/utils/utf7/utf7.go

@@ -0,0 +1,13 @@
+// Package utf7 implements modified UTF-7 encoding defined in RFC 3501 section 5.1.3
+package utf7
+
+import (
+	"encoding/base64"
+)
+
+const (
+	min = 0x20 // Minimum self-representing UTF-7 value
+	max = 0x7E // Maximum self-representing UTF-7 value
+)
+
+var b64Enc = base64.NewEncoding("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,")