package imap import ( "context" "database/sql" "io" "log/slog" "net/mail" "strings" "time" "postern/internal/model" "postern/internal/persistence" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapserver" ) func (m *imapSession) evalSearchCriteria(criteria *imap.SearchCriteria, msg model.Message, sessionTracker *imapserver.SessionTracker, persistence *persistence.Persistence) bool { if criteria == nil { return false } for _, child := range criteria.Or { if !m.evalSearchCriteria(&child[0], msg, sessionTracker, persistence) && !m.evalSearchCriteria(&child[1], msg, sessionTracker, persistence) { return false } } for _, child := range criteria.Not { if m.evalSearchCriteria(&child, msg, sessionTracker, persistence) { return false } } if !evalSearchFlags(criteria, msg) { return false } if !evalSearchID(criteria, msg, sessionTracker) { return false } if !evalSearchDates(criteria, msg) { return false } if !evalSearchSizes(criteria, msg) { return false } if !evalSearchMessageContent(criteria, msg, persistence) { return false } return true } func evalSearchID(criteria *imap.SearchCriteria, msg model.Message, sessionTracker *imapserver.SessionTracker) bool { for _, uid := range criteria.UID { if !uid.Contains(imap.UID(msg.UID)) { return false } } for _, seq := range criteria.SeqNum { serverSeq, err := msg.ServerSeq(context.Background()) if err != nil { slog.Error("could not get serverSeq", "error", err) return false } if !seq.Contains(sessionTracker.EncodeSeqNum(serverSeq)) { return false } } return true } func evalSearchFlags(criteria *imap.SearchCriteria, msg model.Message) bool { for _, f := range criteria.Flag { found := false for _, hasFlags := range toIMAPFlags(msg.Flags) { if strings.EqualFold(string(f), string(hasFlags)) { found = true break } } if !found { return false } } for _, f := range criteria.NotFlag { for _, hasFlags := range toIMAPFlags(msg.Flags) { if strings.EqualFold(string(f), string(hasFlags)) { return false } } } return true } func evalSearchDates(criteria *imap.SearchCriteria, msg model.Message) bool { // Internal Date (SINCE, BEFORE) idate, _ := time.Parse(time.RFC3339, msg.InternalDate) if !criteria.Since.IsZero() { msgDay := idate.UTC().Truncate(24 * time.Hour) refDay := criteria.Since.UTC().Truncate(24 * time.Hour) if msgDay.Before(refDay) { return false } } if !criteria.Before.IsZero() { msgDay := idate.UTC().Truncate(24 * time.Hour) refDay := criteria.Before.UTC().Truncate(24 * time.Hour) if !msgDay.Before(refDay) { return false } } // Envelope Date (SENTSINCE, SENTBEFORE) d, _ := time.Parse(time.RFC3339, msg.EnvelopeDate.String) if !criteria.SentSince.IsZero() { if msg.EnvelopeDate.String == "" { // searches for missing fields result in a failed search return false } msgDay := d.UTC().Truncate(24 * time.Hour) refDay := criteria.SentSince.UTC().Truncate(24 * time.Hour) if msgDay.Before(refDay) { return false } } if !criteria.SentBefore.IsZero() { if msg.EnvelopeDate.String == "" { // searches for missing fields result in a failed search return false } msgDay := d.UTC().Truncate(24 * time.Hour) refDay := criteria.SentBefore.UTC().Truncate(24 * time.Hour) if !msgDay.Before(refDay) { return false } } return true } func evalSearchSizes(criteria *imap.SearchCriteria, msg model.Message) bool { if criteria.Smaller != 0 { if msg.RFC822Size > criteria.Smaller { return false } } if criteria.Larger != 0 { if msg.RFC822Size < criteria.Larger { return false } } return true } // evalSearchMessageContent compares lowercased strings where applicable. // In the future, consider UTF-8 normalizing the strings first to find more of what was intended to be found. func evalSearchMessageContent(criteria *imap.SearchCriteria, msg model.Message, persistence *persistence.Persistence) bool { // if there's anything left over for full message analysis nonEnvelopeHeader := make([]imap.SearchCriteriaHeaderField, 0, len(criteria.Header)) checkHeader := func(needle string, storedHeader sql.NullString) bool { if needle == "" { return storedHeader.Valid } if !storedHeader.Valid { return false } return strings.Contains(strings.ToLower(storedHeader.String), needle) } for _, h := range criteria.Header { headerValueLower := strings.ToLower(h.Value) switch strings.ToLower(h.Key) { case "subject": if !checkHeader(headerValueLower, msg.EnvelopeSubject) { return false } case "message-id": if !checkHeader(headerValueLower, msg.EnvelopeMessageID) { return false } case "in-reply-to": if !checkHeader(headerValueLower, msg.EnvelopeInReplyTo) { return false } case "from": if !checkHeader(headerValueLower, msg.EnvelopeFrom) { return false } case "sender": if !checkHeader(headerValueLower, msg.EnvelopeSender) { return false } case "reply-to": if !checkHeader(headerValueLower, msg.EnvelopeReplyTo) { return false } case "to": if !checkHeader(headerValueLower, msg.EnvelopeTo) { return false } case "cc": if !checkHeader(headerValueLower, msg.EnvelopeCc) { return false } case "bcc": if !checkHeader(headerValueLower, msg.EnvelopeBcc) { return false } default: nonEnvelopeHeader = append(nonEnvelopeHeader, h) } } // At this point, we need to load the full message from disk if len(nonEnvelopeHeader) > 0 || len(criteria.Text) > 0 || len(criteria.Body) > 0 { readMessage, err := persistence.BlobReader(msg.BlobHash) if err != nil { return false } // TODO decode MIME parsedMessage, err := mail.ReadMessage(readMessage) if err != nil { return false } err = readMessage.Close() if err != nil { return false } for _, h := range nonEnvelopeHeader { headerVal, ok := parsedMessage.Header[h.Key] if !ok { // RFC special case to detect header existence return false } foundInValues := false for _, headerValue := range headerVal { if strings.Contains(strings.ToLower(headerValue), strings.ToLower(h.Value)) { foundInValues = true break } } if !foundInValues { return false } } // need to read body if len(criteria.Body) > 0 || len(criteria.Text) > 0 { messageData, err := io.ReadAll(parsedMessage.Body) if err != nil { return false } // RFC expects case-insensitive search messageDataLower := strings.ToLower(string(messageData)) for _, needle := range criteria.Body { if !strings.Contains(messageDataLower, strings.ToLower(needle)) { return false } } for _, needle := range criteria.Text { needleLower := strings.ToLower(needle) if strings.Contains(messageDataLower, needleLower) { continue } found := false for k, v := range parsedMessage.Header { if strings.Contains(strings.ToLower(k), needleLower) { found = true break } for _, headerValue := range v { if strings.Contains(strings.ToLower(headerValue), needleLower) { found = true break } } if found { break } } if !found { return false } } } } return true }