diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d348a77..50e360e 100644 --- a/.github/workflows/release.yml +++ b/.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 diff --git a/.github/workflows/unitTest.yml b/.github/workflows/unitTest.yml index 2c5491d..fbbd9d2 100644 --- a/.github/workflows/unitTest.yml +++ b/.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 - 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 Test + run: make test + +# - name: Run postgres +# run: make test_postgres + diff --git a/Dockerfile b/Dockerfile index 5d8e95a..2d24182 100644 --- a/Dockerfile +++ b/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 diff --git a/DockerfileGithubAction b/DockerfileGithubAction index 0c29ec9..fb0ff2c 100644 --- a/DockerfileGithubAction +++ b/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 diff --git a/Makefile b/Makefile index 68cc118..d8126b1 100644 --- a/Makefile +++ b/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 ./... \ No newline at end of file + export setup_port=17888 && cd server && export PMail_ROOT=$(CURDIR)/server/ && go test -args "postgres" -v -p 1 ./... \ No newline at end of file diff --git a/README.md b/README.md index cb7b7b7..3b5d4d2 100644 --- a/README.md +++ b/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) diff --git a/README_CN.md b/README_CN.md index 017f1c9..14c3fa6 100644 --- a/README_CN.md +++ b/README_CN.md @@ -109,6 +109,9 @@ SMTP地址: smtp.[你的域名] SMTP端口: 25/465(SSL) +IMAP地址: imap.[Your Domain] + +IMAP端口: 993(SSL) # 插件 diff --git a/fe/src/components/GroupSettings.vue b/fe/src/components/GroupSettings.vue index bb29078..b1701fd 100644 --- a/fe/src/components/GroupSettings.vue +++ b/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); }); diff --git a/server/config/config.go b/server/config/config.go index 4d943b1..0e3a832 100644 --- a/server/config/config.go +++ b/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 + } +} diff --git a/server/config/config.json b/server/config/config.json index 7186b54..f7e03d1 100644 --- a/server/config/config.json +++ b/server/config/config.json @@ -1,5 +1,5 @@ { - "logLevel": "", + "logLevel": "debug", "domain": "test.domain", "domains": null, "webDomain": "mail.test.domain", diff --git a/server/consts/consts.go b/server/consts/consts.go index 5e0d958..eb23722 100644 --- a/server/consts/consts.go +++ b/server/consts/consts.go @@ -17,4 +17,10 @@ const ( //EmailStatusDel 3删除 EmailStatusDel int8 = 3 + + // EmailStatusDrafts 草稿箱 + EmailStatusDrafts int8 = 4 + + // EmailStatusJunk 骚扰邮件 + EmailStatusJunk int8 = 5 ) diff --git a/server/controllers/email/delete.go b/server/controllers/email/delete.go index 2b6b560..f099cb0 100644 --- a/server/controllers/email/delete.go +++ b/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) { diff --git a/server/controllers/group.go b/server/controllers/group.go index 897e395..5b4723c 100644 --- a/server/controllers/group.go +++ b/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 diff --git a/server/db/init.go b/server/db/init.go index e453414..31aec12 100644 --- a/server/db/init.go +++ b/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 } diff --git a/server/dto/parsemail/dkim.go b/server/dto/parsemail/dkim.go index c3678bb..464c350 100644 --- a/server/dto/parsemail/dkim.go +++ b/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{ diff --git a/server/dto/parsemail/email.go b/server/dto/parsemail/email.go index 1f65c93..c445a8d 100644 --- a/server/dto/parsemail/email.go +++ b/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 diff --git a/server/dto/parsemail/email_test.go b/server/dto/parsemail/email_test.go index 0f52bd4..96aaaf5 100644 --- a/server/dto/parsemail/email_test.go +++ b/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{ diff --git a/server/dto/response/email.go b/server/dto/response/email.go index d498cda..83fee1d 100644 --- a/server/dto/response/email.go +++ b/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"` } diff --git a/server/go.mod b/server/go.mod index dea4e4d..3b67797 100644 --- a/server/go.mod +++ b/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 ) diff --git a/server/go.sum b/server/go.sum index 2f4a258..21ea687 100644 --- a/server/go.sum +++ b/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= diff --git a/server/cron_server/ssl_update.go b/server/listen/cron_server/ssl_update.go similarity index 92% rename from server/cron_server/ssl_update.go rename to server/listen/cron_server/ssl_update.go index 677c68e..e5f5f38 100644 --- a/server/cron_server/ssl_update.go +++ b/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) } diff --git a/server/http_server/http_server.go b/server/listen/http_server/http_server.go similarity index 100% rename from server/http_server/http_server.go rename to server/listen/http_server/http_server.go diff --git a/server/http_server/https_server.go b/server/listen/http_server/https_server.go similarity index 100% rename from server/http_server/https_server.go rename to server/listen/http_server/https_server.go diff --git a/server/http_server/setup_server.go b/server/listen/http_server/setup_server.go similarity index 100% rename from server/http_server/setup_server.go rename to server/listen/http_server/setup_server.go diff --git a/server/listen/imap_server/imap_server.go b/server/listen/imap_server/imap_server.go new file mode 100644 index 0000000..a65b014 --- /dev/null +++ b/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) + } +} diff --git a/server/listen/imap_server/imap_server_test.go b/server/listen/imap_server/imap_server_test.go new file mode 100644 index 0000000..5c14fa1 --- /dev/null +++ b/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) + } +} diff --git a/server/listen/imap_server/server.go b/server/listen/imap_server/server.go new file mode 100644 index 0000000..9e95c9b --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_copy.go b/server/listen/imap_server/session_copy.go new file mode 100644 index 0000000..eb3aa64 --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_create.go b/server/listen/imap_server/session_create.go new file mode 100644 index 0000000..0417954 --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_delete.go b/server/listen/imap_server/session_delete.go new file mode 100644 index 0000000..5d810a8 --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_expunge.go b/server/listen/imap_server/session_expunge.go new file mode 100644 index 0000000..982555f --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_fetch.go b/server/listen/imap_server/session_fetch.go new file mode 100644 index 0000000..9030479 --- /dev/null +++ b/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() + } +} diff --git a/server/listen/imap_server/session_idle.go b/server/listen/imap_server/session_idle.go new file mode 100644 index 0000000..82c8711 --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_list.go b/server/listen/imap_server/session_list.go new file mode 100644 index 0000000..d9b47b1 --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_login.go b/server/listen/imap_server/session_login.go new file mode 100644 index 0000000..bd21c40 --- /dev/null +++ b/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)", + } +} diff --git a/server/listen/imap_server/session_move.go b/server/listen/imap_server/session_move.go new file mode 100644 index 0000000..58eb458 --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_namespace.go b/server/listen/imap_server/session_namespace.go new file mode 100644 index 0000000..e6ef193 --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_poll.go b/server/listen/imap_server/session_poll.go new file mode 100644 index 0000000..0e55061 --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_rename.go b/server/listen/imap_server/session_rename.go new file mode 100644 index 0000000..7db1568 --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_search.go b/server/listen/imap_server/session_search.go new file mode 100644 index 0000000..0ccfe03 --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_select.go b/server/listen/imap_server/session_select.go new file mode 100644 index 0000000..6d008aa --- /dev/null +++ b/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 + +} diff --git a/server/listen/imap_server/session_status.go b/server/listen/imap_server/session_status.go new file mode 100644 index 0000000..fc3c64f --- /dev/null +++ b/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 +} diff --git a/server/listen/imap_server/session_store.go b/server/listen/imap_server/session_store.go new file mode 100644 index 0000000..06fd54d --- /dev/null +++ b/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 +} diff --git a/server/pop3_server/action.go b/server/listen/pop3_server/action.go similarity index 96% rename from server/pop3_server/action.go rename to server/listen/pop3_server/action.go index d275393..4bd4287 100644 --- a/server/pop3_server/action.go +++ b/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 diff --git a/server/listen/pop3_server/action_test.go b/server/listen/pop3_server/action_test.go new file mode 100644 index 0000000..6e081f9 --- /dev/null +++ b/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()) + +} diff --git a/server/pop3_server/pop3server.go b/server/listen/pop3_server/pop3server.go similarity index 100% rename from server/pop3_server/pop3server.go rename to server/listen/pop3_server/pop3server.go diff --git a/server/smtp_server/read_content.go b/server/listen/smtp_server/read_content.go similarity index 95% rename from server/smtp_server/read_content.go rename to server/listen/smtp_server/read_content.go index d82d312..1ea6ce7 100644 --- a/server/smtp_server/read_content.go +++ b/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 { diff --git a/server/smtp_server/read_content_test.go b/server/listen/smtp_server/read_content_test.go similarity index 99% rename from server/smtp_server/read_content_test.go rename to server/listen/smtp_server/read_content_test.go index 6580ce2..80d2317 100644 --- a/server/smtp_server/read_content_test.go +++ b/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("") diff --git a/server/smtp_server/smtp.go b/server/listen/smtp_server/smtp.go similarity index 100% rename from server/smtp_server/smtp.go rename to server/listen/smtp_server/smtp.go diff --git a/server/smtp_server/smtp_test/sendEmailTest.py b/server/listen/smtp_server/smtp_test/sendEmailTest.py similarity index 100% rename from server/smtp_server/smtp_test/sendEmailTest.py rename to server/listen/smtp_server/smtp_test/sendEmailTest.py diff --git a/server/main.go b/server/main.go index 08f15b6..839f1b0 100644 --- a/server/main.go +++ b/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" } diff --git a/server/main_test.go b/server/main_test.go index 091d38d..727d313 100644 --- a/server/main_test.go +++ b/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": "
测试邮件%d
" +} + +`, 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(` { diff --git a/server/models/group.go b/server/models/group.go index 88de9ea..65549a8 100644 --- a/server/models/group.go +++ b/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 { diff --git a/server/models/user_email.go b/server/models/user_email.go index 5c1c11b..53fd0e4 100644 --- a/server/models/user_email.go +++ b/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 { diff --git a/server/pop3_server/action_test.go b/server/pop3_server/action_test.go deleted file mode 100644 index a65d339..0000000 --- a/server/pop3_server/action_test.go +++ /dev/null @@ -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()) -// -//} diff --git a/server/res_init/init.go b/server/res_init/init.go index 8c181e1..d2cc0a7 100644 --- a/server/res_init/init.go +++ b/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) } diff --git a/server/services/del_email/del_email.go b/server/services/del_email/del_email.go index 4c2ec25..151150d 100644 --- a/server/services/del_email/del_email.go +++ b/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 +} diff --git a/server/services/detail/detail.go b/server/services/detail/detail.go index 2da48c0..2a73e0d 100644 --- a/server/services/detail/detail.go +++ b/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 + + } +} diff --git a/server/services/group/group.go b/server/services/group/group.go index 83e83dd..180b855 100644 --- a/server/services/group/group.go +++ b/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 +} diff --git a/server/services/group/group_test.go b/server/services/group/group_test.go new file mode 100644 index 0000000..ae4645e --- /dev/null +++ b/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) +} diff --git a/server/services/list/list.go b/server/services/list/list.go index d40e125..5346ad1 100644 --- a/server/services/list/list.go +++ b/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 +} diff --git a/server/services/setup/db.go b/server/services/setup/db.go index 08df95c..25a982b 100644 --- a/server/services/setup/db.go +++ b/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 -} diff --git a/server/services/setup/dns.go b/server/services/setup/dns.go index 828256b..2d5afe2 100644 --- a/server/services/setup/dns.go +++ b/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}, diff --git a/server/services/setup/domain.go b/server/services/setup/domain.go index 05aa03e..55d0258 100644 --- a/server/services/setup/domain.go +++ b/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) } diff --git a/server/services/setup/finish.go b/server/services/setup/finish.go index 02bfcd7..c8989a1 100644 --- a/server/services/setup/finish.go +++ b/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) } diff --git a/server/services/setup/ssl/dnsProvide_test.go b/server/services/setup/ssl/dnsProvide_test.go deleted file mode 100644 index 96f3ba1..0000000 --- a/server/services/setup/ssl/dnsProvide_test.go +++ /dev/null @@ -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) -// } -// }) -// } -//} diff --git a/server/services/setup/ssl/ssl.go b/server/services/setup/ssl/ssl.go index 8809855..0108bd2 100644 --- a/server/services/setup/ssl/ssl.go +++ b/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 { diff --git a/server/services/setup/ssl/ssl_test.go b/server/services/setup/ssl/ssl_test.go new file mode 100644 index 0000000..eb38881 --- /dev/null +++ b/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) +} diff --git a/server/utils/goimap/action.go b/server/utils/goimap/action.go new file mode 100644 index 0000000..60a53e2 --- /dev/null +++ b/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] <>:返回邮件的中的某一指定部分,返回的部分用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 +} diff --git a/server/utils/goimap/dto.go b/server/utils/goimap/dto.go new file mode 100644 index 0000000..9cb3f42 --- /dev/null +++ b/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 +} diff --git a/server/utils/goimap/imap.go b/server/utils/goimap/imap.go new file mode 100644 index 0000000..2eb98fe --- /dev/null +++ b/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) +} diff --git a/server/utils/goimap/imap_test.go b/server/utils/goimap/imap_test.go new file mode 100644 index 0000000..1603c21 --- /dev/null +++ b/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) +// }) +// } +//} diff --git a/server/utils/goimap/session.go b/server/utils/goimap/session.go new file mode 100644 index 0000000..e54c1f6 --- /dev/null +++ b/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中 +} diff --git a/server/utils/utf7/LICENSE b/server/utils/utf7/LICENSE new file mode 100644 index 0000000..d6718dc --- /dev/null +++ b/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. diff --git a/server/utils/utf7/README.md b/server/utils/utf7/README.md new file mode 100644 index 0000000..972fb9c --- /dev/null +++ b/server/utils/utf7/README.md @@ -0,0 +1 @@ +COPY from https://github.com/emersion/go-imap/tree/v2/internal/utf7 \ No newline at end of file diff --git a/server/utils/utf7/decoder.go b/server/utils/utf7/decoder.go new file mode 100644 index 0000000..b8e906e --- /dev/null +++ b/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] +} diff --git a/server/utils/utf7/decoder_test.go b/server/utils/utf7/decoder_test.go new file mode 100644 index 0000000..e0c8dff --- /dev/null +++ b/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) + } + } +} diff --git a/server/utils/utf7/encoder.go b/server/utils/utf7/encoder.go new file mode 100644 index 0000000..e7107c3 --- /dev/null +++ b/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() +} diff --git a/server/utils/utf7/encoder_test.go b/server/utils/utf7/encoder_test.go new file mode 100644 index 0000000..f7816ad --- /dev/null +++ b/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) + } + } +} diff --git a/server/utils/utf7/utf7.go b/server/utils/utf7/utf7.go new file mode 100644 index 0000000..3ff09a9 --- /dev/null +++ b/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+,") diff --git a/server/utils/version/version.go b/server/utils/version/version.go new file mode 100644 index 0000000..7d69b2f --- /dev/null +++ b/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 +} diff --git a/server/utils/version/version_test.go b/server/utils/version/version_test.go new file mode 100644 index 0000000..372fcf1 --- /dev/null +++ b/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) + } + }) + } +}