[Improve] SMTP & IMAP (#321)

* [Temporarily Fix] SSL Can NOT Renew

* SMTP and IMAP Improve
SMTP With STARTTLS 行为变更 && IMAP 搜索功能尝试实现

* 修复一些客户端玄学问题
This commit is contained in:
HanaHime 2026-01-17 21:38:08 +08:00 committed by GitHub
parent bb1da921c8
commit 9753b2e09f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 790 additions and 83 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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, &notCriteria)
}
}
// 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
}