[Improve] SMTP & IMAP (#321)
* [Temporarily Fix] SSL Can NOT Renew * SMTP and IMAP Improve SMTP With STARTTLS 行为变更 && IMAP 搜索功能尝试实现 * 修复一些客户端玄学问题
This commit is contained in:
parent
bb1da921c8
commit
9753b2e09f
@ -5,6 +5,13 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Jinnrry/pmail/config"
|
||||
"github.com/Jinnrry/pmail/models"
|
||||
"github.com/Jinnrry/pmail/utils/context"
|
||||
@ -14,12 +21,6 @@ import (
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cast"
|
||||
"io"
|
||||
"mime"
|
||||
"net/textproto"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
@ -166,6 +167,10 @@ func (e *Email) BuildCc2String() string {
|
||||
return users2String(e.Cc)
|
||||
}
|
||||
|
||||
func (e *Email) BuildBcc2String() string {
|
||||
return users2String(e.Bcc)
|
||||
}
|
||||
|
||||
func NewEmailFromModel(d models.Email) *Email {
|
||||
|
||||
var To []*User
|
||||
@ -461,27 +466,63 @@ func (e *Email) ForwardBuildBytes(ctx *context.Context, sender *models.User) []b
|
||||
}
|
||||
|
||||
func (e *Email) BuildPart(ctx *context.Context, loc []int) []byte {
|
||||
if len(loc) < 2 {
|
||||
if len(loc) == 0 {
|
||||
return nil
|
||||
}
|
||||
if loc[0] == 1 && loc[1] == 2 {
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(e.HTML)
|
||||
encoded += "\r\n"
|
||||
|
||||
return []byte(encoded)
|
||||
}
|
||||
if loc[0] == 1 && loc[1] == 1 {
|
||||
if len(e.Text) == 0 {
|
||||
encoded := base64.StdEncoding.EncodeToString(e.HTML)
|
||||
// 处理顶层 part (part 1 = alternative, part 2+ = attachments)
|
||||
if len(loc) == 1 {
|
||||
partIdx := loc[0]
|
||||
if partIdx == 1 {
|
||||
// Part 1 是 alternative,不能直接获取,需要获取子部分
|
||||
return nil
|
||||
}
|
||||
// Part 2, 3, ... 是附件
|
||||
attachIdx := partIdx - 2
|
||||
if attachIdx >= 0 && attachIdx < len(e.Attachments) {
|
||||
encoded := base64.StdEncoding.EncodeToString(e.Attachments[attachIdx].Content)
|
||||
encoded += "\r\n"
|
||||
|
||||
return []byte(encoded)
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(e.Text)
|
||||
encoded += "\r\n"
|
||||
return nil
|
||||
}
|
||||
|
||||
return []byte(encoded)
|
||||
// 处理 alternative 的子部分 (1.1, 1.2)
|
||||
if loc[0] == 1 && len(loc) >= 2 {
|
||||
subIdx := loc[1]
|
||||
|
||||
// 根据 BODYSTRUCTURE 的构建顺序:先 text,后 html
|
||||
// 如果只有一个存在,那个就是 1.1
|
||||
hasText := len(e.Text) > 0
|
||||
hasHtml := len(e.HTML) > 0
|
||||
|
||||
if hasText && hasHtml {
|
||||
// 两者都有: 1.1=text, 1.2=html
|
||||
if subIdx == 1 {
|
||||
encoded := base64.StdEncoding.EncodeToString(e.Text)
|
||||
encoded += "\r\n"
|
||||
return []byte(encoded)
|
||||
}
|
||||
if subIdx == 2 {
|
||||
encoded := base64.StdEncoding.EncodeToString(e.HTML)
|
||||
encoded += "\r\n"
|
||||
return []byte(encoded)
|
||||
}
|
||||
} else if hasText {
|
||||
// 只有 text: 1.1=text
|
||||
if subIdx == 1 {
|
||||
encoded := base64.StdEncoding.EncodeToString(e.Text)
|
||||
encoded += "\r\n"
|
||||
return []byte(encoded)
|
||||
}
|
||||
} else if hasHtml {
|
||||
// 只有 html: 1.1=html
|
||||
if subIdx == 1 {
|
||||
encoded := base64.StdEncoding.EncodeToString(e.HTML)
|
||||
encoded += "\r\n"
|
||||
return []byte(encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@ -3,6 +3,10 @@ package imap_server
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"mime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Jinnrry/pmail/config"
|
||||
"github.com/Jinnrry/pmail/dto/parsemail"
|
||||
"github.com/Jinnrry/pmail/dto/response"
|
||||
@ -12,11 +16,95 @@ import (
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapserver"
|
||||
"github.com/spf13/cast"
|
||||
"mime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// userToAddress 将 parsemail.User 转换为 imap.Address
|
||||
func userToAddress(u *parsemail.User) imap.Address {
|
||||
if u == nil {
|
||||
return imap.Address{}
|
||||
}
|
||||
// 解析邮箱地址的 local 和 domain 部分
|
||||
mailbox := u.EmailAddress
|
||||
host := ""
|
||||
if at := strings.LastIndex(u.EmailAddress, "@"); at >= 0 {
|
||||
mailbox = u.EmailAddress[:at]
|
||||
host = u.EmailAddress[at+1:]
|
||||
}
|
||||
return imap.Address{
|
||||
Name: u.Name,
|
||||
Mailbox: mailbox,
|
||||
Host: host,
|
||||
}
|
||||
}
|
||||
|
||||
// usersToAddresses 将用户列表转换为地址列表
|
||||
func usersToAddresses(users []*parsemail.User) []imap.Address {
|
||||
if len(users) == 0 {
|
||||
return nil
|
||||
}
|
||||
addrs := make([]imap.Address, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u != nil && u.EmailAddress != "" {
|
||||
addrs = append(addrs, userToAddress(u))
|
||||
}
|
||||
}
|
||||
if len(addrs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
// buildEnvelope 构建 IMAP ENVELOPE 结构
|
||||
func buildEnvelope(email *response.EmailResponseData, traEmail *parsemail.Email) *imap.Envelope {
|
||||
// From 地址
|
||||
var from []imap.Address
|
||||
if email.FromAddress != "" {
|
||||
mailbox := email.FromAddress
|
||||
host := ""
|
||||
if at := strings.LastIndex(email.FromAddress, "@"); at >= 0 {
|
||||
mailbox = email.FromAddress[:at]
|
||||
host = email.FromAddress[at+1:]
|
||||
}
|
||||
from = []imap.Address{{
|
||||
Name: email.FromName,
|
||||
Mailbox: mailbox,
|
||||
Host: host,
|
||||
}}
|
||||
}
|
||||
|
||||
// Sender (如果没有单独的 sender,使用 from)
|
||||
var sender []imap.Address
|
||||
if traEmail.Sender != nil && traEmail.Sender.EmailAddress != "" {
|
||||
sender = []imap.Address{userToAddress(traEmail.Sender)}
|
||||
} else {
|
||||
sender = from
|
||||
}
|
||||
|
||||
// Reply-To
|
||||
var replyTo []imap.Address
|
||||
if len(traEmail.ReplyTo) > 0 {
|
||||
replyTo = usersToAddresses(traEmail.ReplyTo)
|
||||
} else {
|
||||
replyTo = from
|
||||
}
|
||||
|
||||
// Message-ID
|
||||
messageID := fmt.Sprintf("<%d@%s>", email.Id, config.Instance.Domain)
|
||||
|
||||
return &imap.Envelope{
|
||||
Date: email.CreateTime,
|
||||
Subject: email.Subject,
|
||||
From: from,
|
||||
Sender: sender,
|
||||
ReplyTo: replyTo,
|
||||
To: usersToAddresses(traEmail.To),
|
||||
Cc: usersToAddresses(traEmail.Cc),
|
||||
Bcc: usersToAddresses(traEmail.Bcc),
|
||||
MessageID: messageID,
|
||||
// InReplyTo 和 References 暂不支持
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serverSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, options *imap.FetchOptions) error {
|
||||
switch numSet.(type) {
|
||||
case imap.SeqSet:
|
||||
@ -91,33 +179,27 @@ func bsAttachment(filename, mime string, size uint32, encoding string) *imap.Bod
|
||||
}
|
||||
|
||||
// multipart/alternative:text + html
|
||||
// 注意:BodyStructureMultiPart 必须至少有一个子元素,否则 go-imap 会 panic
|
||||
func bsAlternative(text, html *imap.BodyStructureSinglePart) *imap.BodyStructureMultiPart {
|
||||
if text == nil && html == nil {
|
||||
return &imap.BodyStructureMultiPart{
|
||||
Subtype: "alternative",
|
||||
Children: []imap.BodyStructure{},
|
||||
Extended: &imap.BodyStructureMultiPartExt{},
|
||||
}
|
||||
var children []imap.BodyStructure
|
||||
|
||||
if text != nil {
|
||||
children = append(children, text)
|
||||
}
|
||||
if text == nil {
|
||||
return &imap.BodyStructureMultiPart{
|
||||
Subtype: "alternative",
|
||||
Children: []imap.BodyStructure{html},
|
||||
Extended: &imap.BodyStructureMultiPartExt{}, // 可选:Params/Disposition/Language/Location
|
||||
}
|
||||
if html != nil {
|
||||
children = append(children, html)
|
||||
}
|
||||
if html == nil {
|
||||
return &imap.BodyStructureMultiPart{
|
||||
Subtype: "alternative",
|
||||
Children: []imap.BodyStructure{text},
|
||||
Extended: &imap.BodyStructureMultiPartExt{}, // 可选:Params/Disposition/Language/Location
|
||||
}
|
||||
|
||||
// 如果没有任何内容,创建一个空的 text/plain 占位符
|
||||
// 这可以避免 go-imap 的 panic: "imap.BodyStructureMultiPart must have at least one child"
|
||||
if len(children) == 0 {
|
||||
children = append(children, bsTextPlain(0, 0))
|
||||
}
|
||||
|
||||
return &imap.BodyStructureMultiPart{
|
||||
Subtype: "alternative",
|
||||
Children: []imap.BodyStructure{text, html},
|
||||
Extended: &imap.BodyStructureMultiPartExt{}, // 可选:Params/Disposition/Language/Location
|
||||
Children: children,
|
||||
Extended: &imap.BodyStructureMultiPartExt{},
|
||||
}
|
||||
}
|
||||
|
||||
@ -145,6 +227,10 @@ func write(ctx *context.Context, w *imapserver.FetchWriter, emailList []*respons
|
||||
if options.UID {
|
||||
writer.WriteUID(imap.UID(email.UeId))
|
||||
}
|
||||
if options.Envelope {
|
||||
env := buildEnvelope(email, traEmail)
|
||||
writer.WriteEnvelope(env)
|
||||
}
|
||||
if options.BodyStructure != nil {
|
||||
var html, text *imap.BodyStructureSinglePart
|
||||
if len(traEmail.HTML) > 0 {
|
||||
@ -185,30 +271,21 @@ func write(ctx *context.Context, w *imapserver.FetchWriter, emailList []*respons
|
||||
}
|
||||
emailContent := traEmail.BuildBytes(ctx, false)
|
||||
|
||||
if section.Specifier == imap.PartSpecifierNone || section.Specifier == imap.PartSpecifierText {
|
||||
if len(section.Part) == 2 {
|
||||
// 取text部分
|
||||
bodyWriter := writer.WriteBodySection(section, cast.ToInt64(len(emailContent)))
|
||||
bodyWriter.Write(traEmail.BuildPart(ctx, section.Part))
|
||||
bodyWriter.Close()
|
||||
} else {
|
||||
bodyWriter := writer.WriteBodySection(section, cast.ToInt64(len(emailContent)))
|
||||
bodyWriter.Write(emailContent)
|
||||
bodyWriter.Close()
|
||||
}
|
||||
}
|
||||
if section.Specifier == imap.PartSpecifierHeader {
|
||||
// 优先检查是否请求 HEADER.FIELDS
|
||||
if len(section.HeaderFields) > 0 || section.Specifier == imap.PartSpecifierHeader {
|
||||
var b bytes.Buffer
|
||||
fields := section.HeaderFields
|
||||
|
||||
if fields == nil || len(fields) == 0 {
|
||||
// 没有指定字段,返回所有常见头部
|
||||
fields = []string{
|
||||
"date", "subject", "from", "to", "cc", "message-id", "content-type",
|
||||
}
|
||||
}
|
||||
|
||||
for _, field := range fields {
|
||||
switch field {
|
||||
fieldLower := strings.ToLower(field)
|
||||
switch fieldLower {
|
||||
case "date":
|
||||
fmt.Fprintf(&b, "Date: %s\r\n", email.CreateTime.Format(time.RFC1123Z))
|
||||
case "subject":
|
||||
@ -219,25 +296,72 @@ func write(ctx *context.Context, w *imapserver.FetchWriter, emailList []*respons
|
||||
} else {
|
||||
fmt.Fprintf(&b, "From: %s\r\n", email.FromAddress)
|
||||
}
|
||||
case "sender":
|
||||
if email.FromName != "" {
|
||||
fmt.Fprintf(&b, "Sender: %s <%s>\r\n", mime.QEncoding.Encode("utf-8", email.FromName), email.FromAddress)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Sender: %s\r\n", email.FromAddress)
|
||||
}
|
||||
case "reply-to":
|
||||
if len(traEmail.ReplyTo) > 0 && traEmail.ReplyTo[0].EmailAddress != "" {
|
||||
if traEmail.ReplyTo[0].Name != "" {
|
||||
fmt.Fprintf(&b, "Reply-To: %s <%s>\r\n", mime.QEncoding.Encode("utf-8", traEmail.ReplyTo[0].Name), traEmail.ReplyTo[0].EmailAddress)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Reply-To: %s\r\n", traEmail.ReplyTo[0].EmailAddress)
|
||||
}
|
||||
}
|
||||
case "to":
|
||||
fmt.Fprintf(&b, "To: %s\r\n", traEmail.BuildTo2String())
|
||||
toStr := traEmail.BuildTo2String()
|
||||
if toStr != "" {
|
||||
fmt.Fprintf(&b, "To: %s\r\n", toStr)
|
||||
}
|
||||
case "cc":
|
||||
if len(traEmail.Cc) > 0 {
|
||||
fmt.Fprintf(&b, "Cc: %s\r\n", traEmail.BuildCc2String())
|
||||
}
|
||||
case "bcc":
|
||||
if len(traEmail.Bcc) > 0 {
|
||||
fmt.Fprintf(&b, "Bcc: %s\r\n", traEmail.BuildBcc2String())
|
||||
}
|
||||
case "message-id":
|
||||
fmt.Fprintf(&b, "Message-ID: %s\r\n", fmt.Sprintf("%d@%s", email.Id, config.Instance.Domain))
|
||||
fmt.Fprintf(&b, "Message-ID: <%d@%s>\r\n", 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])
|
||||
if len(args) >= 2 {
|
||||
fmt.Fprintf(&b, "%s%s\r\n", args[0], args[1])
|
||||
}
|
||||
case "references", "in-reply-to", "thread-topic", "thread-index", "x-priority", "x-mailer", "x-android-message-id":
|
||||
// 这些头部我们目前不存储,跳过
|
||||
default:
|
||||
// 其他未知头部,忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 添加结束空行
|
||||
b.WriteString("\r\n")
|
||||
|
||||
bodyWriter := writer.WriteBodySection(section, cast.ToInt64(b.Len()))
|
||||
bodyWriter.Write(b.Bytes())
|
||||
bodyWriter.Close()
|
||||
} else if section.Specifier == imap.PartSpecifierNone || section.Specifier == imap.PartSpecifierText {
|
||||
if len(section.Part) >= 1 {
|
||||
// 获取指定 part 的内容
|
||||
partContent := traEmail.BuildPart(ctx, section.Part)
|
||||
if partContent != nil {
|
||||
bodyWriter := writer.WriteBodySection(section, cast.ToInt64(len(partContent)))
|
||||
bodyWriter.Write(partContent)
|
||||
bodyWriter.Close()
|
||||
} else {
|
||||
// Part 不存在,返回空
|
||||
bodyWriter := writer.WriteBodySection(section, 0)
|
||||
bodyWriter.Close()
|
||||
}
|
||||
} else {
|
||||
bodyWriter := writer.WriteBodySection(section, cast.ToInt64(len(emailContent)))
|
||||
bodyWriter.Write(emailContent)
|
||||
bodyWriter.Close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
writer.Close()
|
||||
}
|
||||
|
||||
@ -1,31 +1,30 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// Search implements the IMAP SEARCH command with full criteria support
|
||||
// Supports: UID, SeqNum, Date filters, Header search, Body/Text search,
|
||||
// Flag filters, Size filters, and logical combinations (NOT, OR)
|
||||
func (s *serverSession) Search(kind imapserver.NumKind, criteria *imap.SearchCriteria, options *imap.SearchOptions) (*imap.SearchData, error) {
|
||||
retList := []*response.UserEmailUIDData{}
|
||||
log.WithContext(s.ctx).Debugf("IMAP SEARCH: mailbox=%s, kind=%v, criteria=%+v", s.currentMailbox, kind, criteria)
|
||||
|
||||
if len(criteria.UID) > 0 {
|
||||
for _, uidSet := range criteria.UID {
|
||||
for _, uid := range uidSet {
|
||||
res := list.GetUEListByUID(s.ctx, s.currentMailbox, cast.ToInt(uint32(uid.Start)), cast.ToInt(uint32(uid.Stop)), nil)
|
||||
retList = append(retList, res...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
res := list.GetUEListByUID(s.ctx, s.currentMailbox, 0, 0, nil)
|
||||
retList = append(retList, res...)
|
||||
// Use the new comprehensive search function
|
||||
retList, err := list.SearchEmails(s.ctx, s.currentMailbox, criteria)
|
||||
if err != nil {
|
||||
log.WithContext(s.ctx).Errorf("IMAP SEARCH error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := &imap.SearchData{}
|
||||
|
||||
if kind == imapserver.NumKindSeq {
|
||||
// Return sequence numbers
|
||||
idList := imap.SeqSet{}
|
||||
for _, data := range retList {
|
||||
idList = append(idList, imap.SeqRange{
|
||||
@ -35,7 +34,28 @@ func (s *serverSession) Search(kind imapserver.NumKind, criteria *imap.SearchCri
|
||||
}
|
||||
ret.All = idList
|
||||
ret.Count = uint32(len(retList))
|
||||
|
||||
// Handle ESEARCH options
|
||||
if options != nil {
|
||||
if options.ReturnMin && len(retList) > 0 {
|
||||
ret.Min = cast.ToUint32(retList[0].SerialNumber)
|
||||
for _, data := range retList {
|
||||
if cast.ToUint32(data.SerialNumber) < ret.Min {
|
||||
ret.Min = cast.ToUint32(data.SerialNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.ReturnMax && len(retList) > 0 {
|
||||
ret.Max = cast.ToUint32(retList[0].SerialNumber)
|
||||
for _, data := range retList {
|
||||
if cast.ToUint32(data.SerialNumber) > ret.Max {
|
||||
ret.Max = cast.ToUint32(data.SerialNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Return UIDs
|
||||
idList := imap.UIDSet{}
|
||||
for _, data := range retList {
|
||||
idList = append(idList, imap.UIDRange{
|
||||
@ -46,6 +66,28 @@ func (s *serverSession) Search(kind imapserver.NumKind, criteria *imap.SearchCri
|
||||
ret.UID = true
|
||||
ret.All = idList
|
||||
ret.Count = uint32(len(retList))
|
||||
|
||||
// Handle ESEARCH options
|
||||
if options != nil {
|
||||
if options.ReturnMin && len(retList) > 0 {
|
||||
ret.Min = cast.ToUint32(retList[0].ID)
|
||||
for _, data := range retList {
|
||||
if cast.ToUint32(data.ID) < ret.Min {
|
||||
ret.Min = cast.ToUint32(data.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.ReturnMax && len(retList) > 0 {
|
||||
ret.Max = cast.ToUint32(retList[0].ID)
|
||||
for _, data := range retList {
|
||||
if cast.ToUint32(data.ID) > ret.Max {
|
||||
ret.Max = cast.ToUint32(data.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.WithContext(s.ctx).Debugf("IMAP SEARCH result: count=%d", ret.Count)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
@ -5,6 +5,12 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
oerrors "errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Jinnrry/pmail/config"
|
||||
"github.com/Jinnrry/pmail/db"
|
||||
"github.com/Jinnrry/pmail/dto/parsemail"
|
||||
@ -20,14 +26,15 @@ import (
|
||||
"github.com/mileusna/spf"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cast"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
. "xorm.io/builder"
|
||||
)
|
||||
|
||||
// DropUnknownRecipientEmails 是代码级别的功能开关
|
||||
// 当设置为 true 时,发给不存在用户的邮件将被直接丢弃,不会保存到数据库
|
||||
// 这可以有效防止扫描器产生的垃圾邮件被存入第一个用户(管理员)的邮箱
|
||||
// 注意:此开关只能通过修改代码来更改,不暴露给用户配置
|
||||
const DropUnknownRecipientEmails = true
|
||||
|
||||
func (s *Session) Data(r io.Reader) error {
|
||||
|
||||
ctx := s.Ctx
|
||||
@ -300,8 +307,24 @@ func saveEmail(ctx *context.Context, size int, email *parsemail.Email, sendUserI
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 找不到收件人
|
||||
// 如果启用了保护功能,且 DKIM 验证失败,则丢弃邮件
|
||||
// DKIM 验证通过的邮件即使收件人不存在也应交给管理员,避免误丢弃合法邮件
|
||||
if DropUnknownRecipientEmails && !dkimStatus {
|
||||
// 启用了丢弃未知收件人邮件的保护功能,且 DKIM 未通过
|
||||
// 直接删除已插入的邮件记录,不关联任何用户
|
||||
log.WithContext(ctx).Infof("收件人不存在且DKIM验证失败,丢弃邮件: %s -> %v", email.From.EmailAddress, accounts)
|
||||
_, delErr := db.Instance.Delete(&models.Email{Id: modelEmail.Id})
|
||||
if delErr != nil {
|
||||
log.WithContext(ctx).Errorf("db delete error:%+v", delErr.Error())
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
// DKIM 验证通过或未启用保护功能时,邮件丢给管理员账号
|
||||
if dkimStatus {
|
||||
log.WithContext(ctx).Infof("收件人不存在但DKIM验证通过,转交管理员: %s -> %v", email.From.EmailAddress, accounts)
|
||||
}
|
||||
err = db.Instance.Table(&models.User{}).Where("is_admin=1").Find(&users)
|
||||
// 当邮件找不到收件人的时候,邮件全部丢给管理员账号
|
||||
for _, user := range users {
|
||||
ue := models.UserEmail{EmailID: modelEmail.Id, UserID: user.ID, Status: cast.ToInt8(email.Status)}
|
||||
_, err = db.Instance.Insert(&ue)
|
||||
|
||||
@ -2,10 +2,11 @@ package smtp_server
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"time"
|
||||
|
||||
"github.com/Jinnrry/pmail/config"
|
||||
"github.com/emersion/go-smtp"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"time"
|
||||
)
|
||||
|
||||
var instance *smtp.Server
|
||||
@ -31,11 +32,12 @@ func StartWithTLSNew() {
|
||||
log.Fatal(err)
|
||||
return
|
||||
}
|
||||
// Configure the TLS support
|
||||
// Configure the TLS support for STARTTLS
|
||||
instanceTlsNew.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}}
|
||||
|
||||
log.Println("Starting Smtp With SSL Server Port:", instanceTlsNew.Addr)
|
||||
if err := instanceTlsNew.ListenAndServeTLS(); err != nil {
|
||||
log.Println("Starting Smtp With STARTTLS Server Port:", instanceTlsNew.Addr)
|
||||
// 587端口使用STARTTLS(先明文连接,再升级TLS),而非隐式TLS
|
||||
if err := instanceTlsNew.ListenAndServe(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
475
server/services/list/imap_search.go
Normal file
475
server/services/list/imap_search.go
Normal file
@ -0,0 +1,475 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Jinnrry/pmail/db"
|
||||
"github.com/Jinnrry/pmail/dto/response"
|
||||
"github.com/Jinnrry/pmail/models"
|
||||
"github.com/Jinnrry/pmail/utils/context"
|
||||
"github.com/emersion/go-imap/v2"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// IMAPSearchCriteria represents the search criteria for IMAP SEARCH command
|
||||
type IMAPSearchCriteria struct {
|
||||
// UID and sequence number sets
|
||||
UIDs []imap.UIDSet
|
||||
SeqNums []imap.SeqSet
|
||||
|
||||
// Date filters (only date is used, time and timezone are ignored)
|
||||
Since time.Time // Internal date since
|
||||
Before time.Time // Internal date before
|
||||
SentSince time.Time // Sent date since
|
||||
SentBefore time.Time // Sent date before
|
||||
|
||||
// Header field searches
|
||||
Header []HeaderField // Key-Value pairs for header search
|
||||
|
||||
// Content search
|
||||
Body []string // Search in body
|
||||
Text []string // Search in headers + body
|
||||
|
||||
// Flag filters
|
||||
Flag []imap.Flag // Messages with these flags
|
||||
NotFlag []imap.Flag // Messages without these flags
|
||||
|
||||
// Size filters
|
||||
Larger int64 // Messages larger than this (bytes)
|
||||
Smaller int64 // Messages smaller than this (bytes)
|
||||
|
||||
// Logical combinations
|
||||
Not []IMAPSearchCriteria
|
||||
Or [][2]IMAPSearchCriteria
|
||||
}
|
||||
|
||||
// HeaderField represents a header field search criterion
|
||||
type HeaderField struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// IMAPSearchResult represents a search result item
|
||||
type IMAPSearchResult struct {
|
||||
UID int // user_email.id as UID
|
||||
SeqNum int // sequence number
|
||||
EmailID int // email.id
|
||||
IsRead int8 // read status
|
||||
SerialNumber int // serial number
|
||||
}
|
||||
|
||||
// SearchEmails performs IMAP search based on criteria
|
||||
func SearchEmails(ctx *context.Context, groupName string, criteria *imap.SearchCriteria) ([]*response.UserEmailUIDData, error) {
|
||||
// First get the base list for the mailbox
|
||||
baseList := GetUEListByUID(ctx, groupName, 0, 0, nil)
|
||||
if len(baseList) == 0 {
|
||||
return baseList, nil
|
||||
}
|
||||
|
||||
// If no criteria specified, return all
|
||||
if criteria == nil || isEmptyCriteria(criteria) {
|
||||
return baseList, nil
|
||||
}
|
||||
|
||||
// Build UID to sequence number mapping
|
||||
uidToSeq := make(map[int]int)
|
||||
seqToUID := make(map[int]int)
|
||||
for _, item := range baseList {
|
||||
uidToSeq[item.ID] = item.SerialNumber
|
||||
seqToUID[item.SerialNumber] = item.ID
|
||||
}
|
||||
|
||||
// Filter by UID sets
|
||||
if len(criteria.UID) > 0 {
|
||||
baseList = filterByUIDSets(baseList, criteria.UID)
|
||||
}
|
||||
|
||||
// Filter by sequence number sets
|
||||
if len(criteria.SeqNum) > 0 {
|
||||
baseList = filterBySeqNumSets(baseList, criteria.SeqNum)
|
||||
}
|
||||
|
||||
// For more complex filters, we need to fetch email data
|
||||
if needsEmailData(criteria) {
|
||||
baseList = filterWithEmailData(ctx, baseList, criteria)
|
||||
}
|
||||
|
||||
// Filter by flags (is_read status in this implementation)
|
||||
if len(criteria.Flag) > 0 || len(criteria.NotFlag) > 0 {
|
||||
baseList = filterByFlags(baseList, criteria.Flag, criteria.NotFlag)
|
||||
}
|
||||
|
||||
// Handle NOT criteria
|
||||
if len(criteria.Not) > 0 {
|
||||
for _, notCriteria := range criteria.Not {
|
||||
baseList = applyNotCriteria(ctx, groupName, baseList, ¬Criteria)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OR criteria
|
||||
if len(criteria.Or) > 0 {
|
||||
baseList = applyOrCriteria(ctx, groupName, baseList, criteria.Or)
|
||||
}
|
||||
|
||||
return baseList, nil
|
||||
}
|
||||
|
||||
// isEmptyCriteria checks if the search criteria is empty
|
||||
func isEmptyCriteria(criteria *imap.SearchCriteria) bool {
|
||||
return len(criteria.UID) == 0 &&
|
||||
len(criteria.SeqNum) == 0 &&
|
||||
criteria.Since.IsZero() &&
|
||||
criteria.Before.IsZero() &&
|
||||
criteria.SentSince.IsZero() &&
|
||||
criteria.SentBefore.IsZero() &&
|
||||
len(criteria.Header) == 0 &&
|
||||
len(criteria.Body) == 0 &&
|
||||
len(criteria.Text) == 0 &&
|
||||
len(criteria.Flag) == 0 &&
|
||||
len(criteria.NotFlag) == 0 &&
|
||||
criteria.Larger == 0 &&
|
||||
criteria.Smaller == 0 &&
|
||||
len(criteria.Not) == 0 &&
|
||||
len(criteria.Or) == 0
|
||||
}
|
||||
|
||||
// needsEmailData checks if we need to load email data for filtering
|
||||
func needsEmailData(criteria *imap.SearchCriteria) bool {
|
||||
return !criteria.Since.IsZero() ||
|
||||
!criteria.Before.IsZero() ||
|
||||
!criteria.SentSince.IsZero() ||
|
||||
!criteria.SentBefore.IsZero() ||
|
||||
len(criteria.Header) > 0 ||
|
||||
len(criteria.Body) > 0 ||
|
||||
len(criteria.Text) > 0 ||
|
||||
criteria.Larger > 0 ||
|
||||
criteria.Smaller > 0
|
||||
}
|
||||
|
||||
// filterByUIDSets filters the list by UID sets
|
||||
func filterByUIDSets(list []*response.UserEmailUIDData, uidSets []imap.UIDSet) []*response.UserEmailUIDData {
|
||||
var result []*response.UserEmailUIDData
|
||||
for _, item := range list {
|
||||
for _, uidSet := range uidSets {
|
||||
if uidSet.Contains(imap.UID(item.ID)) {
|
||||
result = append(result, item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// filterBySeqNumSets filters the list by sequence number sets
|
||||
func filterBySeqNumSets(list []*response.UserEmailUIDData, seqSets []imap.SeqSet) []*response.UserEmailUIDData {
|
||||
var result []*response.UserEmailUIDData
|
||||
for _, item := range list {
|
||||
for _, seqSet := range seqSets {
|
||||
if seqSet.Contains(uint32(item.SerialNumber)) {
|
||||
result = append(result, item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// filterByFlags filters by message flags
|
||||
func filterByFlags(list []*response.UserEmailUIDData, flags []imap.Flag, notFlags []imap.Flag) []*response.UserEmailUIDData {
|
||||
var result []*response.UserEmailUIDData
|
||||
for _, item := range list {
|
||||
match := true
|
||||
|
||||
// Check required flags
|
||||
for _, flag := range flags {
|
||||
if !hasFlag(item, flag) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check flags that should NOT be present
|
||||
if match {
|
||||
for _, flag := range notFlags {
|
||||
if hasFlag(item, flag) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// hasFlag checks if a message has a specific flag
|
||||
func hasFlag(item *response.UserEmailUIDData, flag imap.Flag) bool {
|
||||
switch flag {
|
||||
case imap.FlagSeen:
|
||||
return item.IsRead == 1
|
||||
case imap.FlagDeleted:
|
||||
return item.Status == 3
|
||||
case imap.FlagDraft:
|
||||
return item.Status == 4
|
||||
case imap.FlagJunk:
|
||||
return item.Status == 5
|
||||
// For flags we don't track, return false
|
||||
case imap.FlagAnswered, imap.FlagFlagged:
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// filterWithEmailData loads email data and applies filters that need it
|
||||
func filterWithEmailData(ctx *context.Context, list []*response.UserEmailUIDData, criteria *imap.SearchCriteria) []*response.UserEmailUIDData {
|
||||
if len(list) == 0 {
|
||||
return list
|
||||
}
|
||||
|
||||
// Get email IDs
|
||||
var emailIDs []int
|
||||
ueMap := make(map[int]*response.UserEmailUIDData) // emailID -> UserEmailUIDData
|
||||
for _, item := range list {
|
||||
emailIDs = append(emailIDs, item.EmailID)
|
||||
ueMap[item.EmailID] = item
|
||||
}
|
||||
|
||||
// Fetch emails from database
|
||||
var emails []models.Email
|
||||
err := db.Instance.Table("email").In("id", emailIDs).Find(&emails)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("Failed to fetch emails for search: %v", err)
|
||||
return list
|
||||
}
|
||||
|
||||
// Build email map
|
||||
emailMap := make(map[int]*models.Email)
|
||||
for i := range emails {
|
||||
emailMap[emails[i].Id] = &emails[i]
|
||||
}
|
||||
|
||||
// Filter
|
||||
var result []*response.UserEmailUIDData
|
||||
for _, item := range list {
|
||||
email, ok := emailMap[item.EmailID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if matchesEmailCriteria(email, criteria) {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// matchesEmailCriteria checks if an email matches the search criteria
|
||||
func matchesEmailCriteria(email *models.Email, criteria *imap.SearchCriteria) bool {
|
||||
// Date filters (internal date = CreateTime)
|
||||
if !criteria.Since.IsZero() {
|
||||
if email.CreateTime.Before(truncateToDate(criteria.Since)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !criteria.Before.IsZero() {
|
||||
if !email.CreateTime.Before(truncateToDate(criteria.Before)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Sent date filters
|
||||
if !criteria.SentSince.IsZero() {
|
||||
if email.SendDate.Before(truncateToDate(criteria.SentSince)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !criteria.SentBefore.IsZero() {
|
||||
if !email.SendDate.Before(truncateToDate(criteria.SentBefore)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Size filters
|
||||
if criteria.Larger > 0 {
|
||||
if int64(email.Size) <= criteria.Larger {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if criteria.Smaller > 0 {
|
||||
if int64(email.Size) >= criteria.Smaller {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Header field search
|
||||
for _, hf := range criteria.Header {
|
||||
if !matchesHeader(email, hf.Key, hf.Value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Body search
|
||||
for _, pattern := range criteria.Body {
|
||||
if !matchesBody(email, pattern) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Text search (headers + body)
|
||||
for _, pattern := range criteria.Text {
|
||||
if !matchesText(email, pattern) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// truncateToDate removes time component from a time.Time
|
||||
func truncateToDate(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// matchesHeader checks if an email matches a header field search
|
||||
func matchesHeader(email *models.Email, key, value string) bool {
|
||||
key = strings.ToLower(key)
|
||||
value = strings.ToLower(value)
|
||||
|
||||
switch key {
|
||||
case "subject":
|
||||
return strings.Contains(strings.ToLower(email.Subject), value)
|
||||
case "from":
|
||||
return strings.Contains(strings.ToLower(email.FromAddress), value) ||
|
||||
strings.Contains(strings.ToLower(email.FromName), value)
|
||||
case "to":
|
||||
return strings.Contains(strings.ToLower(email.To), value)
|
||||
case "cc":
|
||||
return strings.Contains(strings.ToLower(email.Cc), value)
|
||||
case "bcc":
|
||||
return strings.Contains(strings.ToLower(email.Bcc), value)
|
||||
case "reply-to":
|
||||
return strings.Contains(strings.ToLower(email.ReplyTo), value)
|
||||
case "sender":
|
||||
return strings.Contains(strings.ToLower(email.Sender), value)
|
||||
default:
|
||||
// For unknown headers, we can't match
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// matchesBody checks if an email body matches the pattern
|
||||
func matchesBody(email *models.Email, pattern string) bool {
|
||||
pattern = strings.ToLower(pattern)
|
||||
|
||||
// Check text body
|
||||
if email.Text.Valid && strings.Contains(strings.ToLower(email.Text.String), pattern) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check HTML body
|
||||
if email.Html.Valid && strings.Contains(strings.ToLower(email.Html.String), pattern) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesText checks if an email (headers + body) matches the pattern
|
||||
func matchesText(email *models.Email, pattern string) bool {
|
||||
pattern = strings.ToLower(pattern)
|
||||
|
||||
// Check headers
|
||||
if strings.Contains(strings.ToLower(email.Subject), pattern) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(email.FromAddress), pattern) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(email.FromName), pattern) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(email.To), pattern) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(email.Cc), pattern) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(email.Bcc), pattern) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check body
|
||||
return matchesBody(email, pattern)
|
||||
}
|
||||
|
||||
// applyNotCriteria applies NOT criteria
|
||||
func applyNotCriteria(ctx *context.Context, groupName string, list []*response.UserEmailUIDData, notCriteria *imap.SearchCriteria) []*response.UserEmailUIDData {
|
||||
// Get the list of items that match the NOT criteria
|
||||
matchedList, _ := SearchEmails(ctx, groupName, notCriteria)
|
||||
|
||||
// Build a set of matched UIDs
|
||||
matchedUIDs := make(map[int]bool)
|
||||
for _, item := range matchedList {
|
||||
matchedUIDs[item.ID] = true
|
||||
}
|
||||
|
||||
// Return items that are NOT in the matched set
|
||||
var result []*response.UserEmailUIDData
|
||||
for _, item := range list {
|
||||
if !matchedUIDs[item.ID] {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// applyOrCriteria applies OR criteria
|
||||
func applyOrCriteria(ctx *context.Context, groupName string, list []*response.UserEmailUIDData, orCriteria [][2]imap.SearchCriteria) []*response.UserEmailUIDData {
|
||||
if len(orCriteria) == 0 {
|
||||
return list
|
||||
}
|
||||
|
||||
// Build a set of current UIDs for intersection
|
||||
currentUIDs := make(map[int]bool)
|
||||
for _, item := range list {
|
||||
currentUIDs[item.ID] = true
|
||||
}
|
||||
|
||||
// For each OR pair, find items matching either condition
|
||||
resultUIDs := make(map[int]bool)
|
||||
|
||||
for _, pair := range orCriteria {
|
||||
// Get matches for first condition
|
||||
matches1, _ := SearchEmails(ctx, groupName, &pair[0])
|
||||
for _, item := range matches1 {
|
||||
if currentUIDs[item.ID] {
|
||||
resultUIDs[item.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Get matches for second condition
|
||||
matches2, _ := SearchEmails(ctx, groupName, &pair[1])
|
||||
for _, item := range matches2 {
|
||||
if currentUIDs[item.ID] {
|
||||
resultUIDs[item.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build result from original list preserving order
|
||||
var result []*response.UserEmailUIDData
|
||||
for _, item := range list {
|
||||
if resultUIDs[item.ID] {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user