all repos — postern @ fc287763b9ce6b6f9823e11c0b1173da0446ebc5

Modern mail management

internal/imap/search.go (view raw)

  1package imap
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"io"
  7	"log/slog"
  8	"net/mail"
  9	"strings"
 10	"time"
 11
 12	"postern/internal/model"
 13	"postern/internal/persistence"
 14
 15	"github.com/emersion/go-imap/v2"
 16	"github.com/emersion/go-imap/v2/imapserver"
 17)
 18
 19func (m *imapSession) evalSearchCriteria(criteria *imap.SearchCriteria, msg model.Message, sessionTracker *imapserver.SessionTracker, persistence *persistence.Persistence) bool {
 20	if criteria == nil {
 21		return false
 22	}
 23
 24	for _, child := range criteria.Or {
 25		if !m.evalSearchCriteria(&child[0], msg, sessionTracker, persistence) && !m.evalSearchCriteria(&child[1], msg, sessionTracker, persistence) {
 26			return false
 27		}
 28	}
 29
 30	for _, child := range criteria.Not {
 31		if m.evalSearchCriteria(&child, msg, sessionTracker, persistence) {
 32			return false
 33		}
 34	}
 35
 36	if !evalSearchFlags(criteria, msg) {
 37		return false
 38	}
 39
 40	if !evalSearchID(criteria, msg, sessionTracker) {
 41		return false
 42	}
 43
 44	if !evalSearchDates(criteria, msg) {
 45		return false
 46	}
 47
 48	if !evalSearchSizes(criteria, msg) {
 49		return false
 50	}
 51
 52	if !evalSearchMessageContent(criteria, msg, persistence) {
 53		return false
 54	}
 55
 56	return true
 57}
 58
 59func evalSearchID(criteria *imap.SearchCriteria, msg model.Message, sessionTracker *imapserver.SessionTracker) bool {
 60	for _, uid := range criteria.UID {
 61		if !uid.Contains(imap.UID(msg.UID)) {
 62			return false
 63		}
 64	}
 65
 66	for _, seq := range criteria.SeqNum {
 67		serverSeq, err := msg.ServerSeq(context.Background())
 68		if err != nil {
 69			slog.Error("could not get serverSeq", "error", err)
 70			return false
 71		}
 72
 73		if !seq.Contains(sessionTracker.EncodeSeqNum(serverSeq)) {
 74			return false
 75		}
 76	}
 77
 78	return true
 79}
 80
 81func evalSearchFlags(criteria *imap.SearchCriteria, msg model.Message) bool {
 82	for _, f := range criteria.Flag {
 83		found := false
 84		for _, hasFlags := range toIMAPFlags(msg.Flags) {
 85			if strings.EqualFold(string(f), string(hasFlags)) {
 86				found = true
 87				break
 88			}
 89		}
 90		if !found {
 91			return false
 92		}
 93	}
 94
 95	for _, f := range criteria.NotFlag {
 96		for _, hasFlags := range toIMAPFlags(msg.Flags) {
 97			if strings.EqualFold(string(f), string(hasFlags)) {
 98				return false
 99			}
100		}
101	}
102
103	return true
104}
105
106func evalSearchDates(criteria *imap.SearchCriteria, msg model.Message) bool {
107	// Internal Date (SINCE, BEFORE)
108	idate, _ := time.Parse(time.RFC3339, msg.InternalDate)
109	if !criteria.Since.IsZero() {
110		msgDay := idate.UTC().Truncate(24 * time.Hour)
111		refDay := criteria.Since.UTC().Truncate(24 * time.Hour)
112		if msgDay.Before(refDay) {
113			return false
114		}
115	}
116	if !criteria.Before.IsZero() {
117		msgDay := idate.UTC().Truncate(24 * time.Hour)
118		refDay := criteria.Before.UTC().Truncate(24 * time.Hour)
119		if !msgDay.Before(refDay) {
120			return false
121		}
122	}
123
124	// Envelope Date (SENTSINCE, SENTBEFORE)
125	d, _ := time.Parse(time.RFC3339, msg.EnvelopeDate.String)
126	if !criteria.SentSince.IsZero() {
127		if msg.EnvelopeDate.String == "" { // searches for missing fields result in a failed search
128			return false
129		}
130		msgDay := d.UTC().Truncate(24 * time.Hour)
131		refDay := criteria.SentSince.UTC().Truncate(24 * time.Hour)
132		if msgDay.Before(refDay) {
133			return false
134		}
135	}
136	if !criteria.SentBefore.IsZero() {
137		if msg.EnvelopeDate.String == "" { // searches for missing fields result in a failed search
138			return false
139		}
140		msgDay := d.UTC().Truncate(24 * time.Hour)
141		refDay := criteria.SentBefore.UTC().Truncate(24 * time.Hour)
142		if !msgDay.Before(refDay) {
143			return false
144		}
145	}
146
147	return true
148}
149
150func evalSearchSizes(criteria *imap.SearchCriteria, msg model.Message) bool {
151	if criteria.Smaller != 0 {
152		if msg.RFC822Size > criteria.Smaller {
153			return false
154		}
155	}
156
157	if criteria.Larger != 0 {
158		if msg.RFC822Size < criteria.Larger {
159			return false
160		}
161	}
162
163	return true
164}
165
166// evalSearchMessageContent compares lowercased strings where applicable.
167// In the future, consider UTF-8 normalizing the strings first to find more of what was intended to be found.
168func evalSearchMessageContent(criteria *imap.SearchCriteria, msg model.Message, persistence *persistence.Persistence) bool {
169	// if there's anything left over for full message analysis
170	nonEnvelopeHeader := make([]imap.SearchCriteriaHeaderField, 0, len(criteria.Header))
171
172	checkHeader := func(needle string, storedHeader sql.NullString) bool {
173		if needle == "" {
174			return storedHeader.Valid
175		}
176		if !storedHeader.Valid {
177			return false
178		}
179		return strings.Contains(strings.ToLower(storedHeader.String), needle)
180	}
181
182	for _, h := range criteria.Header {
183		headerValueLower := strings.ToLower(h.Value)
184		switch strings.ToLower(h.Key) {
185		case "subject":
186			if !checkHeader(headerValueLower, msg.EnvelopeSubject) {
187				return false
188			}
189		case "message-id":
190			if !checkHeader(headerValueLower, msg.EnvelopeMessageID) {
191				return false
192			}
193		case "in-reply-to":
194			if !checkHeader(headerValueLower, msg.EnvelopeInReplyTo) {
195				return false
196			}
197		case "from":
198			if !checkHeader(headerValueLower, msg.EnvelopeFrom) {
199				return false
200			}
201		case "sender":
202			if !checkHeader(headerValueLower, msg.EnvelopeSender) {
203				return false
204			}
205		case "reply-to":
206			if !checkHeader(headerValueLower, msg.EnvelopeReplyTo) {
207				return false
208			}
209		case "to":
210			if !checkHeader(headerValueLower, msg.EnvelopeTo) {
211				return false
212			}
213		case "cc":
214			if !checkHeader(headerValueLower, msg.EnvelopeCc) {
215				return false
216			}
217		case "bcc":
218			if !checkHeader(headerValueLower, msg.EnvelopeBcc) {
219				return false
220			}
221		default:
222			nonEnvelopeHeader = append(nonEnvelopeHeader, h)
223		}
224	}
225
226	// At this point, we need to load the full message from disk
227	if len(nonEnvelopeHeader) > 0 || len(criteria.Text) > 0 || len(criteria.Body) > 0 {
228		readMessage, err := persistence.BlobReader(msg.BlobHash)
229		if err != nil {
230			return false
231		}
232
233		// TODO decode MIME
234		parsedMessage, err := mail.ReadMessage(readMessage)
235		if err != nil {
236			return false
237		}
238		err = readMessage.Close()
239		if err != nil {
240			return false
241		}
242
243		for _, h := range nonEnvelopeHeader {
244			headerVal, ok := parsedMessage.Header[h.Key]
245			if !ok { // RFC special case to detect header existence
246				return false
247			}
248			foundInValues := false
249			for _, headerValue := range headerVal {
250				if strings.Contains(strings.ToLower(headerValue), strings.ToLower(h.Value)) {
251					foundInValues = true
252					break
253				}
254			}
255			if !foundInValues {
256				return false
257			}
258		}
259
260		// need to read body
261		if len(criteria.Body) > 0 || len(criteria.Text) > 0 {
262			messageData, err := io.ReadAll(parsedMessage.Body)
263			if err != nil {
264				return false
265			}
266			// RFC expects case-insensitive search
267			messageDataLower := strings.ToLower(string(messageData))
268
269			for _, needle := range criteria.Body {
270				if !strings.Contains(messageDataLower, strings.ToLower(needle)) {
271					return false
272				}
273			}
274
275			for _, needle := range criteria.Text {
276				needleLower := strings.ToLower(needle)
277				if strings.Contains(messageDataLower, needleLower) {
278					continue
279				}
280				found := false
281				for k, v := range parsedMessage.Header {
282					if strings.Contains(strings.ToLower(k), needleLower) {
283						found = true
284						break
285					}
286					for _, headerValue := range v {
287						if strings.Contains(strings.ToLower(headerValue), needleLower) {
288							found = true
289							break
290						}
291					}
292					if found {
293						break
294					}
295				}
296				if !found {
297					return false
298				}
299			}
300		}
301	}
302
303	return true
304}