Feature/v2.8.0 (#231)

支持Imap协议
升级所有依赖
修复部分bug
This commit is contained in:
Jinnrry 2025-01-04 16:23:07 +08:00 committed by GitHub
parent 6697d2b229
commit 5af46b32f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 4149 additions and 345 deletions

View File

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

View File

@ -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
@ -51,24 +54,18 @@ jobs:
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
- 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import (
)
type emailDeleteRequest struct {
IDs []int64 `json:"ids"`
IDs []int `json:"ids"`
ForcedDel bool `json:"forcedDel"`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("开始执行插件ReceiveSaveAfterEnd")
// 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 {

View File

@ -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("")

View File

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

View File

@ -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")
}
}
func testDataBaseSet(t *testing.T) {
@ -303,7 +312,9 @@ func testDataBaseSet(t *testing.T) {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Errorf("Response %+v", data)
t.Error("Get Database Config Api Error!")
return
}
argList := flag.Args()
@ -318,7 +329,7 @@ func testDataBaseSet(t *testing.T) {
`
} else if array.InArray("postgres", argList) {
configData = `
{"action":"set","step":"database","db_type":"postgres","db_dsn":"postgres://postgres:githubTest@127.0.0.1:5432/pmail?sslmode=disable"}
{"action":"set","step":"database","db_type":"postgres","db_dsn":"postgres://postgres:githubTest@postgres:5432/pmail?sslmode=disable"}
`
}
@ -658,6 +669,45 @@ func testSendEmail2User2ForMove(t *testing.T) {
}
func genTestEmailData(t *testing.T) {
for i := 0; i < 10; i++ {
ret, err := httpClient.Post(TestHost+"/api/email/send", "application/json", strings.NewReader(fmt.Sprintf(
`
{
"from": {
"name": "user2",
"email": "user2@test.domain"
},
"to": [
{
"name": "admin",
"email": "admin@test.domain"
}
],
"cc": [
],
"subject": "测试邮件%d",
"text": "测试邮件%d",
"html": "<div>测试邮件%d</div>"
}
`, i, i, i)))
if err != nil {
t.Error(err)
}
data, err := readResponse(ret.Body)
if err != nil {
t.Error(err)
}
if data.ErrorNo != 0 {
t.Error("Send Email Api Error!")
}
time.Sleep(3 * time.Second)
}
}
func testSendEmail2User1(t *testing.T) {
ret, err := httpClient.Post(TestHost+"/api/email/send", "application/json", strings.NewReader(`
{

View File

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

View File

@ -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删除
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

830
server/utils/goimap/imap.go Normal file
View File

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

View File

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

View File

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

23
server/utils/utf7/LICENSE Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

13
server/utils/utf7/utf7.go Normal file
View File

@ -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+,")

View File

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

View File

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