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}