ソースを参照

Feature/v2.8.0 (#231)

支持Imap协议
升级所有依赖
修复部分bug
Jinnrry 1 年間 前
コミット
5af46b32f6
82 ファイル変更4148 行追加344 行削除
  1. 1 1
      .github/workflows/release.yml
  2. 8 11
      .github/workflows/unitTest.yml
  3. 2 2
      Dockerfile
  4. 1 1
      DockerfileGithubAction
  5. 5 5
      Makefile
  6. 5 2
      README.md
  7. 3 0
      README_CN.md
  8. 5 6
      fe/src/components/GroupSettings.vue
  9. 155 4
      server/config/config.go
  10. 1 1
      server/config/config.json
  11. 6 0
      server/consts/consts.go
  12. 2 2
      server/controllers/email/delete.go
  13. 1 8
      server/controllers/group.go
  14. 6 1
      server/db/init.go
  15. 3 1
      server/dto/parsemail/dkim.go
  16. 27 0
      server/dto/parsemail/email.go
  17. 1 1
      server/dto/parsemail/email_test.go
  18. 7 0
      server/dto/response/email.go
  19. 16 15
      server/go.mod
  20. 46 42
      server/go.sum
  21. 2 2
      server/listen/cron_server/ssl_update.go
  22. 0 0
      server/listen/http_server/http_server.go
  23. 0 0
      server/listen/http_server/https_server.go
  24. 0 0
      server/listen/http_server/setup_server.go
  25. 55 0
      server/listen/imap_server/imap_server.go
  26. 441 0
      server/listen/imap_server/imap_server_test.go
  27. 75 0
      server/listen/imap_server/server.go
  28. 126 0
      server/listen/imap_server/session_copy.go
  29. 25 0
      server/listen/imap_server/session_create.go
  30. 29 0
      server/listen/imap_server/session_delete.go
  31. 33 0
      server/listen/imap_server/session_expunge.go
  32. 113 0
      server/listen/imap_server/session_fetch.go
  33. 52 0
      server/listen/imap_server/session_idle.go
  34. 106 0
      server/listen/imap_server/session_list.go
  35. 46 0
      server/listen/imap_server/session_login.go
  36. 78 0
      server/listen/imap_server/session_move.go
  37. 16 0
      server/listen/imap_server/session_namespace.go
  38. 20 0
      server/listen/imap_server/session_poll.go
  39. 34 0
      server/listen/imap_server/session_rename.go
  40. 46 0
      server/listen/imap_server/session_search.go
  41. 32 0
      server/listen/imap_server/session_select.go
  42. 40 0
      server/listen/imap_server/session_status.go
  43. 47 0
      server/listen/imap_server/session_store.go
  44. 12 4
      server/listen/pop3_server/action.go
  45. 57 0
      server/listen/pop3_server/action_test.go
  46. 0 0
      server/listen/pop3_server/pop3server.go
  47. 11 5
      server/listen/smtp_server/read_content.go
  48. 2 2
      server/listen/smtp_server/read_content_test.go
  49. 0 0
      server/listen/smtp_server/smtp.go
  50. 0 0
      server/listen/smtp_server/smtp_test/sendEmailTest.py
  51. 1 57
      server/main.go
  52. 55 5
      server/main_test.go
  53. 25 0
      server/models/group.go
  54. 9 6
      server/models/user_email.go
  55. 0 55
      server/pop3_server/action_test.go
  56. 11 3
      server/res_init/init.go
  57. 42 2
      server/services/del_email/del_email.go
  58. 61 0
      server/services/detail/detail.go
  59. 232 2
      server/services/group/group.go
  60. 22 0
      server/services/group/group_test.go
  61. 159 1
      server/services/list/list.go
  62. 4 42
      server/services/setup/db.go
  63. 3 1
      server/services/setup/dns.go
  64. 4 3
      server/services/setup/domain.go
  65. 3 2
      server/services/setup/finish.go
  66. 0 37
      server/services/setup/ssl/dnsProvide_test.go
  67. 24 12
      server/services/setup/ssl/ssl.go
  68. 15 0
      server/services/setup/ssl/ssl_test.go
  69. 59 0
      server/utils/goimap/action.go
  70. 15 0
      server/utils/goimap/dto.go
  71. 830 0
      server/utils/goimap/imap.go
  72. 192 0
      server/utils/goimap/imap_test.go
  73. 28 0
      server/utils/goimap/session.go
  74. 23 0
      server/utils/utf7/LICENSE
  75. 1 0
      server/utils/utf7/README.md
  76. 118 0
      server/utils/utf7/decoder.go
  77. 118 0
      server/utils/utf7/decoder_test.go
  78. 88 0
      server/utils/utf7/encoder.go
  79. 123 0
      server/utils/utf7/encoder_test.go
  80. 13 0
      server/utils/utf7/utf7.go
  81. 16 0
      server/utils/version/version.go
  82. 55 0
      server/utils/version/version_test.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

+ 8 - 11
.github/workflows/unitTest.yml

@@ -39,6 +39,9 @@ jobs:
       COMMIT: ${{ github.workflow_sha }}
       EVENT: ${{ github.event_name}}
     steps:
+      - name: Setup Node.js environment
+        run: apt update && apt install -y nodejs npm
+
       - name: Checkout
         uses: actions/checkout@v4
 
@@ -50,9 +53,6 @@ jobs:
           token: ${{ secrets.GITHUB_TOKEN }}
           issue-number: ${{ github.event.pull_request.number }}
           labels: 'Auto: Test Failed'
-
-      - name: Setup Node.js environment
-        run: apt update && apt install -y nodejs npm
         
       - name: Install Dependencies
         run: npm install --global yarn
@@ -60,15 +60,12 @@ jobs:
       - name: FE build
         run: make build_fe
 
+      - name: Run Test Mysql
+        run: make test_mysql
+
       - name: Run Test
         run: make test
 
-      - uses: actions/upload-artifact@v4
-        with:
-          name: dbfile
-          path: server/config/pmail_temp.db
-
-
-      - name: Run Test Mysql
-        run: make test_mysql
+#      - name: Run postgres
+#        run: make test_postgres
 

+ 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

+ 5 - 5
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,10 +52,10 @@ 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 -p 1 ./...
 
 test_mysql:
-	export setup_port=17888 && cd server && go test -args "mysql" -v ./...
+	export setup_port=17888 && cd server && export PMail_ROOT=$(CURDIR)/server/ && go test -args "mysql" -v -p 1 ./...
 
 test_postgres:
-	export setup_port=17888 && cd server && go test -args "postgres" -v ./...
+	export setup_port=17888 && cd server && export PMail_ROOT=$(CURDIR)/server/ && go test -args "postgres" -v -p 1 ./...

+ 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);
           });

+ 155 - 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,63 @@ 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)
+	_ = os.MkdirAll(ROOT_PATH+"/config/", 0755)
+	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)
+		_ = os.MkdirAll(ROOT_PATH+"/config/", 0755)
+		err := os.WriteFile(ROOT_PATH+"./config/config.json", bytes, 0666)
+		if err != nil {
+			log.Errorf("Write Config Error:%s", err.Error())
+			return nil, errors.Wrap(err)
+		}
+	} else {
+		cfgData, err := os.ReadFile(ROOT_PATH + "./config/config.json")
+		if err != nil {
+			log.Errorf("Read Config Error:%s", err.Error())
+			return nil, errors.Wrap(err)
+		}
+
+		err = json.Unmarshal(cfgData, &configData)
+		configData.fixPath()
+		if err != nil {
+			log.Errorf("Read Config Unmarshal Error:%s", err.Error())
+			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",

+ 6 - 0
server/consts/consts.go

@@ -17,4 +17,10 @@ const (
 
 	//EmailStatusDel 3删除
 	EmailStatusDel int8 = 3
+
+	// EmailStatusDrafts 草稿箱
+	EmailStatusDrafts int8 = 4
+
+	// EmailStatusJunk 骚扰邮件
+	EmailStatusJunk int8 = 5
 )

+ 2 - 2
server/controllers/email/delete.go

@@ -11,8 +11,8 @@ import (
 )
 
 type emailDeleteRequest struct {
-	IDs       []int64 `json:"ids"`
-	ForcedDel bool    `json:"forcedDel"`
+	IDs       []int `json:"ids"`
+	ForcedDel bool  `json:"forcedDel"`
 }
 
 func EmailDelete(ctx *context.Context, w http.ResponseWriter, req *http.Request) {

+ 1 - 8
server/controllers/group.go

@@ -2,11 +2,9 @@ package controllers
 
 import (
 	"encoding/json"
-	"github.com/Jinnrry/pmail/db"
 	"github.com/Jinnrry/pmail/dto"
 	"github.com/Jinnrry/pmail/dto/response"
 	"github.com/Jinnrry/pmail/i18n"
-	"github.com/Jinnrry/pmail/models"
 	"github.com/Jinnrry/pmail/services/group"
 	"github.com/Jinnrry/pmail/utils/array"
 	"github.com/Jinnrry/pmail/utils/context"
@@ -67,13 +65,8 @@ func AddGroup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
 		log.WithContext(ctx).Errorf("%+v", err)
 	}
 
-	var newGroup models.Group = models.Group{
-		Name:     reqData.Name,
-		ParentId: reqData.ParentId,
-		UserId:   ctx.UserID,
-	}
+	newGroup, err := group.CreateGroup(ctx, reqData.Name, reqData.ParentId)
 
-	_, err = db.Instance.Insert(&newGroup)
 	if err != nil {
 		response.NewErrorResponse(response.ServerError, "DBError", err.Error()).FPrint(w)
 		return

+ 6 - 1
server/db/init.go

@@ -36,6 +36,7 @@ func Init(version string) error {
 		return errors.New("Database Type Error!")
 	}
 	if err != nil {
+		log.Errorf("DB init Error! %s", err.Error())
 		return errors.Wrap(err)
 	}
 
@@ -53,11 +54,15 @@ func Init(version string) error {
 		panic(err)
 	}
 
-	if version != "" && v.Info != version {
+	if version != "" && v.Info != version && version != "test" {
 		v.Info = version
 		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{

+ 27 - 0
server/dto/parsemail/email.go

@@ -13,6 +13,7 @@ import (
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cast"
 	"io"
+	"mime"
 	"net/textproto"
 	"regexp"
 	"strings"
@@ -24,6 +25,13 @@ type User struct {
 	Name         string `json:"Name"`
 }
 
+func (u User) Build() string {
+	if u.Name != "" {
+		return fmt.Sprintf("\"%s\" <%s>", mime.QEncoding.Encode("utf-8", u.Name), u.EmailAddress)
+	}
+	return fmt.Sprintf("<%s>", u.EmailAddress)
+}
+
 func (u User) GetDomainAccount() (string, string) {
 	infos := strings.Split(u.EmailAddress, "@")
 	if len(infos) >= 2 {
@@ -60,6 +68,25 @@ type Email struct {
 	Size        int
 }
 
+func users2String(users []*User) string {
+	ret := ""
+	for _, user := range users {
+		if ret != "" {
+			ret += ", "
+		}
+		ret += user.Build()
+	}
+	return ret
+}
+
+func (e *Email) BuildTo2String() string {
+	return users2String(e.To)
+}
+
+func (e *Email) BuildCc2String() string {
+	return users2String(e.Cc)
+}
+
 func NewEmailFromModel(d models.Email) *Email {
 
 	var To []*User

+ 1 - 1
server/dto/parsemail/email_test.go

@@ -68,7 +68,7 @@ func TestEmail_builder(t *testing.T) {
 	e := Email{
 		From:    buildUser("i@test.com"),
 		To:      buildUsers([]string{"to@test.com"}),
-		Subject: "Title",
+		Subject: "Title中文",
 		HTML:    []byte("Html"),
 		Text:    []byte("Text"),
 		Attachments: []*Attachment{

+ 7 - 0
server/dto/response/email.go

@@ -5,4 +5,11 @@ import "github.com/Jinnrry/pmail/models"
 type EmailResponseData struct {
 	models.Email `xorm:"extends"`
 	IsRead       int8 `json:"is_read"`
+	SerialNumber int  `json:"serial_number"`
+	UeId         int  `json:"ue_id"`
+}
+
+type UserEmailUIDData struct {
+	models.UserEmail `xorm:"extends"`
+	SerialNumber     int `json:"serial_number"`
 }

+ 16 - 15
server/go.mod

@@ -9,19 +9,20 @@ require (
 	github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885
 	github.com/alexedwards/scs/v2 v2.8.0
 	github.com/dlclark/regexp2 v1.11.4
+	github.com/emersion/go-imap/v2 v2.0.0-beta.4
 	github.com/emersion/go-message v0.18.1
 	github.com/emersion/go-msgauth v0.6.8
 	github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43
 	github.com/emersion/go-smtp v0.21.3
-	github.com/go-acme/lego/v4 v4.18.0
+	github.com/go-acme/lego/v4 v4.21.0
 	github.com/go-sql-driver/mysql v1.8.1
 	github.com/lib/pq v1.10.9
 	github.com/mileusna/spf v0.9.5
 	github.com/sirupsen/logrus v1.9.3
-	github.com/spf13/cast v1.7.0
-	golang.org/x/crypto v0.27.0
-	golang.org/x/text v0.18.0
-	modernc.org/sqlite v1.33.1
+	github.com/spf13/cast v1.7.1
+	golang.org/x/crypto v0.31.0
+	golang.org/x/text v0.21.0
+	modernc.org/sqlite v1.34.4
 	xorm.io/builder v0.3.13
 	xorm.io/xorm v1.3.9
 )
@@ -31,7 +32,7 @@ require (
 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.4 // indirect
-	github.com/goccy/go-json v0.10.3 // indirect
+	github.com/goccy/go-json v0.10.4 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
@@ -47,16 +48,16 @@ require (
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
 	github.com/rogpeppe/go-internal v1.12.0 // indirect
 	github.com/syndtr/goleveldb v1.0.0 // indirect
-	golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
-	golang.org/x/mod v0.21.0 // indirect
-	golang.org/x/net v0.29.0 // indirect
-	golang.org/x/sync v0.8.0 // indirect
-	golang.org/x/sys v0.25.0 // indirect
-	golang.org/x/tools v0.25.0 // indirect
+	golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect
+	golang.org/x/mod v0.22.0 // indirect
+	golang.org/x/net v0.33.0 // indirect
+	golang.org/x/sync v0.10.0 // indirect
+	golang.org/x/sys v0.28.0 // indirect
+	golang.org/x/tools v0.28.0 // indirect
 	modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
-	modernc.org/libc v1.61.0 // indirect
-	modernc.org/mathutil v1.6.0 // indirect
+	modernc.org/libc v1.61.6 // indirect
+	modernc.org/mathutil v1.7.1 // indirect
 	modernc.org/memory v1.8.0 // indirect
-	modernc.org/strutil v1.2.0 // indirect
+	modernc.org/strutil v1.2.1 // indirect
 	modernc.org/token v1.1.0 // indirect
 )

+ 46 - 42
server/go.sum

@@ -15,12 +15,15 @@ github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gv
 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
 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=
@@ -35,18 +38,18 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
-github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
-github.com/go-acme/lego/v4 v4.18.0 h1:2hH8KcdRBSb+p5o9VZIm61GAOXYALgILUCSs1Q+OYsk=
-github.com/go-acme/lego/v4 v4.18.0/go.mod h1:Blkg3izvXpl3zxk7WKngIuwR2I/hvYVP3vRnvgBp7m8=
+github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
+github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-acme/lego/v4 v4.21.0 h1:arEW+8o5p7VI8Bk1kr/PDlgD1DrxtTH1gJ4b7mehL8o=
+github.com/go-acme/lego/v4 v4.21.0/go.mod h1:HrSWzm3Ckj45Ie3i+p1zKVobbQoMOaGu9m4up0dUeDI=
 github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
 github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
 github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
 github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
 github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
-github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
-github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
+github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -110,22 +113,23 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J
 github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
 github.com/onsi/gomega v1.31.1 h1:KYppCUK+bUgAZwHOu7EXVBKyQA6ILvOESHkn/tgoqvo=
 github.com/onsi/gomega v1.31.1/go.mod h1:y40C95dwAD1Nz36SsEnxvfFe8FFfNxzI5eJ0EYGyAy0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
 github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
 github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
-github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
-github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
+github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
 github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -135,16 +139,16 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
-golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
-golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
-golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU=
+golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
-golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
+golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -155,16 +159,16 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
 golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
-golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
-golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -186,8 +190,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
-golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -197,16 +201,16 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
-golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.6-0.20210726203631-07bc1bf47fb2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
-golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
+golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
+golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -228,30 +232,30 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
-modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
-modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
-modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
+modernc.org/cc/v4 v4.24.2 h1:uektamHbSXU7egelXcyVpMaaAsrRH4/+uMKUQAQUdOw=
+modernc.org/cc/v4 v4.24.2/go.mod h1:T1lKJZhXIi2VSqGBiB4LIbKs9NsKTbUXj4IDrmGqtTI=
+modernc.org/ccgo/v4 v4.23.5 h1:6uAwu8u3pnla3l/+UVUrDDO1HIGxHTYmFH6w+X9nsyw=
+modernc.org/ccgo/v4 v4.23.5/go.mod h1:FogrWfBdzqLWm1ku6cfr4IzEFouq2fSAPf6aSAHdAJQ=
 modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
 modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
-modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
-modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
+modernc.org/gc/v2 v2.6.0 h1:Tiw3pezQj7PfV8k4Dzyu/vhRHR2e92kOXtTFU8pbCl4=
+modernc.org/gc/v2 v2.6.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
 modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a h1:CfbpOLEo2IwNzJdMvE8aiRbPMxoTpgAJeyePh0SmO8M=
 modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
-modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
-modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
-modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
-modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/libc v1.61.6 h1:L2jW0wxHPCyHK0YSHaGaVlY0WxjpG/TTVdg6gRJOPqw=
+modernc.org/libc v1.61.6/go.mod h1:G+DzuaCcReUYYg4nNSfigIfTDCENdj9EByglvaRx53A=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
 modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
 modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
 modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
 modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
 modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
 modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
-modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
-modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
-modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
-modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
+modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
 modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
 modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
 xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo=

+ 2 - 2
server/cron_server/ssl_update.go → 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)
 		}

+ 0 - 0
server/http_server/http_server.go → server/listen/http_server/http_server.go


+ 0 - 0
server/http_server/https_server.go → server/listen/http_server/https_server.go


+ 0 - 0
server/http_server/setup_server.go → server/listen/http_server/setup_server.go


+ 55 - 0
server/listen/imap_server/imap_server.go

@@ -0,0 +1,55 @@
+package imap_server
+
+import (
+	"crypto/tls"
+	"github.com/Jinnrry/pmail/config"
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapserver"
+	log "github.com/sirupsen/logrus"
+	"os"
+)
+
+var instanceTLS *imapserver.Server
+
+func Stop() {
+	if instanceTLS != nil {
+		instanceTLS.Close()
+		instanceTLS = nil
+	}
+}
+
+// StarTLS 启动TLS端口监听,不加密的代码就懒得写了
+func StarTLS() {
+
+	crt, err := tls.LoadX509KeyPair(config.Instance.SSLPublicKeyPath, config.Instance.SSLPrivateKeyPath)
+	if err != nil {
+		panic(err)
+	}
+	tlsConfig := &tls.Config{
+		Certificates: []tls.Certificate{crt},
+	}
+
+	memServer := NewServer()
+
+	option := &imapserver.Options{
+		NewSession: func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
+			return memServer.NewSession(), nil, nil
+		},
+		Caps: imap.CapSet{
+			imap.CapIMAP4rev1: {},
+			imap.CapIMAP4rev2: {},
+		},
+		TLSConfig:    tlsConfig,
+		InsecureAuth: false,
+	}
+
+	if config.Instance.LogLevel == "debug" {
+		option.DebugWriter = os.Stdout
+	}
+
+	instanceTLS = imapserver.New(option)
+	log.Infof("IMAP With TLS Server Start On Port :993")
+	if err := instanceTLS.ListenAndServeTLS(":993"); err != nil {
+		panic(err)
+	}
+}

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

@@ -0,0 +1,441 @@
+package imap_server
+
+import (
+	"crypto/tls"
+	"github.com/Jinnrry/pmail/config"
+	"github.com/Jinnrry/pmail/db"
+	"github.com/Jinnrry/pmail/models"
+	"github.com/Jinnrry/pmail/utils/array"
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapclient"
+	"github.com/emersion/go-message/charset"
+	"mime"
+	"testing"
+	"time"
+)
+
+var clientUnLogin *imapclient.Client
+var clientLogin *imapclient.Client
+
+func TestMain(m *testing.M) {
+	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,
+		},
+	}
+
+	var err error
+	clientUnLogin, err = imapclient.DialTLS("127.0.0.1:993", options)
+	if err != nil {
+		panic(err)
+	}
+
+	clientLogin, err = imapclient.DialTLS("127.0.0.1:993", options)
+	if err != nil {
+		panic(err)
+	}
+
+	err = clientLogin.Login("testCase", "testCase").Wait()
+	if err != nil {
+		panic(err)
+	}
+
+	m.Run()
+}
+
+func TestCapability(t *testing.T) {
+
+	res, err := clientUnLogin.Capability().Wait()
+	if err != nil {
+		t.Error(err)
+	}
+	if _, ok := res["IMAP4rev1"]; !ok {
+		t.Error("Capability Error")
+	}
+
+}
+
+func TestLogin(t *testing.T) {
+	err := clientUnLogin.Login("testCase", "testCaseasdfsadf").Wait()
+	sErr := err.(*imap.Error)
+	if sErr.Code != "AUTHENTICATIONFAILED" {
+		t.Error("Login Error")
+	}
+}
+
+func TestCreate(t *testing.T) {
+	err := clientLogin.Create("一级菜单", nil).Wait()
+	if err != nil {
+		t.Error(err)
+	}
+
+	err = clientLogin.Create("一级菜单/二级菜单", nil).Wait()
+	if err != nil {
+		t.Error(err)
+	}
+
+	res, err := clientLogin.List("", "*", nil).Collect()
+	if err != nil {
+		t.Error(err)
+	}
+	var mailbox []string
+	for _, v := range res {
+		mailbox = append(mailbox, v.Mailbox)
+	}
+
+	if !array.InArray("一级菜单", mailbox) || !array.InArray("一级菜单/二级菜单", mailbox) {
+		t.Error(mailbox)
+	}
+
+}
+
+func TestRename(t *testing.T) {
+
+	err := clientLogin.Rename("一级菜单", "主菜单").Wait()
+	if err != nil {
+		t.Error(err)
+	}
+
+	res, err := clientLogin.List("", "*", nil).Collect()
+	if err != nil {
+		t.Error(err)
+	}
+	var mailbox []string
+	for _, v := range res {
+		mailbox = append(mailbox, v.Mailbox)
+	}
+
+	if !array.InArray("主菜单", mailbox) {
+		t.Error(mailbox)
+	}
+}
+
+func TestList(t *testing.T) {
+	res, err := clientUnLogin.List("", "", &imap.ListOptions{}).Collect()
+
+	if err == nil {
+		t.Logf("%+v", res)
+		t.Error("List Unlogin error")
+	}
+
+	res, err = clientLogin.List("", "", &imap.ListOptions{}).Collect()
+	if err != nil {
+		t.Error(err)
+	}
+	if len(res) == 0 {
+		t.Error("List Error")
+	}
+
+	res, err = clientLogin.List("", "*", &imap.ListOptions{}).Collect()
+	if err != nil {
+		t.Error(err)
+	}
+	if len(res) == 0 {
+		t.Error("List Error")
+	}
+
+	res, err = clientLogin.List("", "一级菜单/%", &imap.ListOptions{}).Collect()
+	if err != nil {
+		t.Error(err)
+	}
+	if len(res) == 0 {
+		t.Error("List Error")
+	}
+
+	if len(res) != 1 {
+		t.Error("List Error")
+	}
+
+	res, err = clientLogin.List("", "一级菜单/*", &imap.ListOptions{}).Collect()
+	if err != nil {
+		t.Error(err)
+	}
+	if len(res) != 1 {
+		t.Error("List Error")
+	}
+	if len(res) == 0 {
+		t.Error("List Error")
+	}
+
+}
+
+func TestDelete(t *testing.T) {
+
+	clientLogin.Create("一级菜单/二级菜单", nil).Wait()
+
+	err := clientLogin.Delete("二级菜单").Wait()
+	if err != nil {
+		t.Error(err)
+	}
+	res, err := clientLogin.List("", "*", nil).Collect()
+	if err != nil {
+		t.Error(err)
+	}
+	var mailbox []string
+	for _, v := range res {
+		mailbox = append(mailbox, v.Mailbox)
+	}
+
+	if array.InArray("二级菜单", mailbox) {
+		t.Error(mailbox)
+	}
+
+}
+
+func TestAppend(t *testing.T) {
+
+}
+func TestSelect(t *testing.T) {
+	res, err := clientUnLogin.Select("INBOX", &imap.SelectOptions{}).Wait()
+	if err == nil {
+		t.Logf("%+v", res)
+		t.Error("Select Unlogin error")
+	}
+
+	res, err = clientLogin.Select("INBOX", &imap.SelectOptions{}).Wait()
+	if err != nil {
+		t.Logf("%+v", res)
+		t.Error("Select error")
+	}
+
+	if res == nil || res.NumMessages == 0 {
+		t.Error("Select Error")
+	}
+
+	res, err = clientLogin.Select("Deleted Messages", &imap.SelectOptions{}).Wait()
+	if err != nil {
+		t.Logf("%+v", res)
+		t.Error("Select error")
+	}
+
+	if res == nil || res.NumMessages == 0 {
+		t.Error("Select Error")
+	}
+
+}
+
+func TestStatus(t *testing.T) {
+	res, err := clientUnLogin.Status("INBOX", &imap.StatusOptions{}).Wait()
+	if err == nil {
+		t.Logf("%+v", res)
+		t.Error("Select Unlogin error")
+	}
+
+	res, err = clientLogin.Status("INBOX", &imap.StatusOptions{}).Wait()
+	if err != nil {
+		t.Logf("%+v", res)
+		t.Error("Select error")
+	}
+
+}
+
+func TestFetch(t *testing.T) {
+	res2, err := clientLogin.Select("INBOX", &imap.SelectOptions{}).Wait()
+	if err != nil {
+		t.Logf("%+v", res2)
+		t.Error("Fetch error")
+	}
+
+	res, err := clientLogin.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{
+		Envelope:     true,
+		Flags:        true,
+		InternalDate: true,
+		RFC822Size:   true,
+		UID:          true,
+		BodySection: []*imap.FetchItemBodySection{
+			{
+				Specifier: imap.PartSpecifierText,
+				Peek:      true,
+			},
+		},
+	}).Collect()
+	if err != nil {
+		t.Logf("%+v", res)
+		t.Error("Fetch error")
+	}
+
+	res, err = clientLogin.Fetch(imap.SeqSetNum(1, 2, 3, 4, 5, 6, 7, 8, 9), &imap.FetchOptions{
+		Flags: true,
+		UID:   true,
+	}).Collect()
+	if err != nil {
+		t.Logf("%+v", res)
+		t.Error("Fetch error")
+	}
+
+	res, err = clientLogin.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{
+		Envelope:     true,
+		Flags:        true,
+		InternalDate: true,
+		RFC822Size:   true,
+		UID:          true,
+		BodySection: []*imap.FetchItemBodySection{
+			{
+				Specifier:    imap.PartSpecifierHeader,
+				HeaderFields: []string{"subject"},
+				Peek:         true,
+			},
+		},
+	}).Collect()
+	if err != nil {
+		t.Logf("%+v", res)
+		t.Error("Fetch error")
+	}
+
+	res, err = clientLogin.Fetch(imap.SeqSetNum(1), &imap.FetchOptions{
+		UID: true,
+		BodySection: []*imap.FetchItemBodySection{
+			{
+				Specifier: imap.PartSpecifierHeader,
+				Peek:      true,
+			},
+		},
+	}).Collect()
+	if err != nil {
+		t.Logf("%+v", res)
+		t.Error("Fetch error")
+	}
+}
+func TestStore(t *testing.T) {
+	res, err := clientLogin.Store(
+		imap.UIDSetNum(1),
+		&imap.StoreFlags{
+			Op:    imap.StoreFlagsAdd,
+			Flags: []imap.Flag{"\\Seen"},
+		},
+		&imap.StoreOptions{}).Collect()
+	if err != nil {
+		t.Errorf("%+v", err)
+	}
+	t.Logf("%+v", res)
+
+}
+func TestClose(t *testing.T) {
+
+}
+func TestExpunge(t *testing.T) {
+
+	clientLogin.Select("INBOX", &imap.SelectOptions{}).Wait()
+
+	res, err := clientLogin.UIDExpunge(imap.UIDSetNum(1, 2)).Collect()
+
+	if err != nil {
+		t.Errorf("%+v", err)
+	}
+	t.Logf("%+v", res)
+	var ues []models.UserEmail
+	db.Instance.Table("user_email").Where("id=1 or id=2").Find(&ues)
+	if len(ues) > 0 {
+		t.Errorf("TestExpunge Error")
+	}
+
+}
+func TestExamine(t *testing.T) {
+
+}
+func TestSubscribe(t *testing.T) {
+
+}
+func TestUnSubscribe(t *testing.T) {
+
+}
+func TestLSub(t *testing.T) {
+
+}
+
+func TestCheck(t *testing.T) {
+
+}
+func TestSearch(t *testing.T) {
+	clientLogin.Select("INBOX", &imap.SelectOptions{}).Wait()
+
+	res, err := clientLogin.Search(&imap.SearchCriteria{
+		UID: []imap.UIDSet{
+			[]imap.UIDRange{
+				{Start: 1},
+			},
+			[]imap.UIDRange{
+				{Start: 2},
+			},
+			[]imap.UIDRange{
+				{Start: 2, Stop: 5},
+			},
+		},
+	}, &imap.SearchOptions{}).Wait()
+	if err != nil {
+		t.Errorf("%+v", err)
+	}
+	t.Logf("%+v", res)
+}
+func TestMove(t *testing.T) {
+	clientLogin.Select("INBOX", &imap.SelectOptions{}).Wait()
+
+	_, err := clientLogin.Move(imap.UIDSetNum(21), "Junk").Wait()
+	if err != nil {
+		t.Errorf("%+v", err)
+	}
+	_, err = clientLogin.Move(imap.UIDSetNum(23), "一级菜单").Wait()
+	if err != nil {
+		t.Errorf("%+v", err)
+	}
+	var ue []models.UserEmail
+	db.Instance.Table("user_email").Where("id=21 or id=23").Find(&ue)
+	for _, v := range ue {
+		if v.ID == 21 && (v.GroupId != 0 || v.Status != 5) {
+			t.Errorf("TestMove Error")
+		}
+		if v.ID == 23 && v.GroupId != 4 {
+			t.Errorf("TestMove Error")
+		}
+	}
+
+}
+
+func TestCopy(t *testing.T) {
+	clientLogin.Select("INBOX", &imap.SelectOptions{}).Wait()
+
+	res, err := clientLogin.Copy(imap.UIDSetNum(25), "Junk").Wait()
+	if err != nil {
+		t.Errorf("%+v", err)
+	}
+	t.Logf("%+v", res)
+
+	if !res.DestUIDs.Contains(33) {
+		t.Errorf("TestCopy Error")
+	}
+
+	res, err = clientLogin.Copy(imap.UIDSetNum(27), "一级菜单").Wait()
+	if err != nil {
+		t.Errorf("%+v", err)
+	}
+	t.Logf("%+v", res)
+	if !res.DestUIDs.Contains(34) {
+		t.Errorf("TestCopy Error")
+	}
+}
+
+func TestNoop(t *testing.T) {
+	err := clientLogin.Noop().Wait()
+	if err != nil {
+		t.Error(err)
+	}
+}
+func TestIDLE(t *testing.T) {
+
+}
+func TestUnselect(t *testing.T) {
+
+}
+
+func TestLogout(t *testing.T) {
+	err := clientLogin.Logout().Wait()
+	if err != nil {
+		t.Error(err)
+	}
+}

+ 75 - 0
server/listen/imap_server/server.go

@@ -0,0 +1,75 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/utils/context"
+	"github.com/Jinnrry/pmail/utils/id"
+	"github.com/emersion/go-imap/v2"
+	log "github.com/sirupsen/logrus"
+	"sync"
+	"time"
+
+	"github.com/emersion/go-imap/v2/imapserver"
+)
+
+// Server is a server instance.
+//
+// A server contains a list of users.
+type Server struct {
+	mutex sync.Mutex
+}
+
+// NewServer creates a new server.
+func NewServer() *Server {
+	return &Server{}
+}
+
+type Status int8
+
+const (
+	UNAUTHORIZED Status = 1
+	AUTHORIZED   Status = 2
+	SELECTED     Status = 3
+	LOGOUT       Status = 4
+)
+
+type serverSession struct {
+	server         *Server // immutable
+	ctx            *context.Context
+	status         Status
+	currentMailbox string
+	connectTime    time.Time
+}
+
+// NewSession creates a new IMAP session.
+func (s *Server) NewSession() imapserver.Session {
+	tc := &context.Context{}
+	tc.SetValue(context.LogID, id.GenLogID())
+
+	return &serverSession{
+		server:      s,
+		ctx:         tc,
+		connectTime: time.Now(),
+	}
+}
+
+func (s *serverSession) Close() error {
+	return nil
+}
+
+func (s *serverSession) Subscribe(mailbox string) error {
+	return nil
+}
+
+func (s *serverSession) Unsubscribe(mailbox string) error {
+	return nil
+}
+
+func (s *serverSession) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
+	log.WithContext(s.ctx).Errorf("Append Not Implemented")
+	return nil, nil
+}
+
+func (s *serverSession) Unselect() error {
+	s.currentMailbox = ""
+	return nil
+}

+ 126 - 0
server/listen/imap_server/session_copy.go

@@ -0,0 +1,126 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/consts"
+	"github.com/Jinnrry/pmail/db"
+	"github.com/Jinnrry/pmail/dto/response"
+	"github.com/Jinnrry/pmail/models"
+	"github.com/Jinnrry/pmail/services/group"
+	"github.com/Jinnrry/pmail/services/list"
+	"github.com/Jinnrry/pmail/utils/context"
+	"github.com/emersion/go-imap/v2"
+	"github.com/spf13/cast"
+)
+
+func (s *serverSession) Copy(numSet imap.NumSet, dest string) (*imap.CopyData, error) {
+
+	var emailList []*response.EmailResponseData
+
+	switch numSet.(type) {
+	case imap.SeqSet:
+		seqSet := numSet.(imap.SeqSet)
+		for _, seq := range seqSet {
+			emailList = list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{
+				Star: cast.ToInt(seq.Start),
+				End:  cast.ToInt(seq.Stop),
+			}, false)
+		}
+	case imap.UIDSet:
+		uidSet := numSet.(imap.UIDSet)
+		for _, uid := range uidSet {
+			emailList = list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{
+				Star: cast.ToInt(uint32(uid.Start)),
+				End:  cast.ToInt(uint32(uid.Stop)),
+			}, true)
+		}
+	}
+
+	if len(emailList) == 0 {
+		return nil, &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: "Email Not Found",
+		}
+	}
+
+	var err error
+	destUid := []int{}
+	UIDValidity := 0
+	if group.IsDefaultBox(dest) {
+		UIDValidity, destUid, err = copy2defaultbox(s.ctx, emailList, dest)
+	} else {
+		UIDValidity, destUid, err = copy2userbox(s.ctx, emailList, dest)
+	}
+	data := imap.CopyData{}
+	data.UIDValidity = cast.ToUint32(UIDValidity)
+	data.DestUIDs = imap.UIDSet{}
+	data.SourceUIDs = imap.UIDSet{}
+	for _, uid := range destUid {
+		data.DestUIDs = append(data.DestUIDs, imap.UIDRange{Start: imap.UID(cast.ToUint32(uid)), Stop: imap.UID(cast.ToUint32(uid))})
+	}
+
+	for _, email := range emailList {
+		data.SourceUIDs = append(data.SourceUIDs, imap.UIDRange{Start: imap.UID(cast.ToUint32(email.UeId)), Stop: imap.UID(cast.ToUint32(email.UeId))})
+	}
+
+	return &data, err
+}
+
+func copy2defaultbox(ctx *context.Context, mails []*response.EmailResponseData, dest string) (int, []int, error) {
+
+	var destUid []int
+	for _, email := range mails {
+
+		newUe := models.UserEmail{
+			UserID:  ctx.UserID,
+			EmailID: email.Id,
+			IsRead:  email.IsRead,
+			GroupId: 0,
+		}
+		switch dest {
+		case "Deleted Messages":
+			newUe.Status = consts.EmailStatusDel
+		case "INBOX":
+			newUe.Status = consts.EmailStatusWait
+		case "Sent Messages":
+			newUe.Status = consts.EmailStatusSent
+		case "Drafts":
+			newUe.Status = consts.EmailStatusDrafts
+		case "Junk":
+			newUe.Status = consts.EmailStatusJunk
+		}
+		db.Instance.Insert(&newUe)
+		destUid = append(destUid, newUe.ID)
+	}
+
+	return models.GroupNameToCode[dest], destUid, nil
+}
+
+func copy2userbox(ctx *context.Context, mails []*response.EmailResponseData, dest string) (int, []int, error) {
+	groupInfo, err := group.GetGroupByFullPath(ctx, dest)
+	if err != nil {
+		return 0, nil, &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: err.Error(),
+		}
+	}
+	if groupInfo == nil || groupInfo.ID == 0 {
+		return 0, nil, &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: "Group not found",
+		}
+	}
+	var destUid []int
+	for _, email := range mails {
+		newUe := models.UserEmail{
+			UserID:  ctx.UserID,
+			EmailID: email.Id,
+			IsRead:  email.IsRead,
+			GroupId: groupInfo.ID,
+			Status:  email.Status,
+		}
+		db.Instance.Insert(&newUe)
+		destUid = append(destUid, newUe.ID)
+	}
+
+	return groupInfo.ID, destUid, nil
+}

+ 25 - 0
server/listen/imap_server/session_create.go

@@ -0,0 +1,25 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/services/group"
+	"github.com/emersion/go-imap/v2"
+	"strings"
+)
+
+func (s *serverSession) Create(mailbox string, options *imap.CreateOptions) error {
+	groupPath := strings.Split(mailbox, "/")
+
+	var parentId int
+	for _, path := range groupPath {
+		newGroup, err := group.CreateGroup(s.ctx, path, parentId)
+		if err != nil {
+			return &imap.Error{
+				Type: imap.StatusResponseTypeNo,
+				Text: err.Error(),
+			}
+		}
+		parentId = newGroup.ID
+	}
+
+	return nil
+}

+ 29 - 0
server/listen/imap_server/session_delete.go

@@ -0,0 +1,29 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/services/group"
+	"github.com/emersion/go-imap/v2"
+	"strings"
+)
+
+func (s *serverSession) Delete(mailbox string) error {
+	groupPath := strings.Split(mailbox, "/")
+
+	groupName := groupPath[len(groupPath)-1]
+	groupInfo, err := group.GetGroupByName(s.ctx, groupName)
+	if err != nil {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: err.Error(),
+		}
+	}
+	_, err = group.DelGroup(s.ctx, groupInfo.ID)
+	if err != nil {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: err.Error(),
+		}
+	}
+
+	return nil
+}

+ 33 - 0
server/listen/imap_server/session_expunge.go

@@ -0,0 +1,33 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/services/del_email"
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapserver"
+	"github.com/spf13/cast"
+)
+
+func (s *serverSession) Expunge(w *imapserver.ExpungeWriter, uids *imap.UIDSet) error {
+	if uids == nil {
+		return nil
+	}
+
+	uidList := []int{}
+	for _, uidRange := range *uids {
+		if uidRange.Start > 0 && uidRange.Stop > 0 {
+			for i := uidRange.Start; i <= uidRange.Stop; i++ {
+				uidList = append(uidList, cast.ToInt(uint32(i)))
+			}
+		}
+	}
+
+	err := del_email.DelByUID(s.ctx, uidList)
+	if err != nil {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: err.Error(),
+		}
+	}
+
+	return nil
+}

+ 113 - 0
server/listen/imap_server/session_fetch.go

@@ -0,0 +1,113 @@
+package imap_server
+
+import (
+	"bytes"
+	"fmt"
+	"github.com/Jinnrry/pmail/config"
+	"github.com/Jinnrry/pmail/dto/parsemail"
+	"github.com/Jinnrry/pmail/dto/response"
+	"github.com/Jinnrry/pmail/services/detail"
+	"github.com/Jinnrry/pmail/services/list"
+	"github.com/Jinnrry/pmail/utils/context"
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapserver"
+	"github.com/spf13/cast"
+	"mime"
+	"strings"
+	"time"
+)
+
+func (s *serverSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error {
+	switch numSet.(type) {
+	case imap.SeqSet:
+		seqSet := numSet.(imap.SeqSet)
+		for _, seq := range seqSet {
+			emailList := list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{
+				Star: cast.ToInt(seq.Start),
+				End:  cast.ToInt(seq.Stop),
+			}, false)
+			write(s.ctx, w, emailList, options)
+		}
+
+	case imap.UIDSet:
+		uidSet := numSet.(imap.UIDSet)
+		for _, uid := range uidSet {
+			emailList := list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{
+				Star: cast.ToInt(uint32(uid.Start)),
+				End:  cast.ToInt(uint32(uid.Stop)),
+			}, true)
+			write(s.ctx, w, emailList, options)
+		}
+	}
+	return nil
+}
+
+func write(ctx *context.Context, w *imapserver.FetchWriter, emailList []*response.EmailResponseData, options *imap.FetchOptions) {
+	for _, email := range emailList {
+		writer := w.CreateMessage(cast.ToUint32(email.SerialNumber))
+		if options.UID {
+			writer.WriteUID(imap.UID(email.UeId))
+		}
+		if options.RFC822Size {
+			emailContent := parsemail.NewEmailFromModel(email.Email).BuildBytes(ctx, false)
+			writer.WriteRFC822Size(cast.ToInt64(len(emailContent)))
+		}
+		if options.Flags {
+			if email.IsRead == 1 {
+				writer.WriteFlags([]imap.Flag{imap.FlagSeen})
+			} else {
+				writer.WriteFlags([]imap.Flag{})
+			}
+		}
+		if options.InternalDate {
+			writer.WriteInternalDate(email.CreateTime)
+		}
+		for _, section := range options.BodySection {
+			if !section.Peek {
+				detail.MakeRead(ctx, email.Id, true)
+			}
+			emailContent := parsemail.NewEmailFromModel(email.Email).BuildBytes(ctx, false)
+
+			if section.Specifier == imap.PartSpecifierNone || section.Specifier == imap.PartSpecifierText {
+				bodyWriter := writer.WriteBodySection(section, cast.ToInt64(len(emailContent)))
+				bodyWriter.Write(emailContent)
+				bodyWriter.Close()
+			}
+			if section.Specifier == imap.PartSpecifierHeader {
+				var b bytes.Buffer
+				parseEmail := parsemail.NewEmailFromModel(email.Email)
+				for _, field := range section.HeaderFields {
+					switch field {
+					case "date":
+						fmt.Fprintf(&b, "Date: %s\r\n", email.CreateTime.Format(time.RFC1123Z))
+					case "subject":
+						fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", email.Subject))
+					case "from":
+						if email.FromName != "" {
+							fmt.Fprintf(&b, "From: %s <%s>\r\n", mime.QEncoding.Encode("utf-8", email.FromName), email.FromAddress)
+						} else {
+							fmt.Fprintf(&b, "From: %s\r\n", email.FromAddress)
+						}
+					case "to":
+						fmt.Fprintf(&b, "To: %s\r\n", parseEmail.BuildTo2String())
+					case "cc":
+						if len(parseEmail.Cc) > 0 {
+							fmt.Fprintf(&b, "Cc: %s\r\n", parseEmail.BuildCc2String())
+						}
+					case "message-id":
+						fmt.Fprintf(&b, "Message-ID: %s\r\n", fmt.Sprintf("%d@%s", email.Id, config.Instance.Domain))
+					case "content-type":
+						args := strings.SplitN(string(emailContent), "\r\n", 3)
+						fmt.Fprintf(&b, "%s%s\r\n", args[0], args[1])
+					}
+				}
+
+				bodyWriter := writer.WriteBodySection(section, cast.ToInt64(b.Len()))
+				bodyWriter.Write(b.Bytes())
+				bodyWriter.Close()
+			}
+
+		}
+		writer.Close()
+	}
+}

+ 52 - 0
server/listen/imap_server/session_idle.go

@@ -0,0 +1,52 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/models"
+	"github.com/Jinnrry/pmail/utils/context"
+	"github.com/emersion/go-imap/v2/imapserver"
+	"github.com/spf13/cast"
+	"sync"
+)
+
+var userConnects sync.Map
+
+func (s *serverSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) error {
+	connects, ok := userConnects.Load(s.ctx.UserID)
+	logId := cast.ToString(s.ctx.GetValue(context.LogID))
+
+	if !ok {
+
+		connects = map[string]*imapserver.UpdateWriter{
+			logId: w,
+		}
+		userConnects.Store(s.ctx.UserID, connects)
+	} else {
+		connects := connects.(map[string]*imapserver.UpdateWriter)
+		if _, ok := connects[logId]; !ok {
+			connects[logId] = w
+			userConnects.Store(s.ctx.UserID, connects)
+		}
+	}
+
+	go func() {
+		<-stop
+		userConnects.Delete(logId)
+	}()
+
+	return nil
+}
+
+func IdleNotice(ctx *context.Context, userId int, email *models.Email) error {
+	if userId == 0 || email == nil || email.Id == 0 {
+		return nil
+	}
+
+	connects, ok := userConnects.Load(userId)
+	if ok {
+		connects := connects.(map[string]*imapserver.UpdateWriter)
+		for _, connect := range connects {
+			connect.WriteNumMessages(1)
+		}
+	}
+	return nil
+}

+ 106 - 0
server/listen/imap_server/session_list.go

@@ -0,0 +1,106 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/db"
+	"github.com/Jinnrry/pmail/models"
+	"github.com/Jinnrry/pmail/utils/context"
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapserver"
+	log "github.com/sirupsen/logrus"
+	"strings"
+)
+
+func matchGroup(ctx *context.Context, w *imapserver.ListWriter, basePath, pattern string) {
+	var groups []*models.Group
+	if basePath == "" && pattern == "*" {
+		db.Instance.Table("group").Where("user_id=?", ctx.UserID).Find(&groups)
+		//w.WriteList(&imap.ListData{
+		//	Attrs:   []imap.MailboxAttr{imap.MailboxAttrNoSelect, imap.MailboxAttrHasChildren},
+		//	Delim:   '/',
+		//	Mailbox: "[PMail]",
+		//})
+		w.WriteList(&imap.ListData{
+			Attrs:   []imap.MailboxAttr{imap.MailboxAttrHasNoChildren},
+			Delim:   '/',
+			Mailbox: "INBOX",
+		})
+		w.WriteList(&imap.ListData{
+			Attrs:   []imap.MailboxAttr{imap.MailboxAttrHasNoChildren, imap.MailboxAttrSent},
+			Delim:   '/',
+			Mailbox: "Sent Messages",
+		})
+		w.WriteList(&imap.ListData{
+			Attrs:   []imap.MailboxAttr{imap.MailboxAttrHasNoChildren, imap.MailboxAttrDrafts},
+			Delim:   '/',
+			Mailbox: "Drafts",
+		})
+
+		w.WriteList(&imap.ListData{
+			Attrs:   []imap.MailboxAttr{imap.MailboxAttrHasNoChildren, imap.MailboxAttrTrash},
+			Delim:   '/',
+			Mailbox: "Deleted Messages",
+		})
+		w.WriteList(&imap.ListData{
+			Attrs:   []imap.MailboxAttr{imap.MailboxAttrHasNoChildren, imap.MailboxAttrJunk},
+			Delim:   '/',
+			Mailbox: "Junk",
+		})
+	} else {
+		pattern = strings.ReplaceAll(pattern, "/*", "/%")
+
+		db.Instance.Table("group").Where("user_id=? and full_path like ?", ctx.UserID, pattern).Find(&groups)
+
+	}
+	for _, group := range groups {
+
+		data := &imap.ListData{
+			Attrs:   []imap.MailboxAttr{},
+			Mailbox: group.Name,
+			Delim:   '/',
+		}
+
+		if hasChildren(ctx, group.ID) {
+			data.Attrs = append(data.Attrs, imap.MailboxAttrHasChildren)
+		}
+
+		data.Mailbox = getLayerName(ctx, group, true)
+
+		w.WriteList(data)
+
+	}
+
+}
+
+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, allPath bool) string {
+	if item.ParentId == 0 {
+		return item.Name
+	}
+	var parent models.Group
+	_, _ = db.Instance.Table("group").Where("id=?", item.ParentId).Get(&parent)
+	if allPath {
+		return getLayerName(ctx, &parent, allPath) + "/" + item.Name
+	}
+	return getLayerName(ctx, &parent, allPath)
+}
+
+func (s *serverSession) List(w *imapserver.ListWriter, ref string, patterns []string, options *imap.ListOptions) error {
+	log.WithContext(s.ctx).Debugf("imap server list, ref: %s ,patterns: %s ", ref, patterns)
+
+	if ref == "" && len(patterns) == 0 {
+		w.WriteList(&imap.ListData{
+			Attrs:   []imap.MailboxAttr{imap.MailboxAttrNoSelect, imap.MailboxAttrHasChildren},
+			Delim:   '/',
+			Mailbox: "[PMail]",
+		})
+	}
+	for _, pattern := range patterns {
+		matchGroup(s.ctx, w, ref, pattern)
+	}
+
+	return nil
+}

+ 46 - 0
server/listen/imap_server/session_login.go

@@ -0,0 +1,46 @@
+package imap_server
+
+import (
+	"database/sql"
+	"github.com/Jinnrry/pmail/db"
+	"github.com/Jinnrry/pmail/models"
+	"github.com/Jinnrry/pmail/utils/errors"
+	"github.com/Jinnrry/pmail/utils/password"
+	"github.com/emersion/go-imap/v2"
+	log "github.com/sirupsen/logrus"
+	"strings"
+)
+
+func (s *serverSession) Login(username, pwd string) error {
+	if strings.Contains(username, "@") {
+		args := strings.Split(username, "@")
+		username = args[0]
+	}
+
+	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(s.ctx).Errorf("%+v", err)
+	}
+
+	if user.ID > 0 {
+		s.status = AUTHORIZED
+
+		s.ctx.UserID = user.ID
+		s.ctx.UserName = user.Name
+		s.ctx.UserAccount = user.Account
+		log.WithContext(s.ctx).Debug("Login successful")
+
+		return nil
+	}
+
+	log.WithContext(s.ctx).Info("user not found")
+	return &imap.Error{
+		Type: imap.StatusResponseTypeNo,
+		Code: imap.ResponseCodeAuthenticationFailed,
+		Text: "Invalid credentials (Failure)",
+	}
+}

+ 78 - 0
server/listen/imap_server/session_move.go

@@ -0,0 +1,78 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/dto/response"
+	"github.com/Jinnrry/pmail/services/group"
+	"github.com/Jinnrry/pmail/services/list"
+	"github.com/Jinnrry/pmail/utils/context"
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapserver"
+	"github.com/spf13/cast"
+)
+
+func (s *serverSession) Move(w *imapserver.MoveWriter, numSet imap.NumSet, dest string) error {
+
+	var emailList []*response.EmailResponseData
+
+	switch numSet.(type) {
+	case imap.SeqSet:
+		seqSet := numSet.(imap.SeqSet)
+		for _, seq := range seqSet {
+			emailList = list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{
+				Star: cast.ToInt(seq.Start),
+				End:  cast.ToInt(seq.Stop),
+			}, false)
+		}
+	case imap.UIDSet:
+		uidSet := numSet.(imap.UIDSet)
+		for _, uid := range uidSet {
+			emailList = list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{
+				Star: cast.ToInt(uint32(uid.Start)),
+				End:  cast.ToInt(uint32(uid.Stop)),
+			}, true)
+		}
+	}
+
+	var mailIds []int
+	for _, email := range emailList {
+		mailIds = append(mailIds, email.Id)
+	}
+
+	if group.IsDefaultBox(dest) {
+		return move2defaultbox(s.ctx, mailIds, dest)
+	} else {
+		return move2userbox(s.ctx, mailIds, dest)
+	}
+
+}
+
+func move2defaultbox(ctx *context.Context, mailIds []int, dest string) error {
+	err := group.Move2DefaultBox(ctx, mailIds, dest)
+	if err != nil {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: err.Error(),
+		}
+	}
+	return nil
+}
+
+func move2userbox(ctx *context.Context, mailIds []int, dest string) error {
+	groupInfo, err := group.GetGroupByFullPath(ctx, dest)
+	if err != nil {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: err.Error(),
+		}
+	}
+	if groupInfo == nil || groupInfo.ID == 0 {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: "Group not found",
+		}
+	}
+
+	group.MoveMailToGroup(ctx, mailIds, groupInfo.ID)
+
+	return nil
+}

+ 16 - 0
server/listen/imap_server/session_namespace.go

@@ -0,0 +1,16 @@
+package imap_server
+
+import (
+	"github.com/emersion/go-imap/v2"
+)
+
+func (s *serverSession) Namespace() (*imap.NamespaceData, error) {
+	return &imap.NamespaceData{
+		Personal: []imap.NamespaceDescriptor{
+			{
+				Prefix: "",
+				Delim:  '/',
+			},
+		},
+	}, nil
+}

+ 20 - 0
server/listen/imap_server/session_poll.go

@@ -0,0 +1,20 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/db"
+	"github.com/Jinnrry/pmail/models"
+	"github.com/emersion/go-imap/v2/imapserver"
+	"github.com/spf13/cast"
+)
+
+func (s *serverSession) Poll(w *imapserver.UpdateWriter, allowExpunge bool) error {
+
+	var ue []models.UserEmail
+	db.Instance.Table("user_email").Where("user_id=? and create >=?", s.ctx.UserID, s.connectTime).Find(&ue)
+
+	if len(ue) > 0 {
+		w.WriteNumMessages(cast.ToUint32(len(ue)))
+	}
+
+	return nil
+}

+ 34 - 0
server/listen/imap_server/session_rename.go

@@ -0,0 +1,34 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/services/group"
+	"github.com/emersion/go-imap/v2"
+	"strings"
+)
+
+func (s *serverSession) Rename(mailbox, newName string) error {
+	if group.IsDefaultBox(mailbox) {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: "This mailbox does not support rename.",
+		}
+	}
+
+	groupPath := strings.Split(mailbox, "/")
+
+	oldGroupName := groupPath[len(groupPath)-1]
+
+	newGroupPath := strings.Split(newName, "/")
+
+	newGroupName := newGroupPath[len(newGroupPath)-1]
+
+	err := group.Rename(s.ctx, oldGroupName, newGroupName)
+
+	if err != nil {
+		return &imap.Error{
+			Type: imap.StatusResponseTypeNo,
+			Text: err.Error(),
+		}
+	}
+	return nil
+}

+ 46 - 0
server/listen/imap_server/session_search.go

@@ -0,0 +1,46 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/dto/response"
+	"github.com/Jinnrry/pmail/services/list"
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapserver"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cast"
+)
+
+func (s *serverSession) Search(kind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) {
+	retList := []*response.UserEmailUIDData{}
+
+	for _, uidSet := range criteria.UID {
+		for _, uid := range uidSet {
+			res := list.GetUEListByUID(s.ctx, s.currentMailbox, cast.ToInt(uid.Start), cast.ToInt(uid.Stop), nil)
+			retList = append(retList, res...)
+		}
+	}
+	ret := &imap.SearchData{}
+
+	if kind == imapserver.NumKindSeq {
+		idList := imap.SeqSet{}
+		for _, data := range retList {
+			log.WithContext(s.ctx).Debugf("Search Seq result: UID: %d  EmailID:%d", data.ID, data.EmailID)
+			idList = append(idList, imap.SeqRange{
+				Start: cast.ToUint32(data.SerialNumber),
+				Stop:  cast.ToUint32(data.SerialNumber),
+			})
+		}
+		ret.All = idList
+	} else {
+		idList := imap.UIDSet{}
+		for _, data := range retList {
+			log.WithContext(s.ctx).Debugf("Search UID result: UID: %d  EmailID:%d", data.ID, data.EmailID)
+
+			idList = append(idList, imap.UIDRange{
+				Start: imap.UID(data.ID),
+				Stop:  imap.UID(data.ID),
+			})
+		}
+		ret.All = idList
+	}
+	return ret, nil
+}

+ 32 - 0
server/listen/imap_server/session_select.go

@@ -0,0 +1,32 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/services/group"
+	"github.com/emersion/go-imap/v2"
+	"github.com/spf13/cast"
+	"strings"
+)
+
+func (s *serverSession) Select(mailbox string, options *imap.SelectOptions) (*imap.SelectData, error) {
+	if "" == mailbox {
+		return nil, &imap.Error{
+			Type: imap.StatusResponseTypeBad,
+			Text: "mailbox not found",
+		}
+	}
+
+	paths := strings.Split(mailbox, "/")
+	s.currentMailbox = strings.Trim(paths[len(paths)-1], `"`)
+	_, data := group.GetGroupStatus(s.ctx, s.currentMailbox, []string{"MESSAGES", "UNSEEN", "UIDNEXT", "UIDVALIDITY"})
+
+	ret := &imap.SelectData{
+		Flags:          []imap.Flag{imap.FlagSeen},
+		PermanentFlags: []imap.Flag{imap.FlagSeen},
+		NumMessages:    cast.ToUint32(data["MESSAGES"]),
+		UIDNext:        imap.UID(data["UIDNEXT"]),
+		UIDValidity:    cast.ToUint32(data["UIDVALIDITY"]),
+	}
+
+	return ret, nil
+
+}

+ 40 - 0
server/listen/imap_server/session_status.go

@@ -0,0 +1,40 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/services/group"
+	"github.com/emersion/go-imap/v2"
+	"github.com/spf13/cast"
+)
+
+func (s *serverSession) Status(mailbox string, options *imap.StatusOptions) (*imap.StatusData, error) {
+	category := []string{}
+	if options.UIDNext {
+		category = append(category, "UIDNEXT")
+	}
+	if options.NumMessages {
+		category = append(category, "MESSAGES")
+	}
+	if options.UIDValidity {
+		category = append(category, "UIDVALIDITY")
+	}
+	if options.NumUnseen {
+		category = append(category, "UNSEEN")
+	}
+
+	_, data := group.GetGroupStatus(s.ctx, mailbox, category)
+
+	numMessages := cast.ToUint32(data["MESSAGES"])
+	numUnseen := cast.ToUint32(data["UNSEEN"])
+	numValidity := cast.ToUint32(data["UIDVALIDITY"])
+	numUIDNext := cast.ToUint32(data["UIDNEXT"])
+
+	ret := &imap.StatusData{
+		Mailbox:     mailbox,
+		NumMessages: &numMessages,
+		UIDNext:     imap.UID(numUIDNext),
+		UIDValidity: numValidity,
+		NumUnseen:   &numUnseen,
+	}
+
+	return ret, nil
+}

+ 47 - 0
server/listen/imap_server/session_store.go

@@ -0,0 +1,47 @@
+package imap_server
+
+import (
+	"github.com/Jinnrry/pmail/services/detail"
+	"github.com/Jinnrry/pmail/services/list"
+	"github.com/Jinnrry/pmail/utils/array"
+	"github.com/emersion/go-imap/v2"
+	"github.com/emersion/go-imap/v2/imapserver"
+	"github.com/spf13/cast"
+)
+
+func (s *serverSession) Store(w *imapserver.FetchWriter, numSet imap.NumSet, flags *imap.StoreFlags, options *imap.StoreOptions) error {
+	if flags.Op == imap.StoreFlagsSet {
+		return nil
+	}
+
+	if !array.InArray(imap.FlagSeen, flags.Flags) {
+		return nil
+	}
+
+	switch numSet.(type) {
+	case imap.SeqSet:
+		seqSet := numSet.(imap.SeqSet)
+		for _, seq := range seqSet {
+			emailList := list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{
+				Star: cast.ToInt(seq.Start),
+				End:  cast.ToInt(seq.Stop),
+			}, false)
+			for _, data := range emailList {
+				detail.MakeRead(s.ctx, data.Id, flags.Op == imap.StoreFlagsAdd)
+			}
+		}
+
+	case imap.UIDSet:
+		uidSet := numSet.(imap.UIDSet)
+		for _, uid := range uidSet {
+			emailList := list.GetEmailListByGroup(s.ctx, s.currentMailbox, list.ImapListReq{
+				Star: cast.ToInt(uint32(uid.Start)),
+				End:  cast.ToInt(uint32(uid.Stop)),
+			}, true)
+			for _, data := range emailList {
+				detail.MakeRead(s.ctx, data.Id, flags.Op == imap.StoreFlagsAdd)
+			}
+		}
+	}
+	return nil
+}

+ 12 - 4
server/pop3_server/action.go → 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)
@@ -327,8 +328,15 @@ func (a action) Noop(session *gopop.Session) error {
 
 func (a action) Quit(session *gopop.Session) error {
 	log.WithContext(session.Ctx).Debugf("POP3 CMD: QUIT ")
+
+	var DelIds []int
+
 	if len(session.DeleteIds) > 0 {
-		del_email.DelEmail(session.Ctx.(*context.Context), session.DeleteIds, false)
+		for _, delId := range session.DeleteIds {
+			DelIds = append(DelIds, cast.ToInt(delId))
+		}
+
+		del_email.DelEmail(session.Ctx.(*context.Context), DelIds, false)
 	}
 
 	return nil

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

@@ -0,0 +1,57 @@
+package pop3_server
+
+import (
+	"bytes"
+	"fmt"
+	"github.com/Jinnrry/gopop"
+	"github.com/Jinnrry/pmail/config"
+	"github.com/Jinnrry/pmail/db"
+	"github.com/Jinnrry/pmail/utils/context"
+	"github.com/emersion/go-message/mail"
+	"io"
+	"testing"
+)
+
+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{}
+	session := &gopop.Session{
+		Ctx: &context.Context{
+			UserID: 1,
+		},
+	}
+	got, got1, err := a.Retr(session, 301)
+
+	_, _, _ = got, got1, err
+}
+
+func Test_email(t *testing.T) {
+	var b bytes.Buffer
+
+	// Create our mail header
+	var h mail.Header
+
+	// Create a new mail writer
+	mw, _ := mail.CreateWriter(&b, h)
+
+	// Create a text part
+	tw, _ := mw.CreateInline()
+
+	var html mail.InlineHeader
+
+	html.Header.Set("Content-Transfer-Encoding", "base64")
+	w, _ := tw.CreatePart(html)
+
+	io.WriteString(w, "=")
+
+	w.Close()
+
+	tw.Close()
+
+	fmt.Printf("%s", b.String())
+
+}

+ 0 - 0
server/pop3_server/pop3server.go → server/listen/pop3_server/pop3server.go


+ 11 - 5
server/smtp_server/read_content.go → server/listen/smtp_server/read_content.go

@@ -9,6 +9,7 @@ import (
 	"github.com/Jinnrry/pmail/dto/parsemail"
 	"github.com/Jinnrry/pmail/hooks"
 	"github.com/Jinnrry/pmail/hooks/framework"
+	"github.com/Jinnrry/pmail/listen/imap_server"
 	"github.com/Jinnrry/pmail/models"
 	"github.com/Jinnrry/pmail/services/rule"
 	"github.com/Jinnrry/pmail/utils/array"
@@ -85,7 +86,7 @@ func (s *Session) Data(r io.Reader) error {
 		}
 
 		// 转发
-		_, err := saveEmail(ctx, len(emailData), email, s.Ctx.UserID, 1, nil, true, true)
+		_, _, err := saveEmail(ctx, len(emailData), email, s.Ctx.UserID, 1, nil, true, true)
 		if err != nil {
 			log.WithContext(ctx).Errorf("Email Save Error %v", err)
 		}
@@ -159,7 +160,7 @@ func (s *Session) Data(r io.Reader) error {
 			return nil
 		}
 
-		users, _ := saveEmail(ctx, len(emailData), email, 0, 0, s.To, SPFStatus, dkimStatus)
+		users, dbEmail, _ := saveEmail(ctx, len(emailData), email, 0, 0, s.To, SPFStatus, dkimStatus)
 
 		if email.MessageId > 0 {
 			log.WithContext(ctx).Debugf("开始执行邮件规则!")
@@ -192,12 +193,17 @@ func (s *Session) Data(r io.Reader) error {
 		as3.Wait()
 		log.WithContext(ctx).Debugf("开始执行插件ReceiveSaveAfter!End")
 
+		// IDLE命令通知
+		for _, user := range users {
+			imap_server.IdleNotice(ctx, user.ID, dbEmail)
+		}
+
 	}
 
 	return nil
 }
 
-func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserID int, emailType int, reallyTo []string, SPFStatus, dkimStatus bool) ([]*models.User, error) {
+func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserID int, emailType int, reallyTo []string, SPFStatus, dkimStatus bool) ([]*models.User, *models.Email, error) {
 	var dkimV, spfV int8
 	if dkimStatus {
 		dkimV = 1
@@ -209,7 +215,7 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
 	log.WithContext(ctx).Debugf("开始入库!")
 
 	if email == nil {
-		return nil, nil
+		return nil, nil, nil
 	}
 
 	modelEmail := models.Email{
@@ -305,7 +311,7 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
 		}
 	}
 
-	return users, nil
+	return users, &modelEmail, nil
 }
 
 func json2string(d any) string {

+ 2 - 2
server/smtp_server/read_content_test.go → 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 - 0
server/smtp_server/smtp.go → server/listen/smtp_server/smtp.go


+ 0 - 0
server/smtp_server/smtp_test/sendEmailTest.py → server/listen/smtp_server/smtp_test/sendEmailTest.py


+ 1 - 57
server/main.go

@@ -1,39 +1,12 @@
 package main
 
 import (
-	"bytes"
-	"fmt"
 	"github.com/Jinnrry/pmail/config"
-	"github.com/Jinnrry/pmail/cron_server"
+	"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"
 	}

+ 55 - 5
server/main_test.go

@@ -4,10 +4,10 @@ import (
 	"encoding/json"
 	"flag"
 	"fmt"
+	"github.com/Jinnrry/pmail/config"
 	"github.com/Jinnrry/pmail/db"
 	"github.com/Jinnrry/pmail/dto/response"
 	"github.com/Jinnrry/pmail/models"
-	"github.com/Jinnrry/pmail/services/setup"
 	"github.com/Jinnrry/pmail/signal"
 	"github.com/Jinnrry/pmail/utils/array"
 	"github.com/spf13/cast"
@@ -54,22 +54,25 @@ func TestMaster(t *testing.T) {
 	t.Run("testPwdSet", testPwdSet)
 	t.Run("testDomainSet", testDomainSet)
 	t.Run("testDNSSet", testDNSSet)
-	cfg, err := setup.ReadConfig()
+	cfg, err := config.ReadConfig()
 	if err != nil {
 		t.Fatal(err)
 	}
 	cfg.HttpsEnabled = 2
 	cfg.HttpPort = TestPort
-	err = setup.WriteConfig(cfg)
+	cfg.LogLevel = "debug"
+	err = config.WriteConfig(cfg)
 	if err != nil {
 		t.Fatal(err)
 	}
 	t.Run("testSSLSet", testSSLSet)
+	t.Logf("Stop 8 Second for wating restart")
 	time.Sleep(8 * time.Second)
 	t.Run("testLogin", testLogin)           // 登录管理员账号
 	t.Run("testCreateUser", testCreateUser) // 创建3个测试用户
 	t.Run("testEditUser", testEditUser)     // 编辑user2,封禁user3
 	t.Run("testSendEmail", testSendEmail)
+	t.Logf("Stop 8 Second for wating sending")
 	time.Sleep(8 * time.Second)
 	t.Run("testEmailList", testEmailList)
 	t.Run("testGetDetail", testGetEmailDetail)
@@ -99,6 +102,10 @@ func TestMaster(t *testing.T) {
 	t.Run("testMoverEmailSend", testSendEmail2User2ForSpam)
 	time.Sleep(3 * time.Second)
 
+	// 生成10封测试邮件
+	t.Run("genTestEmailData", genTestEmailData)
+	time.Sleep(3 * time.Second)
+
 	// 检查规则执行
 	t.Run("testCheckRule", testCheckRule)
 	time.Sleep(3 * time.Second)
@@ -287,8 +294,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) {
@@ -303,7 +312,9 @@ func testDataBaseSet(t *testing.T) {
 		t.Error(err)
 	}
 	if data.ErrorNo != 0 {
+		t.Errorf("Response %+v", data)
 		t.Error("Get Database Config Api Error!")
+		return
 	}
 
 	argList := flag.Args()
@@ -318,7 +329,7 @@ func testDataBaseSet(t *testing.T) {
 `
 	} else if array.InArray("postgres", argList) {
 		configData = `
-{"action":"set","step":"database","db_type":"postgres","db_dsn":"postgres://postgres:githubTest@127.0.0.1:5432/pmail?sslmode=disable"}
+{"action":"set","step":"database","db_type":"postgres","db_dsn":"postgres://postgres:githubTest@postgres:5432/pmail?sslmode=disable"}
 `
 	}
 
@@ -658,6 +669,45 @@ func testSendEmail2User2ForMove(t *testing.T) {
 
 }
 
+func genTestEmailData(t *testing.T) {
+	for i := 0; i < 10; i++ {
+		ret, err := httpClient.Post(TestHost+"/api/email/send", "application/json", strings.NewReader(fmt.Sprintf(
+			`
+		{
+    "from": {
+        "name": "user2",
+        "email": "user2@test.domain"
+    },
+    "to": [
+        {
+            "name": "admin",
+            "email": "admin@test.domain"
+        }
+    ],
+    "cc": [
+        
+    ],
+    "subject": "测试邮件%d",
+    "text": "测试邮件%d",
+    "html": "<div>测试邮件%d</div>"
+}
+
+`, i, i, i)))
+		if err != nil {
+			t.Error(err)
+		}
+		data, err := readResponse(ret.Body)
+		if err != nil {
+			t.Error(err)
+		}
+		if data.ErrorNo != 0 {
+			t.Error("Send Email Api Error!")
+		}
+		time.Sleep(3 * time.Second)
+	}
+
+}
+
 func testSendEmail2User1(t *testing.T) {
 	ret, err := httpClient.Post(TestHost+"/api/email/send", "application/json", strings.NewReader(`
 		{

+ 25 - 0
server/models/group.go

@@ -5,6 +5,31 @@ type Group struct {
 	Name     string `xorm:"varchar(10) notnull default('') comment('分组名称')" json:"name"`
 	ParentId int    `xorm:"parent_id int unsigned notnull default(0) comment('父分组名称')" json:"parent_id"`
 	UserId   int    `xorm:"user_id int unsigned notnull default(0) comment('用户id')" json:"-"`
+	FullPath string `xrom:"full_path varchar(600) comment('完整路径')" json:"full_path"`
+}
+
+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 {

+ 9 - 6
server/models/user_email.go

@@ -1,12 +1,15 @@
 package models
 
+import "time"
+
 type UserEmail struct {
-	ID      int  `xorm:"id int unsigned not null pk autoincr"`
-	UserID  int  `xorm:"user_id int not null index('idx_eid') index comment('用户id')"`
-	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删除
+	ID      int       `xorm:"id int unsigned not null pk autoincr"`
+	UserID  int       `xorm:"user_id int not null index('idx_eid') index comment('用户id')"`
+	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删除 4草稿箱(Drafts)  5骚扰邮件(Junk)
+	Created time.Time `xorm:"create datetime created index('idx_create_time')"`
 }
 
 func (p UserEmail) TableName() string {

+ 0 - 55
server/pop3_server/action_test.go

@@ -1,55 +0,0 @@
-package pop3_server
-
-//import (
-//	"bytes"
-//	"fmt"
-//	"github.com/Jinnrry/gopop"
-//	"github.com/Jinnrry/pmail/config"
-//	"github.com/Jinnrry/pmail/db"
-//	"github.com/Jinnrry/pmail/utils/context"
-//	"github.com/emersion/go-message/mail"
-//	"io"
-//	"testing"
-//)
-//
-//func Test_action_Retr(t *testing.T) {
-//	config.Init()
-//	db.Init("")
-//
-//	a := action{}
-//	session := &gopop.Session{
-//		Ctx: &context.Context{
-//			UserID: 1,
-//		},
-//	}
-//	got, got1, err := a.Retr(session, 301)
-//
-//	_, _, _ = got, got1, err
-//}
-//
-//func Test_email(t *testing.T) {
-//	var b bytes.Buffer
-//
-//	// Create our mail header
-//	var h mail.Header
-//
-//	// Create a new mail writer
-//	mw, _ := mail.CreateWriter(&b, h)
-//
-//	// Create a text part
-//	tw, _ := mw.CreateInline()
-//
-//	var html mail.InlineHeader
-//
-//	html.Header.Set("Content-Transfer-Encoding", "base64")
-//	w, _ := tw.CreatePart(html)
-//
-//	io.WriteString(w, "=")
-//
-//	w.Close()
-//
-//	tw.Close()
-//
-//	fmt.Printf("%s", b.String())
-//
-//}

+ 11 - 3
server/res_init/init.go

@@ -6,15 +6,17 @@ import (
 	"github.com/Jinnrry/pmail/db"
 	"github.com/Jinnrry/pmail/dto/parsemail"
 	"github.com/Jinnrry/pmail/hooks"
-	"github.com/Jinnrry/pmail/http_server"
-	"github.com/Jinnrry/pmail/pop3_server"
+	"github.com/Jinnrry/pmail/listen/http_server"
+	"github.com/Jinnrry/pmail/listen/imap_server"
+	"github.com/Jinnrry/pmail/listen/pop3_server"
+	"github.com/Jinnrry/pmail/listen/smtp_server"
 	"github.com/Jinnrry/pmail/services/setup/ssl"
 	"github.com/Jinnrry/pmail/session"
 	"github.com/Jinnrry/pmail/signal"
-	"github.com/Jinnrry/pmail/smtp_server"
 	"github.com/Jinnrry/pmail/utils/file"
 	log "github.com/sirupsen/logrus"
 	"os"
+	"time"
 )
 
 func Init(serverVersion string) {
@@ -47,6 +49,8 @@ func Init(serverVersion string) {
 		// pop3 server start
 		go pop3_server.Start()
 		go pop3_server.StartWithTls()
+		// imap server start
+		go imap_server.StarTLS()
 
 		configStr, _ := json.Marshal(config.Instance)
 		log.Warnf("Config File Info:  %s", configStr)
@@ -58,6 +62,7 @@ func Init(serverVersion string) {
 			http_server.HttpsStop()
 			http_server.HttpStop()
 			pop3_server.Stop()
+			imap_server.Stop()
 			hooks.Stop()
 		case <-signal.StopChan:
 			log.Infof("Server Stop!")
@@ -65,9 +70,12 @@ func Init(serverVersion string) {
 			http_server.HttpsStop()
 			http_server.HttpStop()
 			pop3_server.Stop()
+			imap_server.Stop()
 			hooks.Stop()
 			return
 		}
+		log.Infof("Server Stop Success!")
+		time.Sleep(5 * time.Second)
 
 	}
 

+ 42 - 2
server/services/del_email/del_email.go

@@ -5,11 +5,13 @@ import (
 	"github.com/Jinnrry/pmail/db"
 	"github.com/Jinnrry/pmail/models"
 	"github.com/Jinnrry/pmail/utils/context"
+	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cast"
 	"xorm.io/xorm"
 )
+import . "xorm.io/builder"
 
-func DelEmail(ctx *context.Context, ids []int64, forcedDel bool) error {
+func DelEmail(ctx *context.Context, ids []int, forcedDel bool) error {
 	session := db.Instance.NewSession()
 	defer session.Close()
 	if err := session.Begin(); err != nil {
@@ -31,7 +33,10 @@ type num struct {
 
 func deleteOne(ctx *context.Context, session *xorm.Session, id int64, forcedDel bool) error {
 	if !forcedDel {
-		_, err := session.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", id, ctx.UserID).Update(map[string]interface{}{"status": consts.EmailStatusDel})
+		_, err := session.Table(&models.UserEmail{}).Where("email_id=? and user_id=?", id, ctx.UserID).Update(map[string]interface{}{
+			"status":   consts.EmailStatusDel,
+			"group_id": 0,
+		})
 		return err
 	}
 	// 先删除关联关系
@@ -53,3 +58,38 @@ func deleteOne(ctx *context.Context, session *xorm.Session, id int64, forcedDel
 	}
 	return err
 }
+
+func DelByUID(ctx *context.Context, ids []int) error {
+	session := db.Instance.NewSession()
+	defer session.Close()
+	for _, id := range ids {
+		var ue models.UserEmail
+		session.Table("user_email").Where(Eq{"id": ids, "user_id": ctx.UserID}).Get(&ue)
+		if ue.ID == 0 {
+			log.WithContext(ctx).Warn("no user email found")
+			return nil
+		}
+		emailId := ue.EmailID
+
+		// 先删除关联关系
+		_, err := session.Table(&models.UserEmail{}).Where("id=? and user_id=?", id, ctx.UserID).Delete(&ue)
+		if err != nil {
+			session.Rollback()
+			return err
+		}
+
+		// 检查email是否还有人有权限
+		var Num num
+		_, err = session.Table(&models.UserEmail{}).Select("count(1) as num").Where("email_id=? ", emailId).Get(&Num)
+		if err != nil {
+			return err
+		}
+		if Num.Num == 0 {
+			var email models.Email
+			_, err = session.Table(&email).Where("id=?", id).Delete(&email)
+
+		}
+	}
+	session.Commit()
+	return nil
+}

+ 61 - 0
server/services/detail/detail.go

@@ -8,11 +8,14 @@ import (
 	"github.com/Jinnrry/pmail/dto/parsemail"
 	"github.com/Jinnrry/pmail/dto/response"
 	"github.com/Jinnrry/pmail/models"
+	"github.com/Jinnrry/pmail/services/list"
+	"github.com/Jinnrry/pmail/utils/array"
 	"github.com/Jinnrry/pmail/utils/context"
 	"github.com/Jinnrry/pmail/utils/errors"
 	log "github.com/sirupsen/logrus"
 	"strings"
 )
+import . "xorm.io/builder"
 
 func GetEmailDetail(ctx *context.Context, id int, markRead bool) (*response.EmailResponseData, error) {
 	// 先查是否是本人的邮件
@@ -56,3 +59,61 @@ func GetEmailDetail(ctx *context.Context, id int, markRead bool) (*response.Emai
 
 	return &email, nil
 }
+
+func MakeRead(ctx *context.Context, emailId int, hadRead bool) {
+	ue := models.UserEmail{
+		UserID:  ctx.UserID,
+		IsRead:  1,
+		EmailID: emailId,
+	}
+	if !hadRead {
+		ue.IsRead = 0
+	}
+
+	db.Instance.Where("email_id = ? and user_id=?", emailId, ctx.UserID).Cols("is_read").Update(&ue)
+}
+
+func FindUE(ctx *context.Context, groupName string, req list.ImapListReq, uid bool) []models.UserEmail {
+	var ue []models.UserEmail
+	if uid {
+		err := db.Instance.Where(Eq{"id": req.UidList}).Find(&ue)
+		if err != nil {
+			log.WithContext(ctx).Errorf("SQL error:%+v", err)
+		}
+		return ue
+	} else {
+		sql := fmt.Sprintf("SELECT id,email_id, is_read from (SELECT id,email_id, is_read, ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM `user_email` WHERE (user_id = ? and status = ?)) a WHERE serial_number in (%s)",
+			array.Join(req.UidList, ","),
+		)
+		switch groupName {
+		case "INBOX":
+			db.Instance.SQL(sql, ctx.UserID, 0).Find(&ue)
+		case "Sent Messages":
+			db.Instance.SQL(sql, ctx.UserID, 1).Find(&ue)
+		case "Drafts":
+			db.Instance.SQL(sql, ctx.UserID, 4).Find(&ue)
+		case "Deleted Messages":
+			db.Instance.SQL(sql, ctx.UserID, 3).Find(&ue)
+		case "Junk":
+			db.Instance.SQL(sql, ctx.UserID, 5).Find(&ue)
+		default:
+			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 {
+				return nil
+			}
+			db.Instance.
+				SQL(fmt.Sprintf(
+					"SELECT * from (SELECT id,email_id, is_read, ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM `user_email` WHERE (user_id = ? and group_id = ?)) a WHERE serial_number in (%s)",
+					array.Join(req.UidList, ","),
+				)).
+				Find(&ue, ctx.UserID, group.ID)
+		}
+
+		return ue
+
+	}
+}

+ 232 - 2
server/services/group/group.go

@@ -1,14 +1,19 @@
 package group
 
 import (
+	errors2 "errors"
 	"fmt"
+	"github.com/Jinnrry/pmail/consts"
 	"github.com/Jinnrry/pmail/db"
 	"github.com/Jinnrry/pmail/dto"
 	"github.com/Jinnrry/pmail/models"
+	"github.com/Jinnrry/pmail/services/del_email"
 	"github.com/Jinnrry/pmail/utils/array"
 	"github.com/Jinnrry/pmail/utils/context"
 	"github.com/Jinnrry/pmail/utils/errors"
 	log "github.com/sirupsen/logrus"
+	"strings"
+	"xorm.io/builder"
 )
 
 type GroupItem struct {
@@ -18,6 +23,50 @@ type GroupItem struct {
 	Children []*GroupItem `json:"children"`
 }
 
+func CreateGroup(ctx *context.Context, name string, parentId int) (*models.Group, error) {
+	// 先查询是否存在
+	var group models.Group
+	db.Instance.Table("group").Where("name = ? and user_id = ?", name, ctx.UserID).Get(&group)
+	if group.ID > 0 {
+		return &group, nil
+	}
+	group.Name = name
+	group.ParentId = parentId
+	group.UserId = ctx.UserID
+	group.FullPath = getLayerName(ctx, &group, true)
+
+	_, err := db.Instance.Insert(&group)
+	return &group, err
+}
+
+func Rename(ctx *context.Context, oldName, newName string) error {
+	oldGroupInfo, err := GetGroupByName(ctx, oldName)
+	if err != nil {
+		return err
+	}
+	if oldGroupInfo == nil || oldGroupInfo.ID == 0 {
+		return errors2.New("group not found")
+	}
+	oldGroupInfo.Name = newName
+	oldGroupInfo.FullPath = getLayerName(ctx, oldGroupInfo, true)
+	_, err = db.Instance.ID(oldGroupInfo.ID).Update(oldGroupInfo)
+	return err
+}
+
+func GetGroupByName(ctx *context.Context, name string) (*models.Group, error) {
+	var group models.Group
+	db.Instance.Table("group").Where("name = ? and user_id = ?", name, ctx.UserID).Get(&group)
+
+	return &group, nil
+}
+
+func GetGroupByFullPath(ctx *context.Context, fullPath string) (*models.Group, error) {
+	var group models.Group
+	_, err := db.Instance.Table("group").Where("full_path = ? and user_id = ?", fullPath, ctx.UserID).Get(&group)
+
+	return &group, err
+}
+
 func DelGroup(ctx *context.Context, groupId int) (bool, error) {
 	allGroupIds := getAllChildId(ctx, groupId)
 	allGroupIds = append(allGroupIds, groupId)
@@ -36,7 +85,7 @@ func DelGroup(ctx *context.Context, groupId int) (bool, error) {
 		return false, errors.Wrap(err)
 	}
 
-	_, err = trans.Exec(db.WithContext(ctx, fmt.Sprintf("update email set group_id=0 where group_id in (%s)", array.Join(allGroupIds, ","))))
+	_, err = trans.Exec(db.WithContext(ctx, fmt.Sprintf("update user_email set group_id=0 where group_id in (%s)", array.Join(allGroupIds, ","))))
 	if err != nil {
 		trans.Rollback()
 		return false, errors.Wrap(err)
@@ -72,7 +121,9 @@ func GetGroupInfoList(ctx *context.Context) []*GroupItem {
 
 // MoveMailToGroup 将某封邮件移动到某个分组中
 func MoveMailToGroup(ctx *context.Context, mailId []int, groupId int) bool {
-	res, err := db.Instance.Exec(db.WithContext(ctx, fmt.Sprintf("update email set group_id=? where id in (%s)", array.Join(mailId, ","))), groupId)
+	res, err := db.Instance.Exec(db.WithContext(ctx,
+		fmt.Sprintf("update user_email set group_id=? where email_id in (%s) and user_id =?", array.Join(mailId, ","))),
+		groupId, ctx.UserID)
 	if err != nil {
 		log.WithContext(ctx).Errorf("SQL Error:%+v", err)
 		return false
@@ -113,3 +164,182 @@ 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, allPath bool) string {
+	if item.ParentId == 0 {
+		return item.Name
+	}
+	var parent models.Group
+	_, _ = db.Instance.Table("group").Where("id=?", item.ParentId).Get(&parent)
+	if allPath {
+		return getLayerName(ctx, &parent, allPath) + "/" + item.Name
+	}
+	return getLayerName(ctx, &parent, allPath)
+}
+
+func IsDefaultBox(box string) bool {
+	return array.InArray(box, []string{"INBOX", "Sent Messages", "Drafts", "Deleted Messages", "Junk"})
+}
+
+func GetGroupStatus(ctx *context.Context, groupName string, params []string) (string, map[string]int) {
+	retMap := map[string]int{}
+
+	if !IsDefaultBox(groupName) {
+		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)
+		default:
+			continue
+		}
+		retMap[param] = value
+		ret += fmt.Sprintf("%s %d", param, value)
+	}
+	if ret == "" {
+		return "", retMap
+	}
+
+	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":
+		if mustUnread {
+			db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and status=4 and is_read=0", ctx.UserID).Get(&count)
+		} else {
+			db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and status=4", ctx.UserID).Get(&count)
+		}
+	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":
+		if mustUnread {
+			db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and status=5 and is_read=0", ctx.UserID).Get(&count)
+		} else {
+			db.Instance.Table("user_email").Select("count(1)").Where("user_id=? and status=5", ctx.UserID).Get(&count)
+		}
+	}
+	return count
+}
+
+func Move2DefaultBox(ctx *context.Context, mailIds []int, groupName string) error {
+	switch groupName {
+	case "Deleted Messages":
+		err := del_email.DelEmail(ctx, mailIds, false)
+		if err != nil {
+			return err
+		}
+	case "INBOX":
+		_, err := db.Instance.Table(&models.UserEmail{}).Where(builder.Eq{
+			"user_id":  ctx.UserID,
+			"email_id": mailIds,
+		}).Update(map[string]interface{}{
+			"status":   consts.EmailTypeReceive,
+			"group_id": 0,
+		})
+		return err
+	case "Sent Messages":
+		_, err := db.Instance.Table(&models.UserEmail{}).Where(builder.Eq{
+			"user_id":  ctx.UserID,
+			"email_id": mailIds,
+		}).Update(map[string]interface{}{
+			"status":   consts.EmailStatusSent,
+			"group_id": 0,
+		})
+		return err
+	case "Drafts":
+		_, err := db.Instance.Table(&models.UserEmail{}).Where(builder.Eq{
+			"user_id":  ctx.UserID,
+			"email_id": mailIds,
+		}).Update(map[string]interface{}{
+			"status":   consts.EmailStatusDrafts,
+			"group_id": 0,
+		})
+		return err
+	case "Junk":
+		_, err := db.Instance.Table(&models.UserEmail{}).Where(builder.Eq{
+			"user_id":  ctx.UserID,
+			"email_id": mailIds,
+		}).Update(map[string]interface{}{
+			"status":   consts.EmailStatusJunk,
+			"group_id": 0,
+		})
+		return err
+	}
+	return nil
+}

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

+ 159 - 1
server/services/list/list.go

@@ -5,9 +5,13 @@ import (
 	"github.com/Jinnrry/pmail/db"
 	"github.com/Jinnrry/pmail/dto"
 	"github.com/Jinnrry/pmail/dto/response"
+	"github.com/Jinnrry/pmail/models"
+	"github.com/Jinnrry/pmail/utils/array"
 	"github.com/Jinnrry/pmail/utils/context"
 	log "github.com/sirupsen/logrus"
+	"strings"
 )
+import . "xorm.io/builder"
 
 func GetEmailList(ctx *context.Context, tagInfo dto.SearchTag, keyword string, pop3List bool, offset, limit int) (emailList []*response.EmailResponseData, total int64) {
 	return getList(ctx, tagInfo, keyword, pop3List, offset, limit)
@@ -46,7 +50,7 @@ func genSQL(ctx *context.Context, count bool, tagInfo dto.SearchTag, keyword str
 	if tagInfo.Status != -1 {
 		sql += " and ue.status =? "
 		sqlParams = append(sqlParams, tagInfo.Status)
-	} else {
+	} else if tagInfo.GroupId == -1 {
 		sql += " and ue.status != 3"
 	}
 
@@ -94,3 +98,157 @@ func Stat(ctx *context.Context) (int64, int64) {
 	}
 	return ret.Total, ret.Size
 }
+
+type ImapListReq struct {
+	UidList []int
+	Star    int
+	End     int
+}
+
+func GetUEListByUID(ctx *context.Context, groupName string, star, end int, uidList []int) []*response.UserEmailUIDData {
+	var ue []*response.UserEmailUIDData
+	sql := "SELECT id,email_id, is_read, ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM `user_email` WHERE user_id = ? "
+
+	params := []any{ctx.UserID}
+
+	if len(uidList) > 0 {
+		sql += fmt.Sprintf(" and id in (%s)", array.Join(uidList, ","))
+	}
+	if star > 0 {
+		sql += " and id >=?"
+		params = append(params, star)
+	}
+	if end > 0 {
+		sql += " and id <=?"
+		params = append(params, end)
+	}
+
+	switch groupName {
+	case "INBOX":
+		sql += " and status =?"
+		params = append(params, 0)
+	case "Sent Messages":
+		sql += " and status =?"
+		params = append(params, 1)
+	case "Drafts":
+		sql += " and status =?"
+		params = append(params, 4)
+	case "Deleted Messages":
+		sql += " and status =?"
+		params = append(params, 3)
+	case "Junk":
+		sql += " and status =?"
+		params = append(params, 5)
+	default:
+		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 {
+			return nil
+		}
+
+		sql += " and group_id = ?"
+		params = append(params, group.ID)
+	}
+
+	db.Instance.SQL(sql, params...).Find(&ue)
+	return ue
+}
+
+func getEmailListByUidList(ctx *context.Context, groupName string, req ImapListReq, uid bool) []*response.EmailResponseData {
+	var ret []*response.EmailResponseData
+	var ue []*response.UserEmailUIDData
+	sql := fmt.Sprintf("SELECT id,email_id, is_read, ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM `user_email` WHERE (user_id = ? and id in (%s))", array.Join(req.UidList, ","))
+	if req.Star > 0 && req.End != 0 {
+		sql = fmt.Sprintf("SELECT id,email_id, is_read, ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM `user_email` WHERE (user_id = ? and id >=%d and id <= %d)", req.Star, req.End)
+	}
+	if req.Star > 0 && req.End == 0 {
+		sql = fmt.Sprintf("SELECT id,email_id, is_read, ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM `user_email` WHERE (user_id = ? and id >=%d )", req.Star)
+	}
+
+	err := db.Instance.SQL(sql, ctx.UserID).Find(&ue)
+	if err != nil {
+		log.WithContext(ctx).Errorf("SQL ERROR: %s ,Error:%s", sql, err)
+	}
+	ueMap := map[int]*response.UserEmailUIDData{}
+	var emailIds []int
+	for _, email := range ue {
+		ueMap[email.EmailID] = email
+		emailIds = append(emailIds, email.EmailID)
+	}
+
+	_ = db.Instance.Table("email").Select("*").Where(Eq{"id": emailIds}).Find(&ret)
+	for i, data := range ret {
+		ret[i].IsRead = ueMap[data.Id].IsRead
+		ret[i].SerialNumber = ueMap[data.Id].SerialNumber
+		ret[i].UeId = ueMap[data.Id].ID
+	}
+
+	return ret
+}
+
+func GetEmailListByGroup(ctx *context.Context, groupName string, req ImapListReq, uid bool) []*response.EmailResponseData {
+	if len(req.UidList) == 0 && req.Star == 0 && req.End == 0 {
+		return nil
+	}
+
+	if uid {
+		return getEmailListByUidList(ctx, groupName, req, uid)
+	}
+
+	var ret []*response.EmailResponseData
+	var ue []*response.UserEmailUIDData
+
+	sql := fmt.Sprintf("SELECT * from (SELECT id,email_id, is_read, ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM `user_email` WHERE (user_id = ? and status = ? and group_id=0 )) a WHERE serial_number in (%s)", array.Join(req.UidList, ","))
+	if req.Star > 0 && req.End == 0 {
+		sql = fmt.Sprintf("SELECT * from (SELECT id,email_id, is_read, ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM `user_email` WHERE (user_id = ? and status = ? and group_id=0 )) a WHERE serial_number >= %d", req.Star)
+	}
+	if req.Star > 0 && req.End > 0 {
+		sql = fmt.Sprintf("SELECT * from (SELECT id,email_id, is_read, ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM `user_email` WHERE (user_id = ? and status = ? and group_id=0 )) a WHERE serial_number >= %d and serial_number <=%d", req.Star, req.End)
+	}
+
+	switch groupName {
+	case "INBOX":
+		db.Instance.SQL(sql, ctx.UserID, 0).Find(&ue)
+	case "Sent Messages":
+		db.Instance.SQL(sql, ctx.UserID, 1).Find(&ue)
+	case "Drafts":
+		db.Instance.SQL(sql, ctx.UserID, 4).Find(&ue)
+	case "Deleted Messages":
+		db.Instance.SQL(sql, ctx.UserID, 3).Find(&ue)
+	case "Junk":
+		db.Instance.SQL(sql, ctx.UserID, 5).Find(&ue)
+	default:
+		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 {
+			return ret
+		}
+		db.Instance.
+			SQL(fmt.Sprintf(
+				"SELECT * from (SELECT id,email_id, is_read, ROW_NUMBER() OVER (ORDER BY id) AS serial_number FROM `user_email` WHERE (user_id = ? and group_id = ?)) a WHERE serial_number in (%s)",
+				array.Join(req.UidList, ","))).
+			Find(&ue, ctx.UserID, group.ID)
+	}
+
+	ueMap := map[int]*response.UserEmailUIDData{}
+	var emailIds []int
+	for _, email := range ue {
+		ueMap[email.EmailID] = email
+		emailIds = append(emailIds, email.EmailID)
+	}
+
+	_ = db.Instance.Table("email").Select("*").Where(Eq{"id": emailIds}).Find(&ret)
+	for i, data := range ret {
+		ret[i].IsRead = ueMap[data.Id].IsRead
+		ret[i].SerialNumber = ueMap[data.Id].SerialNumber
+		ret[i].UeId = ueMap[data.Id].ID
+	}
+
+	return 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)
+}

+ 59 - 0
server/utils/goimap/action.go

@@ -0,0 +1,59 @@
+package goimap
+
+type Action interface {
+	Create(session *Session, path string) CommandResponse             // 创建邮箱
+	Delete(session *Session, path string) CommandResponse             // 删除邮箱
+	Rename(session *Session, oldPath, newPath string) CommandResponse // 重命名邮箱
+	List(session *Session, basePath, template string) CommandResponse // 浏览邮箱
+	Append(session *Session, item string) CommandResponse             // 上传邮件
+	Select(session *Session, path string) CommandResponse             // 选择邮箱
+	/*
+		读取邮件的文本信息,且仅用于显示的目的。
+			ALL:只返回按照一定格式的邮件摘要,包括邮件标志、RFC822.SIZE、自身的时间和信封信息。IMAP客户机能够将标准邮件解析成这些信息并显示出来。
+			BODY:只返回邮件体文本格式和大小的摘要信息。IMAP客户机可以识别这些细腻,并向用户显示详细的关于邮件的信息。其实是一些非扩展的BODYSTRUCTURE的信息。
+			FAST:只返回邮件的一些摘要,包括邮件标志、RFC822.SIZE、和自身的时间。
+			FULL:同样的还是一些摘要信息,包括邮件标志、RFC822.SIZE、自身的时间和BODYSTRUCTURE的信息。
+			BODYSTRUCTUR:是邮件的[MIME-IMB]的体结构。这是服务器通过解析[RFC-2822]头中的[MIME-IMB]各字段和[MIME-IMB]头信息得出来的。包括的内容有:邮件正文的类型、字符集、编码方式等和各附件的类型、字符集、编码方式、文件名称等等。
+			ENVELOPE:信息的信封结构。是服务器通过解析[RFC-2822]头中的[MIME-IMB]各字段得出来的,默认各字段都是需要的。主要包括:自身的时间、附件数、收件人、发件人等。
+			FLAGS:此邮件的标志。
+			INTERNALDATE:自身的时间。
+			RFC822.SIZE:邮件的[RFC-2822]大小
+			RFC822.HEADER:在功能上等同于BODY.PEEK[HEADER],
+			RFC822:功能上等同于BODY[]。
+			RFC822.TEXT:功能上等同于BODY[TEXT]
+			UID:返回邮件的UID号,UID号是唯一标识邮件的一个号码。
+			BODY[section] <<partial>>:返回邮件的中的某一指定部分,返回的部分用section来表示,section部分包含的信息通常是代表某一部分的一个数字或者是下面的某一个部分:HEADER, HEADER.FIELDS, HEADER.FIELDS.NOT, MIME, and TEXT。如果section部分是空的话,那就代表返回全部的信息,包括头信息。
+			BODY[HEADER]返回完整的文件头信息。
+			BODY[HEADER.FIELDS ()]:在小括号里面可以指定返回的特定字段。
+			BODY[HEADER.FIELDS.NOT ()]:在小括号里面可以指定不需要返回的特定字段。
+			BODY[MIME]:返回邮件的[MIME-IMB]的头信息,在正常情况下跟BODY[HEADER]没有区别。
+			BODY[TEXT]:返回整个邮件体,这里的邮件体并不包括邮件头。
+			**/
+	Fetch(session *Session, mailIds, dataNames string, uid bool) CommandResponse
+	Store(session *Session, mailId, flags string, uid bool) CommandResponse // STORE 命令用于修改指定邮件的属性,包括给邮件打上已读标记、删除标记
+	Close(session *Session) CommandResponse                                 // 关闭文件夹
+	Expunge(session *Session) CommandResponse                               // 删除已经标记为删除的邮件,释放服务器上的存储空间
+	Examine(session *Session, path string) CommandResponse                  // 只读方式打开邮箱
+	Subscribe(session *Session, path string) CommandResponse                // 活动邮箱列表中增加一个邮箱
+	UnSubscribe(session *Session, path string) CommandResponse              // 活动邮箱列表中去掉一个邮箱
+	LSub(session *Session, path, mailbox string) CommandResponse            // 显示那些使用SUBSCRIBE命令设置为活动邮箱的文件
+	/*
+		@category:
+			MESSAGES	邮箱中的邮件总数
+			RECENT	邮箱中标志为\RECENT的邮件数
+			UIDNEXT	可以分配给新邮件的下一个UID
+			UIDVALIDITY	邮箱的UID有效性标志
+			UNSEEN	邮箱中没有被标志为\UNSEEN的邮件数
+	*/
+	Status(session *Session, mailbox string, category []string) CommandResponse  // 查询邮箱的当前状态
+	Check(session *Session) CommandResponse                                      // sync数据
+	Search(session *Session, keyword, criteria string, uid bool) CommandResponse // 命令可以根据搜索条件在处于活动状态的邮箱中搜索邮件,然后显示匹配的邮件编号
+	Copy(session *Session, mailId, mailBoxName string) CommandResponse           // 把邮件从一个邮箱复制到另一个邮箱
+	CapaBility(session *Session) CommandResponse                                 // 返回IMAP服务器支持的功能列表
+	Noop(session *Session) CommandResponse                                       // 什么都不做,连接保活
+	Login(session *Session, username, password string) CommandResponse           // 登录
+	Logout(session *Session) CommandResponse                                     // 注销登录
+	IDLE(session *Session) CommandResponse                                       // 进入IDLE状态
+	Unselect(session *Session) CommandResponse                                   // 取消邮箱选择
+	Custom(session *Session, cmd string, args string) CommandResponse
+}

+ 15 - 0
server/utils/goimap/dto.go

@@ -0,0 +1,15 @@
+package goimap
+
+type CommandResponseType uint8
+
+const (
+	SUCCESS CommandResponseType = 0
+	BAD     CommandResponseType = 1
+	NO      CommandResponseType = 2
+)
+
+type CommandResponse struct {
+	Type    CommandResponseType
+	Message string
+	Data    []string
+}

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

@@ -0,0 +1,830 @@
+package goimap
+
+import (
+	"bufio"
+	"crypto/tls"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"github.com/Jinnrry/pmail/utils/context"
+	"github.com/Jinnrry/pmail/utils/id"
+	log "github.com/sirupsen/logrus"
+	"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) authenticate(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if args == "LOGIN" {
+		write(session, "+ VXNlciBOYW1lAA=="+eol, "")
+		line, err2 := reader.ReadString('\n')
+		if err2 != nil {
+			if conn != nil {
+				_ = conn.Close()
+			}
+			session.Conn = nil
+			session.IN_IDLE = false
+			return
+		}
+		account, err := base64.StdEncoding.DecodeString(line)
+		if err != nil {
+			showBad(session, "Data Error.", nub)
+			return
+		}
+		write(session, "+ UGFzc3dvcmQA"+eol, "")
+		line, err = reader.ReadString('\n')
+		if err2 != nil {
+			if conn != nil {
+				_ = conn.Close()
+			}
+			session.Conn = nil
+			session.IN_IDLE = false
+			return
+		}
+		password, err := base64.StdEncoding.DecodeString(line)
+		res := s.Action.Login(session, string(account), string(password))
+		if res.Type == SUCCESS {
+			showSucc(session, res.Message, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	} else {
+		showBad(session, "Unsupported AUTHENTICATE mechanism.", nub)
+	}
+}
+
+func (s *Server) capability(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	res := s.Action.CapaBility(session)
+	if res.Type == BAD {
+		write(session, fmt.Sprintf("* BAD %s%s", res.Message, eol), nub)
+	} else {
+		ret := "*"
+		for _, command := range res.Data {
+			ret += " " + command
+		}
+		ret += eol
+		write(session, ret, nub)
+		showSucc(session, res.Message, nub)
+	}
+}
+
+func (s *Server) create(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "CREATE", nub)
+		return
+	}
+	res := s.Action.Create(session, args)
+	showSucc(session, res.Message, nub)
+}
+
+func (s *Server) delete(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "DELETE", nub)
+		return
+	}
+	res := s.Action.Delete(session, args)
+	if res.Type == SUCCESS {
+		showSucc(session, res.Message, nub)
+	} else if res.Type == BAD {
+		showBad(session, res.Message, nub)
+	} else {
+		showNo(session, res.Message, nub)
+	}
+}
+
+func (s *Server) rename(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "RENAME", nub)
+	} else {
+		dt := strings.Split(args, " ")
+		res := s.Action.Rename(session, dt[0], dt[1])
+		if res.Type == SUCCESS {
+			showSucc(session, res.Message, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	}
+}
+
+func (s *Server) list(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "LIST", nub)
+	} else {
+		dt := strings.Split(args, " ")
+		dt[0] = strings.Trim(dt[0], `"`)
+		dt[1] = strings.Trim(dt[1], `"`)
+		res := s.Action.List(session, dt[0], dt[1])
+		if res.Type == SUCCESS {
+			showSuccWithData(session, res.Data, res.Message, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	}
+}
+
+func (s *Server) append(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	log.WithContext(session.Ctx).Debugf("Append: %+v", args)
+}
+
+func (s *Server) cselect(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	res := s.Action.Select(session, args)
+	if res.Type == SUCCESS {
+		showSuccWithData(session, res.Data, res.Message, nub)
+	} else if res.Type == BAD {
+		showBad(session, res.Message, nub)
+	} else {
+		showNo(session, res.Message, nub)
+	}
+}
+
+func (s *Server) fetch(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader, uid bool) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "FETCH", nub)
+	} else {
+		dt := strings.SplitN(args, " ", 2)
+		if len(dt) != 2 {
+			showBad(session, "Error Params", nub)
+			return
+		}
+
+		res := s.Action.Fetch(session, dt[0], dt[1], uid)
+		if res.Type == SUCCESS {
+			showSuccWithData(session, res.Data, res.Message, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	}
+}
+
+func (s *Server) store(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader, uid bool) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "RENAME", nub)
+	} else {
+		dt := strings.SplitN(args, " ", 2)
+		res := s.Action.Store(session, dt[0], dt[1], uid)
+		if res.Type == SUCCESS {
+			showSucc(session, res.Message, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	}
+}
+
+func (s *Server) cclose(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	res := s.Action.Close(session)
+	if res.Type == SUCCESS {
+		showSucc(session, res.Message, nub)
+	} else if res.Type == BAD {
+		showBad(session, res.Message, nub)
+	} else {
+		showNo(session, res.Message, nub)
+	}
+}
+
+func (s *Server) expunge(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	res := s.Action.Expunge(session)
+	if res.Type == SUCCESS {
+		showSucc(session, res.Message, nub)
+	} else if res.Type == BAD {
+		showBad(session, res.Message, nub)
+	} else {
+		showNo(session, res.Message, nub)
+	}
+}
+
+func (s *Server) examine(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "EXAMINE", nub)
+	}
+	res := s.Action.Examine(session, args)
+	if res.Type == SUCCESS {
+		showSucc(session, res.Message, nub)
+	} else if res.Type == BAD {
+		showBad(session, res.Message, nub)
+	} else {
+		showNo(session, res.Message, nub)
+	}
+}
+
+func (s *Server) unsubscribe(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "UNSUBSCRIBE", nub)
+	} else {
+		res := s.Action.UnSubscribe(session, args)
+		if res.Type == SUCCESS {
+			showSucc(session, res.Message, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	}
+}
+
+func (s *Server) lsub(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "LSUB", nub)
+	} else {
+		dt := strings.Split(args, " ")
+		dt[0] = strings.Trim(dt[0], `"`)
+		res := s.Action.LSub(session, dt[0], dt[1])
+		if res.Type == SUCCESS {
+			showSuccWithData(session, res.Data, res.Message, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	}
+}
+
+func (s *Server) status(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "STATUS", nub)
+	} else {
+		var mailBox string
+		var params []string
+		if strings.HasPrefix(args, `"`) {
+			dt := strings.Split(args, `"`)
+			if len(dt) >= 3 {
+				mailBox = dt[1]
+			}
+			dt[2] = strings.Trim(dt[2], "() ")
+			params = strings.Split(dt[2], " ")
+		} else {
+			dt := strings.SplitN(args, " ", 2)
+			dt[0] = strings.ReplaceAll(dt[0], `"`, "")
+			dt[1] = strings.Trim(dt[1], "()")
+			mailBox = dt[0]
+			params = strings.Split(dt[1], " ")
+		}
+
+		res := s.Action.Status(session, mailBox, params)
+		if res.Type == SUCCESS {
+			showSuccWithData(session, res.Data, res.Message, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	}
+}
+
+func (s *Server) check(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	res := s.Action.Check(session)
+	if res.Type == SUCCESS {
+		showSucc(session, res.Message, nub)
+	} else if res.Type == BAD {
+		showBad(session, res.Message, nub)
+	} else {
+		showNo(session, res.Message, nub)
+	}
+}
+
+func (s *Server) search(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader, uid bool) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "SEARCH", nub)
+	} else {
+		var res CommandResponse
+		if args == "ALL" {
+			res = s.Action.Search(session, "", "UID 1:*", uid)
+		} else {
+			res = s.Action.Search(session, "", args, uid)
+		}
+
+		if res.Type == SUCCESS {
+			content := "* SEARCH"
+			for _, datum := range res.Data {
+				content += " " + datum
+			}
+			content += eol
+			content += fmt.Sprintf("%s OK SEARCH completed (Success)%s", nub, eol)
+			write(session, content, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	}
+}
+
+func (s *Server) copy(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "COPY", nub)
+	} else {
+		dt := strings.SplitN(args, " ", 2)
+		res := s.Action.Copy(session, dt[0], dt[1])
+		if res.Type == SUCCESS {
+			showSucc(session, res.Message, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	}
+}
+
+func (s *Server) noop(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	res := s.Action.Noop(session)
+	if res.Type == SUCCESS {
+		showSucc(session, res.Message, nub)
+	} else if res.Type == BAD {
+		showBad(session, res.Message, nub)
+	} else {
+		showNo(session, res.Message, nub)
+	}
+}
+
+func (s *Server) login(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if args == "" {
+		paramsErr(session, "LOGIN", nub)
+	} else {
+		dt := strings.SplitN(args, " ", 2)
+		res := s.Action.Login(session, strings.Trim(dt[0], `"`), strings.Trim(dt[1], `"`))
+		if res.Type == SUCCESS {
+			showSucc(session, res.Message, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	}
+}
+
+func (s *Server) logout(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	res := s.Action.Logout(session)
+	write(session, "* BYE PMail Server logging out"+eol, nub)
+	if res.Type == SUCCESS {
+		showSucc(session, res.Message, nub)
+	} else if res.Type == BAD {
+		showBad(session, res.Message, nub)
+	} else {
+		showNo(session, res.Message, nub)
+	}
+	if conn != nil {
+		_ = conn.Close()
+	}
+}
+
+func (s *Server) unselect(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	res := s.Action.Unselect(session)
+	if res.Type == SUCCESS {
+		showSucc(session, res.Message, nub)
+	} else if res.Type == BAD {
+		showBad(session, res.Message, nub)
+	} else {
+		showNo(session, res.Message, nub)
+	}
+}
+
+func (s *Server) subscribe(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	if args == "" {
+		paramsErr(session, "SUBSCRIBE", nub)
+	} else {
+		res := s.Action.Subscribe(session, args)
+		if res.Type == SUCCESS {
+			showSucc(session, res.Message, nub)
+		} else if res.Type == BAD {
+			showBad(session, res.Message, nub)
+		} else {
+			showNo(session, res.Message, nub)
+		}
+	}
+}
+
+func (s *Server) idle(session *Session, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	if session.Status != AUTHORIZED {
+		showBad(session, "Need Login", nub)
+		return
+	}
+	session.IN_IDLE = true
+	res := s.Action.IDLE(session)
+	if res.Type == SUCCESS {
+		write(session, "+ idling"+eol, nub)
+	} else if res.Type == BAD {
+		showBad(session, res.Message, nub)
+	} else {
+		showNo(session, res.Message, nub)
+	}
+}
+
+func (s *Server) custom(session *Session, cmd string, args string, nub string, conn net.Conn, reader *bufio.Reader) {
+	res := s.Action.Custom(session, cmd, args)
+	if res.Type == BAD {
+		write(session, fmt.Sprintf("* BAD %s %s", res.Message, eol), nub)
+	} else if res.Type == NO {
+		showNo(session, res.Message, nub)
+	} else {
+		if len(res.Data) == 0 {
+			showSucc(session, res.Message, nub)
+		} else {
+			ret := ""
+			for _, re := range res.Data {
+				ret += fmt.Sprintf("%s%s", re, eol)
+			}
+			ret += "." + eol
+			write(session, fmt.Sprintf(ret), nub)
+		}
+	}
+}
+
+func (s *Server) doCommand(session *Session, rawLine string, conn net.Conn, reader *bufio.Reader) {
+	nub, cmd, args := getCommand(rawLine)
+	log.WithContext(session.Ctx).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 "AUTHENTICATE":
+		s.authenticate(session, args, nub, conn, reader)
+	case "CAPABILITY":
+		s.capability(session, rawLine, nub, conn, reader)
+	case "CREATE":
+		s.create(session, args, nub, conn, reader)
+	case "DELETE":
+		s.delete(session, args, nub, conn, reader)
+	case "RENAME":
+		s.rename(session, args, nub, conn, reader)
+	case "LIST":
+		s.list(session, args, nub, conn, reader)
+	case "APPEND":
+		s.append(session, args, nub, conn, reader)
+	case "SELECT":
+		s.cselect(session, args, nub, conn, reader)
+	case "FETCH":
+		s.fetch(session, args, nub, conn, reader, false)
+	case "UID FETCH":
+		s.fetch(session, args, nub, conn, reader, true)
+	case "STORE":
+		s.store(session, args, nub, conn, reader, false)
+	case "UID STORE":
+		s.store(session, args, nub, conn, reader, true)
+	case "CLOSE":
+		s.cclose(session, args, nub, conn, reader)
+	case "EXPUNGE":
+		s.expunge(session, args, nub, conn, reader)
+	case "EXAMINE":
+		s.examine(session, args, nub, conn, reader)
+	case "SUBSCRIBE":
+		s.subscribe(session, args, nub, conn, reader)
+	case "UNSUBSCRIBE":
+		s.unsubscribe(session, args, nub, conn, reader)
+	case "LSUB":
+		s.lsub(session, args, nub, conn, reader)
+	case "STATUS":
+		s.status(session, args, nub, conn, reader)
+	case "CHECK":
+		s.check(session, args, nub, conn, reader)
+	case "SEARCH":
+		s.search(session, args, nub, conn, reader, false)
+	case "UID SEARCH":
+		s.search(session, args, nub, conn, reader, true)
+	case "COPY":
+		s.copy(session, args, nub, conn, reader)
+	case "NOOP":
+		s.noop(session, args, nub, conn, reader)
+	case "LOGIN":
+		s.login(session, args, nub, conn, reader)
+	case "LOGOUT":
+		s.logout(session, args, nub, conn, reader)
+	case "UNSELECT":
+		s.unselect(session, args, nub, conn, reader)
+	case "IDLE":
+		s.idle(session, args, nub, conn, reader)
+	default:
+		s.custom(session, cmd, args, nub, conn, reader)
+	}
+}
+
+func (s *Server) handleClient(conn net.Conn) {
+
+	defer func() {
+		if conn != nil {
+			_ = conn.Close()
+		}
+	}()
+
+	session := &Session{
+		Conn:      conn,
+		Status:    UNAUTHORIZED,
+		AliveTime: time.Now(),
+	}
+
+	tc := &context.Context{}
+	tc.SetValue(context.LogID, id.GenLogID())
+	session.Ctx = tc
+
+	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, "* 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(session, fmt.Sprintf(`* OK [CAPABILITY IMAP4 IMAP4rev1 AUTH=LOGIN] PMail Server ready%s`, eol), "")
+
+	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()
+
+		s.doCommand(session, rawLine, conn, reader)
+
+	}
+}
+
+// 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 "", "", ""
+	}
+
+	if len(cmd) == 3 {
+		if strings.ToTitle(cmd[1]) == "UID" {
+			args := strings.SplitN(cmd[2], " ", 2)
+			if len(args) >= 2 {
+				return cmd[0], strings.ToTitle(cmd[1]) + " " + strings.ToTitle(args[0]), args[1]
+			}
+		}
+
+		return cmd[0], strings.ToTitle(cmd[1]), cmd[2]
+	}
+
+	return cmd[0], strings.ToTitle(cmd[1]), ""
+}
+
+func getSafeArg(args []string, nr int) string {
+	if nr < len(args) {
+		return args[nr]
+	}
+	return ""
+}
+
+func showSucc(s *Session, msg, nub string) {
+	if msg == "" {
+		write(s, fmt.Sprintf("%s OK success %s", nub, eol), nub)
+	} else {
+		write(s, fmt.Sprintf("%s %s %s", nub, msg, eol), nub)
+	}
+}
+
+func showSuccWithData(s *Session, data []string, msg string, nub string) {
+	content := ""
+	for _, datum := range data {
+		content += fmt.Sprintf("%s%s", datum, eol)
+	}
+	content += fmt.Sprintf("%s OK %s%s", nub, msg, eol)
+	write(s, content, nub)
+}
+
+func showBad(s *Session, err string, nub string) {
+	if nub == "" {
+		nub = "*"
+	}
+
+	if err == "" {
+		write(s, fmt.Sprintf("%s BAD %s", nub, eol), nub)
+		return
+	}
+	write(s, fmt.Sprintf("%s BAD %s%s", nub, err, eol), nub)
+}
+
+func showNo(s *Session, msg string, nub string) {
+	write(s, fmt.Sprintf("%s NO %s%s", nub, msg, eol), nub)
+}
+
+func paramsErr(session *Session, commend string, nub string) {
+	write(session, fmt.Sprintf("* BAD %s parameters! %s", commend, eol), nub)
+}
+
+func write(session *Session, content string, nub string) {
+	if !strings.HasSuffix(content, eol) {
+		log.WithContext(session.Ctx).Errorf("Error:返回结尾错误  %s", content)
+	}
+	log.WithContext(session.Ctx).Debugf("Imap Out:\t |%s", content)
+	fmt.Fprintf(session.Conn, content)
+}

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

@@ -0,0 +1,192 @@
+package goimap
+
+import (
+	"fmt"
+	"net"
+	"net/netip"
+	"reflect"
+	"testing"
+	"time"
+)
+
+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)`,
+		},
+		{
+			"LOGIN命令测试",
+			args{`a LOGIN admin 666666`},
+			"a",
+			"LOGIN",
+			`admin 666666`,
+		},
+		{
+			"SELECT命令测试",
+			args{`9.79 SELECT INBOX`},
+			"9.79",
+			"SELECT",
+			`INBOX`,
+		},
+		{
+			"CAPABILITY命令测试",
+			args{`1.81 CAPABILITY`},
+			"1.81",
+			"CAPABILITY",
+			``,
+		},
+		{
+			"DELETE命令测试",
+			args{`3.183 SELECT "Deleted Messages"`},
+			"3.183",
+			"SELECT",
+			`"Deleted Messages"`,
+		},
+		{
+			"异常命令测试",
+			args{`GET/HTTP/1.0`},
+			"",
+			"",
+			``,
+		},
+		{
+			"FETCH命令测试",
+			args{`4.189 FETCH 7:38 (INTERNALDATE UID RFC822.SIZE FLAGS BODY.PEEK[HEADER.FIELDS (date subject from to cc message-id in-reply-to references content-type x-priority x-uniform-type-identifier x-universally-unique-identifier list-id list-unsubscribe bimi-indicator bimi-location x-bimi-indicator-hash authentication-results dkim-signature)])`},
+			"4.189",
+			"FETCH",
+			`7:38 (INTERNALDATE UID RFC822.SIZE FLAGS BODY.PEEK[HEADER.FIELDS (date subject from to cc message-id in-reply-to references content-type x-priority x-uniform-type-identifier x-universally-unique-identifier list-id list-unsubscribe bimi-indicator bimi-location x-bimi-indicator-hash authentication-results dkim-signature)])`,
+		},
+		{
+			"FETCH命令测试2",
+			args{`4.167 FETCH 1:41 (FLAGS UID)`},
+			"4.167",
+			"FETCH",
+			`1:41 (FLAGS UID)`,
+		},
+		{
+			"UID FETCH命令测试",
+			args{`4.200 UID FETCH 5 BODY.PEEK[HEADER]`},
+			"4.200",
+			"UID FETCH",
+			`5 BODY.PEEK[HEADER]`,
+		},
+		{
+			"UID Search命令测试",
+			args{`C117 UID SEARCH UID 46:*`},
+			"C117",
+			"UID SEARCH",
+			`UID 46:*`,
+		},
+	}
+	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)
+			}
+		})
+	}
+}
+
+type mockConn struct{}
+
+func (m mockConn) Read(b []byte) (n int, err error) {
+	fmt.Println("Read")
+	return 0, err
+}
+
+func (m mockConn) Write(b []byte) (n int, err error) {
+	return 0, err
+}
+
+func (m mockConn) Close() error {
+	return nil
+}
+
+func (m mockConn) LocalAddr() net.Addr {
+	return net.TCPAddrFromAddrPort(netip.AddrPort{})
+}
+
+func (m mockConn) RemoteAddr() net.Addr {
+	return net.TCPAddrFromAddrPort(netip.AddrPort{})
+}
+
+func (m mockConn) SetDeadline(t time.Time) error {
+	return nil
+}
+
+func (m mockConn) SetReadDeadline(t time.Time) error {
+	return nil
+}
+
+func (m mockConn) SetWriteDeadline(t time.Time) error {
+	return nil
+}
+
+//
+//func TestServer_doCommand(t *testing.T) {
+//	type args struct {
+//		session *Session
+//		rawLine string
+//		conn    net.Conn
+//		reader  *bufio.Reader
+//	}
+//	tests := []struct {
+//		name string
+//		args args
+//	}{
+//		{
+//			name: "StatusTest",
+//			args: args{
+//				session: &Session{
+//					Status: AUTHORIZED,
+//
+//				},
+//				rawLine: `9.33 STATUS "Sent Messages" (MESSAGES UIDNEXT UIDVALIDITY UNSEEN)`,
+//				conn:    &mockConn{},
+//				reader:  &bufio.Reader{},
+//			},
+//		},
+//		{
+//			name: "StatusTest2",
+//			args: args{
+//				session: &Session{
+//					Status: AUTHORIZED,
+//				},
+//				rawLine: `9.33 STATUS INBOX (MESSAGES UIDNEXT UIDVALIDITY UNSEEN)`,
+//				conn:    &mockConn{},
+//				reader:  &bufio.Reader{},
+//			},
+//		},
+//	}
+//	for _, tt := range tests {
+//		t.Run(tt.name, func(t *testing.T) {
+//			s := &Server{
+//			}
+//			s.doCommand(tt.args.session, tt.args.rawLine, tt.args.conn, tt.args.reader)
+//		})
+//	}
+//}

+ 28 - 0
server/utils/goimap/session.go

@@ -0,0 +1,28 @@
+package goimap
+
+import (
+	"context"
+	"net"
+	"time"
+)
+
+type Status int8
+
+const (
+	UNAUTHORIZED Status = 1
+	AUTHORIZED   Status = 2
+	SELECTED     Status = 3
+	LOGOUT       Status = 4
+)
+
+type Session struct {
+	Status      Status
+	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+,")

+ 16 - 0
server/utils/version/version.go

@@ -0,0 +1,16 @@
+package version
+
+func LT(version1, version2 string) bool {
+	if version2 == "test" {
+		return true
+	}
+
+	return version1 < version2
+}
+
+func GT(version1, version2 string) bool {
+	if version2 == "test" {
+		return false
+	}
+	return version1 > version2
+}

+ 55 - 0
server/utils/version/version_test.go

@@ -0,0 +1,55 @@
+package version
+
+import "testing"
+
+func TestLT(t *testing.T) {
+	type args struct {
+		version1 string
+		version2 string
+	}
+	tests := []struct {
+		name string
+		args args
+		want bool
+	}{
+		{
+			name: "test1",
+			args: args{
+				version1: "1.0.0",
+				version2: "1.0.0",
+			},
+			want: false,
+		},
+		{
+			name: "test1",
+			args: args{
+				version1: "2.0.0",
+				version2: "1.0.0",
+			},
+			want: false,
+		},
+		{
+			name: "test1",
+			args: args{
+				version1: "1.0.0",
+				version2: "2.0.0",
+			},
+			want: true,
+		},
+		{
+			name: "test1",
+			args: args{
+				version1: "",
+				version2: "1.0.0",
+			},
+			want: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := LT(tt.args.version1, tt.args.version2); got != tt.want {
+				t.Errorf("LT() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}