diff --git a/server/dto/parsemail/email.go b/server/dto/parsemail/email.go index b10361b..d72db03 100644 --- a/server/dto/parsemail/email.go +++ b/server/dto/parsemail/email.go @@ -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 diff --git a/server/listen/imap_server/session_fetch.go b/server/listen/imap_server/session_fetch.go index 0dce1fc..56a5ce1 100644 --- a/server/listen/imap_server/session_fetch.go +++ b/server/listen/imap_server/session_fetch.go @@ -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() } diff --git a/server/listen/imap_server/session_search.go b/server/listen/imap_server/session_search.go index 865840b..81247fa 100644 --- a/server/listen/imap_server/session_search.go +++ b/server/listen/imap_server/session_search.go @@ -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 } diff --git a/server/listen/smtp_server/read_content.go b/server/listen/smtp_server/read_content.go index c74d3eb..eeb850a 100644 --- a/server/listen/smtp_server/read_content.go +++ b/server/listen/smtp_server/read_content.go @@ -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) diff --git a/server/listen/smtp_server/smtp.go b/server/listen/smtp_server/smtp.go index 94cbe5d..77dd7bc 100644 --- a/server/listen/smtp_server/smtp.go +++ b/server/listen/smtp_server/smtp.go @@ -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) } } diff --git a/server/services/list/imap_search.go b/server/services/list/imap_search.go new file mode 100644 index 0000000..c938bc2 --- /dev/null +++ b/server/services/list/imap_search.go @@ -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 +}