jinnrry преди 2 години
родител
ревизия
13f6570c8f

+ 46 - 0
.github/workflows/docker_build_pre.yml

@@ -0,0 +1,46 @@
+name: Docker Image CI Pre
+
+on:
+  workflow_dispatch:
+  release:
+    types: [ prereleased ]
+
+env:
+  REGISTRY: ghcr.io
+
+jobs:
+  build-and-push-image:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      packages: write
+    steps:
+      - name: Get version
+        id: get_version
+        run: |
+          echo "VERSION=pre${GITHUB_REF/refs\/tags\//}" >> ${GITHUB_ENV}
+          echo "${GITHUB_REF/refs\/tags\//}"
+          echo "${GITHUB_REF#refs/*/}"
+          echo "${GITHUB_REF}"
+      - uses: actions/checkout@v3
+
+      - name: set lower case repository name
+        run: |
+          echo "REPOSITORY_LC=${REPOSITORY,,}" >> ${GITHUB_ENV}
+        env:
+          REPOSITORY: '${{ github.repository }}'
+
+      - name: Log in to the Container registry
+        uses: docker/login-action@v2.1.0
+        with:
+          registry: ${{ env.REGISTRY }}
+          username: ${{ github.actor }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Build and push Docker images
+        uses: docker/build-push-action@v4
+        with:
+          context: .
+          push: true
+          tags: |
+            ${{ env.REGISTRY }}/${{ env.REPOSITORY_LC }}:${{ env.VERSION }}

+ 10 - 1
README.md

@@ -34,6 +34,8 @@ beautiful and cute Logo for this project!
 (Note: Even if you don't need https, please make sure the path to the ssl certificate file is correct, although the web
 > service doesn't use the certificate anymore, the smtp protocol still needs the certificate)
 
+* Support pop3, smtp protocol, you can use any mail client you like.
+
 ## Disadvantages
 
 * At present, only the core function of sending and receiving emails has been completed. Basically, it can only be used
@@ -55,7 +57,7 @@ beautiful and cute Logo for this project!
 
 Or 
 
-`docker run -p 25:25 -p 80:80 -p 443:443 -p 465:465 -v $(pwd)/config:/work/config ghcr.io/jinnrry/pmail:latest`
+`docker run -p 25:25 -p 80:80 -p 443:443 -p 110:110 -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, and 443.
@@ -106,8 +108,15 @@ Open the `config/config.json` file in the run directory, edit a few configuratio
 }
 ```
 
+# Mail Client Configuration
+
+POP3 Server Address : [Your Domain]
+
+POP3 Port: 110
 
+SMTP Server Address : smtp.[Your Domain]
 
+SMTP Port: 25
 
 # For Developer
 

+ 15 - 1
README_CN.md

@@ -35,6 +35,10 @@ PMail是一个追求极简部署流程、极致资源占用的个人域名邮箱
 默认情况下,会为web后台也生成ssl证书,让后台使用https访问,如果你有自己的网关层,不需要https的话,在配置文件中将`httpsEnabled`
 设置为`2`,这样管理后台就不会使用https协议。( 注意:即使你不需要https,也请保证ssl证书文件路径正确,http协议虽然不使用证书了,但是smtp协议还需要证书)
 
+### 5、邮件客户端支持
+
+只要支持pop3、smtp协议的邮件客户端均可使用
+
 ## 其他
 
 ### 不足
@@ -57,7 +61,7 @@ PMail是一个追求极简部署流程、极致资源占用的个人域名邮箱
 
 或者
 
-`docker run -p 25:25 -p 80:80 -p 443:443 -p 465:465 -v $(pwd)/config:/work/config ghcr.io/jinnrry/pmail:latest`
+`docker run -p 25:25 -p 80:80 -p 443:443 -p 110:110 -v $(pwd)/config:/work/config ghcr.io/jinnrry/pmail:latest`
 
 > [!IMPORTANT]
 > 如果你服务器开启了防火墙,你需要放行25、80、443这三个端口
@@ -105,6 +109,16 @@ PMail是一个追求极简部署流程、极致资源占用的个人域名邮箱
 }
 ```
 
+# 第三方邮件客户端配置
+
+POP3地址: [你的域名]
+
+POP3端口: 110
+
+SMTP地址: smtp.[你的域名]
+
+SMTP端口: 25
+
 # 参与开发
 
 ## 项目架构

+ 1 - 1
server/config/config.go

@@ -38,7 +38,7 @@ type Config struct {
 //go:embed tables/*
 var tableConfig embed.FS
 
-const Version = "2.2.7"
+const Version = "2.3.0"
 
 const DBTypeMySQL = "mysql"
 const DBTypeSQLite = "sqlite"

+ 48 - 5
server/dto/parsemail/email.go

@@ -16,8 +16,8 @@ import (
 )
 
 type User struct {
-	EmailAddress string
-	Name         string
+	EmailAddress string `json:"EmailAddress"`
+	Name         string `json:"Name"`
 }
 
 type Attachment struct {
@@ -247,7 +247,47 @@ func (e *Email) ForwardBuildBytes(ctx *context.Context, forwardAddress string) [
 	return instance.Sign(b.String())
 }
 
-func (e *Email) BuildBytes(ctx *context.Context) []byte {
+func (e *Email) BuilderHeaders(ctx *context.Context) []byte {
+	var b bytes.Buffer
+
+	from := []*mail.Address{{e.From.Name, e.From.EmailAddress}}
+	to := []*mail.Address{}
+	for _, user := range e.To {
+		to = append(to, &mail.Address{
+			Name:    user.Name,
+			Address: user.EmailAddress,
+		})
+	}
+
+	// Create our mail header
+	var h mail.Header
+	h.SetDate(time.Now())
+	h.SetAddressList("From", from)
+	h.SetAddressList("To", to)
+	h.SetText("Subject", e.Subject)
+	if len(e.Cc) != 0 {
+		cc := []*mail.Address{}
+		for _, user := range e.Cc {
+			cc = append(cc, &mail.Address{
+				Name:    user.Name,
+				Address: user.EmailAddress,
+			})
+		}
+		h.SetAddressList("Cc", cc)
+	}
+
+	// Create a new mail writer
+	mw, err := mail.CreateWriter(&b, h)
+	if err != nil {
+		log.WithContext(ctx).Fatal(err)
+	}
+
+	mw.Close()
+
+	return b.Bytes()
+}
+
+func (e *Email) BuildBytes(ctx *context.Context, dkim bool) []byte {
 	var b bytes.Buffer
 
 	from := []*mail.Address{{e.From.Name, e.From.EmailAddress}}
@@ -323,6 +363,9 @@ func (e *Email) BuildBytes(ctx *context.Context) []byte {
 
 	mw.Close()
 
-	// dkim 签名后返回
-	return instance.Sign(b.String())
+	if dkim {
+		// dkim 签名后返回
+		return instance.Sign(b.String())
+	}
+	return b.Bytes()
 }

+ 11 - 0
server/dto/parsemail/email_test.go

@@ -1,6 +1,8 @@
 package parsemail
 
 import (
+	"fmt"
+	"pmail/config"
 	"testing"
 )
 
@@ -41,3 +43,12 @@ func Test_buildUser(t *testing.T) {
 		t.Error("error")
 	}
 }
+
+func TestEmail_BuilderHeaders(t *testing.T) {
+	config.Init()
+	Init()
+	e := Email{
+		From: buildUser("Jinnrry N <jiangwei1995910@gmail.com>"),
+	}
+	fmt.Println(string(e.BuilderHeaders(nil)))
+}

+ 2 - 1
server/go.mod

@@ -1,6 +1,6 @@
 module pmail
 
-go 1.20
+go 1.21
 
 require (
 	github.com/alexedwards/scs/mysqlstore v0.0.0-20230327161757-10d4299e3b24
@@ -23,6 +23,7 @@ require (
 replace github.com/alexedwards/scs/sqlite3store v0.0.0-20230327161757-10d4299e3b24 => github.com/Jinnrry/scs/sqlite3store v0.0.0-20230803080525-914f01e0d379
 
 require (
+	github.com/Jinnrry/gopop v0.0.0-20231109113124-29947e68ddf7 // indirect
 	github.com/cenkalti/backoff/v4 v4.2.1 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect

+ 10 - 0
server/go.sum

@@ -1,3 +1,13 @@
+github.com/Jinnrry/gopop v0.0.0-20231109082454-dbfe64f12f23 h1:8EipsGnMHqE2uHcb7h1bWPk+Fyropo1tOsqach72s7k=
+github.com/Jinnrry/gopop v0.0.0-20231109082454-dbfe64f12f23/go.mod h1:xcI6e+jbXWN+T8EWOJtHbAku6pzNqyCHaFvzdeL1r2o=
+github.com/Jinnrry/gopop v0.0.0-20231109090543-1be853ddd42f h1:Hx4PvKX1pv4fkaUkQ730/sTNlkiU296K2OskzmoG49k=
+github.com/Jinnrry/gopop v0.0.0-20231109090543-1be853ddd42f/go.mod h1:xcI6e+jbXWN+T8EWOJtHbAku6pzNqyCHaFvzdeL1r2o=
+github.com/Jinnrry/gopop v0.0.0-20231109090625-7854338d798d h1:NgY+RB1q50i6vjvD9fboS2hnlFnkI7iXdBwfsX4rgRw=
+github.com/Jinnrry/gopop v0.0.0-20231109090625-7854338d798d/go.mod h1:xcI6e+jbXWN+T8EWOJtHbAku6pzNqyCHaFvzdeL1r2o=
+github.com/Jinnrry/gopop v0.0.0-20231109111809-633359de6de1 h1:K2qHwBniOX3Tcl1Fp2H8wx6jpyTolG2BkCrl4eJjwP4=
+github.com/Jinnrry/gopop v0.0.0-20231109111809-633359de6de1/go.mod h1:xcI6e+jbXWN+T8EWOJtHbAku6pzNqyCHaFvzdeL1r2o=
+github.com/Jinnrry/gopop v0.0.0-20231109113124-29947e68ddf7 h1:GFm3jyFtb+AsVodnQ4cE80J3d05EpnpxxZkFiiAJqJ4=
+github.com/Jinnrry/gopop v0.0.0-20231109113124-29947e68ddf7/go.mod h1:xcI6e+jbXWN+T8EWOJtHbAku6pzNqyCHaFvzdeL1r2o=
 github.com/Jinnrry/scs/sqlite3store v0.0.0-20230803080525-914f01e0d379 h1:i6LB/3lgkRDupe3owyNXtH8dtQrdaReCLeAZKrWcqAE=
 github.com/Jinnrry/scs/sqlite3store v0.0.0-20230803080525-914f01e0d379/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
 github.com/alexedwards/scs/mysqlstore v0.0.0-20230327161757-10d4299e3b24 h1:1jXpX7IE/zuf9FZQJpqZNepXqW8mq6NLzplHDCA43HY=

+ 58 - 0
server/models/email.go

@@ -3,6 +3,7 @@ package models
 import (
 	"database/sql"
 	"encoding/json"
+	"pmail/dto/parsemail"
 	"time"
 )
 
@@ -40,6 +41,42 @@ type attachments struct {
 	//Content     []byte
 }
 
+func (d Email) GetTos() []*parsemail.User {
+	var ret []*parsemail.User
+	json.Unmarshal([]byte(d.To), &ret)
+	return ret
+}
+
+func (d Email) GetReplyTo() []*parsemail.User {
+	var ret []*parsemail.User
+	json.Unmarshal([]byte(d.ReplyTo), &ret)
+	return ret
+}
+
+func (d Email) GetSender() *parsemail.User {
+	var ret *parsemail.User
+	json.Unmarshal([]byte(d.Sender), &ret)
+	return ret
+}
+
+func (d Email) GetBcc() []*parsemail.User {
+	var ret []*parsemail.User
+	json.Unmarshal([]byte(d.Bcc), &ret)
+	return ret
+}
+
+func (d Email) GetCc() []*parsemail.User {
+	var ret []*parsemail.User
+	json.Unmarshal([]byte(d.Cc), &ret)
+	return ret
+}
+
+func (d Email) GetAttachments() []*parsemail.Attachment {
+	var ret []*parsemail.Attachment
+	json.Unmarshal([]byte(d.Attachments), &ret)
+	return ret
+}
+
 func (d Email) MarshalJSON() ([]byte, error) {
 	type Alias Email
 
@@ -78,3 +115,24 @@ func (d Email) MarshalJSON() ([]byte, error) {
 		Attachments:  showAtt,
 	})
 }
+
+func (d Email) ToTransObj() *parsemail.Email {
+
+	return &parsemail.Email{
+		From: &parsemail.User{
+			Name:         d.FromName,
+			EmailAddress: d.FromAddress,
+		},
+		To:          d.GetTos(),
+		Subject:     d.Subject,
+		Text:        []byte(d.Text.String),
+		HTML:        []byte(d.Html.String),
+		Sender:      d.GetSender(),
+		ReplyTo:     d.GetReplyTo(),
+		Bcc:         d.GetBcc(),
+		Cc:          d.GetCc(),
+		Attachments: d.GetAttachments(),
+		Date:        d.SendDate.Format("2006-01-02 15:04:05"),
+	}
+
+}

+ 198 - 0
server/pop3_server/action.go

@@ -0,0 +1,198 @@
+package pop3_server
+
+import (
+	"database/sql"
+	"github.com/Jinnrry/gopop"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cast"
+	"pmail/db"
+	"pmail/models"
+	"pmail/services/detail"
+	"pmail/utils/array"
+	"pmail/utils/context"
+	"pmail/utils/errors"
+	"pmail/utils/id"
+	"pmail/utils/password"
+)
+
+type action struct {
+}
+
+func (a action) User(ctx *gopop.Data, username string) error {
+	if ctx.Ctx == nil {
+		tc := &context.Context{}
+		tc.SetValue(context.LogID, id.GenLogID())
+		ctx.Ctx = tc
+	}
+
+	ctx.User = username
+	return nil
+}
+
+func (a action) Pass(ctx *gopop.Data, pwd string) error {
+	if ctx.Ctx == nil {
+		tc := &context.Context{}
+		tc.SetValue(context.LogID, id.GenLogID())
+		ctx.Ctx = tc
+	}
+
+	var user models.User
+
+	encodePwd := password.Encode(pwd)
+
+	err := db.Instance.Get(&user, db.WithContext(ctx.Ctx.(*context.Context), "select * from user where account =? and password =?"), ctx.User, encodePwd)
+	if err != nil && !errors.Is(err, sql.ErrNoRows) {
+		log.WithContext(ctx.Ctx.(*context.Context)).Errorf("%+v", err)
+	}
+
+	if user.ID > 0 {
+		ctx.Status = gopop.TRANSACTION
+
+		ctx.Ctx.(*context.Context).UserID = user.ID
+		ctx.Ctx.(*context.Context).UserName = user.Name
+		ctx.Ctx.(*context.Context).UserAccount = user.Account
+
+		return nil
+	}
+
+	return errors.New("password error")
+}
+
+func (a action) Apop(ctx *gopop.Data, username, digest string) error {
+	if ctx.Ctx == nil {
+		tc := &context.Context{}
+		tc.SetValue(context.LogID, id.GenLogID())
+		ctx.Ctx = tc
+	}
+
+	var user models.User
+
+	err := db.Instance.Get(&user, db.WithContext(ctx.Ctx.(*context.Context), "select * from user where account =? "), username)
+	if err != nil && !errors.Is(err, sql.ErrNoRows) {
+		log.WithContext(ctx.Ctx.(*context.Context)).Errorf("%+v", err)
+	}
+
+	if user.ID > 0 && digest == password.Md5Encode(user.Password) {
+		ctx.User = username
+		ctx.Status = gopop.TRANSACTION
+
+		ctx.Ctx.(*context.Context).UserID = user.ID
+		ctx.Ctx.(*context.Context).UserName = user.Name
+		ctx.Ctx.(*context.Context).UserAccount = user.Account
+
+		return nil
+	}
+
+	return errors.New("password error")
+
+}
+
+type statInfo struct {
+	Num  int64 `json:"num"`
+	Size int64 `json:"size"`
+}
+
+func (a action) Stat(ctx *gopop.Data) (msgNum, msgSize int64, err error) {
+	var si statInfo
+	err = db.Instance.Get(&si, db.WithContext(ctx.Ctx.(*context.Context), "select count(1) as `num`, sum(length(text)+length(html)) as `size` from email"))
+	if err != nil && !errors.Is(err, sql.ErrNoRows) {
+		log.WithContext(ctx.Ctx.(*context.Context)).Errorf("%+v", err)
+		err = nil
+		return 0, 0, nil
+	}
+
+	return si.Num, si.Size, nil
+}
+
+func (a action) Uidl(ctx *gopop.Data, id int64) (string, error) {
+	return cast.ToString(id), nil
+}
+
+type listItem struct {
+	Id   int64 `json:"id"`
+	Size int64 `json:"size"`
+}
+
+func (a action) List(ctx *gopop.Data, msg string) ([]gopop.MailInfo, error) {
+	var res []listItem
+	var id int64
+	if msg != "" {
+		id = cast.ToInt64(msg)
+		if id == 0 {
+			return nil, errors.New("params error")
+		}
+	}
+	var err error
+	if id != 0 {
+		err = db.Instance.Select(&res, db.WithContext(ctx.Ctx.(*context.Context), "select id, length(text)+length(html) as `size` from email where id =?"), id)
+	} else {
+		err = db.Instance.Select(&res, db.WithContext(ctx.Ctx.(*context.Context), "select id, length(text)+length(html) as `size` from email"))
+	}
+
+	if err != nil && !errors.Is(err, sql.ErrNoRows) {
+		log.WithContext(ctx.Ctx.(*context.Context)).Errorf("%+v", err)
+		err = nil
+		return []gopop.MailInfo{}, nil
+	}
+	ret := []gopop.MailInfo{}
+	for _, re := range res {
+		ret = append(ret, gopop.MailInfo{
+			Id:   re.Id,
+			Size: re.Size,
+		})
+	}
+	return ret, nil
+}
+
+func (a action) Retr(ctx *gopop.Data, id int64) (string, int64, error) {
+
+	email, err := detail.GetEmailDetail(ctx.Ctx.(*context.Context), cast.ToInt(id), false)
+	if err != nil {
+		log.WithContext(ctx.Ctx.(*context.Context)).Errorf("%+v", err)
+		return "", 0, errors.New("server error")
+	}
+
+	ret := email.ToTransObj().BuildBytes(ctx.Ctx.(*context.Context), false)
+	return string(ret), cast.ToInt64(len(ret)), nil
+
+}
+
+func (a action) Delete(ctx *gopop.Data, id int64) error {
+	ctx.DeleteIds = append(ctx.DeleteIds, id)
+	ctx.DeleteIds = array.Unique(ctx.DeleteIds)
+	return nil
+}
+
+func (a action) Rest(ctx *gopop.Data) error {
+	ctx.DeleteIds = []int64{}
+	return nil
+}
+
+func (a action) Top(ctx *gopop.Data, id int64, n int) (string, error) {
+	//email, err := detail.GetEmailDetail(ctx.Ctx.(*context.Context), cast.ToInt(id), false)
+	//if err != nil {
+	//	log.WithContext(ctx.Ctx.(*context.Context)).Errorf("%+v", err)
+	//	return "", errors.New("server error")
+	//}
+	//
+	//ret := email.ToTransObj().BuilderHeaders(ctx.Ctx.(*context.Context))
+	//return string(ret), nil
+
+	return "", errors.New("not supported")
+}
+
+func (a action) Noop(ctx *gopop.Data) error {
+	return nil
+}
+
+func (a action) Quit(ctx *gopop.Data) error {
+	if len(ctx.DeleteIds) > 0 {
+
+		_, err := db.Instance.Exec(db.WithContext(ctx.Ctx.(*context.Context), "DELETE FROM email WHERE id in ?"), ctx.DeleteIds)
+		if err != nil {
+			log.WithContext(ctx.Ctx.(*context.Context)).Errorf("%+v", err)
+		}
+	}
+
+	return nil
+}

+ 23 - 0
server/pop3_server/pop3server.go

@@ -0,0 +1,23 @@
+package pop3_server
+
+import (
+	"github.com/Jinnrry/gopop"
+	log "github.com/sirupsen/logrus"
+	"pmail/config"
+)
+
+var instance *gopop.Server
+
+func Start() {
+	instance = gopop.NewPop3Server(110, config.Instance.Domain, false, action{})
+	log.Infof("POP3 Server Start On Port :110")
+
+	err := instance.Start()
+	if err != nil {
+		panic(err)
+	}
+}
+
+func Stop() {
+	instance.Stop()
+}

+ 3 - 0
server/res_init/init.go

@@ -9,6 +9,7 @@ import (
 	"pmail/dto/parsemail"
 	"pmail/hooks"
 	"pmail/http_server"
+	"pmail/pop3_server"
 	"pmail/session"
 	"pmail/signal"
 	"pmail/smtp_server"
@@ -40,6 +41,7 @@ func Init() {
 		// http server start
 		go http_server.HttpsStart()
 		go http_server.HttpStart()
+		go pop3_server.Start()
 
 		configStr, _ := json.Marshal(config.Instance)
 		log.Warnf("Config File Info:  %s", configStr)
@@ -49,6 +51,7 @@ func Init() {
 		smtp_server.Stop()
 		http_server.HttpsStop()
 		http_server.HttpStop()
+		pop3_server.Stop()
 	}
 
 }

+ 1 - 1
server/utils/context/context.go

@@ -25,7 +25,7 @@ func (c *Context) SetValue(key string, value any) {
 
 }
 
-func (c Context) GetValue(key string) any {
+func (c *Context) GetValue(key string) any {
 	if c.values == nil {
 		return nil
 	}

+ 2 - 2
server/utils/password/encode.go

@@ -7,11 +7,11 @@ import (
 
 // Encode 对密码两次md5加盐
 func Encode(password string) string {
-	encodePwd := md5Encode(md5Encode(password+"pmail") + "pmail2023")
+	encodePwd := Md5Encode(Md5Encode(password+"pmail") + "pmail2023")
 	return encodePwd
 }
 
-func md5Encode(str string) string {
+func Md5Encode(str string) string {
 	h := md5.New()
 	h.Write([]byte(str))
 	return hex.EncodeToString(h.Sum(nil))

+ 1 - 1
server/utils/send/send.go

@@ -128,7 +128,7 @@ func Forward(ctx *context.Context, e *parsemail.Email, forwardAddress string) er
 
 func Send(ctx *context.Context, e *parsemail.Email) (error, map[string]error) {
 
-	b := e.BuildBytes(ctx)
+	b := e.BuildBytes(ctx, true)
 
 	var to []*parsemail.User
 	to = append(append(append(to, e.To...), e.Cc...), e.Bcc...)