1package fetcher
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "io"
8 "io/ioutil"
9 "mime"
10 "mime/quotedprintable"
11 "strings"
12 "time"
13
14 "github.com/emersion/go-imap"
15 "github.com/emersion/go-imap/client"
16 "github.com/emersion/go-message/mail"
17 "github.com/floatpane/matcha/config"
18 "golang.org/x/text/encoding/ianaindex"
19 "golang.org/x/text/transform"
20)
21
22// Attachment holds data for an email attachment.
23type Attachment struct {
24 Filename string
25 PartID string // Keep PartID to fetch on demand
26 Data []byte
27 Encoding string // Store encoding for proper decoding
28}
29
30type Email struct {
31 UID uint32
32 From string
33 To []string
34 Subject string
35 Body string
36 Date time.Time
37 MessageID string
38 References []string
39 Attachments []Attachment
40 AccountID string // ID of the account this email belongs to
41}
42
43func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
44 mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
45 if err != nil {
46 body, _ := ioutil.ReadAll(reader)
47 return string(body), nil
48 }
49
50 charset := "utf-8"
51 if params["charset"] != "" {
52 charset = strings.ToLower(params["charset"])
53 }
54
55 encoding, err := ianaindex.IANA.Encoding(charset)
56 if err != nil || encoding == nil {
57 encoding, _ = ianaindex.IANA.Encoding("utf-8")
58 }
59
60 transformReader := transform.NewReader(reader, encoding.NewDecoder())
61 decodedBody, err := ioutil.ReadAll(transformReader)
62 if err != nil {
63 return "", err
64 }
65
66 if strings.HasPrefix(mediaType, "multipart/") {
67 return "[This is a multipart message]", nil
68 }
69
70 return string(decodedBody), nil
71}
72
73func decodeHeader(header string) string {
74 dec := new(mime.WordDecoder)
75 dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
76 encoding, err := ianaindex.IANA.Encoding(charset)
77 if err != nil {
78 return nil, err
79 }
80 return transform.NewReader(input, encoding.NewDecoder()), nil
81 }
82 decoded, err := dec.DecodeHeader(header)
83 if err != nil {
84 return header
85 }
86 return decoded
87}
88
89func connect(account *config.Account) (*client.Client, error) {
90 imapServer := account.GetIMAPServer()
91 imapPort := account.GetIMAPPort()
92
93 if imapServer == "" {
94 return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
95 }
96
97 addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
98 c, err := client.DialTLS(addr, nil)
99 if err != nil {
100 return nil, err
101 }
102
103 if err := c.Login(account.Email, account.Password); err != nil {
104 return nil, err
105 }
106
107 return c, nil
108}
109
110func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
111 c, err := connect(account)
112 if err != nil {
113 return nil, err
114 }
115 defer c.Logout()
116
117 mbox, err := c.Select("INBOX", false)
118 if err != nil {
119 return nil, err
120 }
121
122 if mbox.Messages == 0 {
123 return []Email{}, nil
124 }
125
126 to := mbox.Messages - offset
127 from := uint32(1)
128 if to > limit {
129 from = to - limit + 1
130 }
131
132 if to < 1 {
133 return []Email{}, nil
134 }
135
136 seqset := new(imap.SeqSet)
137 seqset.AddRange(from, to)
138
139 messages := make(chan *imap.Message, limit)
140 done := make(chan error, 1)
141 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
142 go func() {
143 done <- c.Fetch(seqset, fetchItems, messages)
144 }()
145
146 var msgs []*imap.Message
147 for msg := range messages {
148 msgs = append(msgs, msg)
149 }
150
151 if err := <-done; err != nil {
152 return nil, err
153 }
154
155 var emails []Email
156 for _, msg := range msgs {
157 if msg == nil || msg.Envelope == nil {
158 continue
159 }
160
161 var fromAddr string
162 if len(msg.Envelope.From) > 0 {
163 fromAddr = msg.Envelope.From[0].Address()
164 }
165
166 var toAddrList []string
167 // Build recipient list from To and Cc for matching and display
168 for _, addr := range msg.Envelope.To {
169 toAddrList = append(toAddrList, addr.Address())
170 }
171 for _, addr := range msg.Envelope.Cc {
172 toAddrList = append(toAddrList, addr.Address())
173 }
174
175 // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
176 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
177 if fetchEmail == "" {
178 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
179 }
180
181 // Check if any recipient matches the fetchEmail
182 matched := false
183 for _, r := range toAddrList {
184 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
185 matched = true
186 break
187 }
188 }
189
190 if !matched {
191 // Skip messages not addressed to the configured fetch email
192 continue
193 }
194
195 emails = append(emails, Email{
196 UID: msg.Uid,
197 From: fromAddr,
198 To: toAddrList,
199 Subject: decodeHeader(msg.Envelope.Subject),
200 Date: msg.Envelope.Date,
201 AccountID: account.ID,
202 })
203 }
204
205 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
206 emails[i], emails[j] = emails[j], emails[i]
207 }
208
209 return emails, nil
210}
211
212func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
213 c, err := connect(account)
214 if err != nil {
215 return "", nil, err
216 }
217 defer c.Logout()
218
219 if _, err := c.Select("INBOX", false); err != nil {
220 return "", nil, err
221 }
222
223 seqset := new(imap.SeqSet)
224 seqset.AddNum(uid)
225
226 messages := make(chan *imap.Message, 1)
227 done := make(chan error, 1)
228 fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
229 go func() {
230 done <- c.UidFetch(seqset, fetchItems, messages)
231 }()
232
233 if err := <-done; err != nil {
234 return "", nil, err
235 }
236
237 msg := <-messages
238 if msg == nil || msg.BodyStructure == nil {
239 return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
240 }
241
242 var textPartID string
243 var attachments []Attachment
244 var checkPart func(part *imap.BodyStructure, partID string)
245 checkPart = func(part *imap.BodyStructure, partID string) {
246 // Check for text content
247 if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" {
248 textPartID = partID
249 }
250
251 // Check for attachments using multiple methods
252 filename := ""
253 // First try the Filename() method which handles various cases
254 if fn, err := part.Filename(); err == nil && fn != "" {
255 filename = fn
256 }
257 // Fallback: check DispositionParams
258 if filename == "" {
259 if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
260 filename = fn
261 }
262 }
263 // Fallback: check Params (for name parameter)
264 if filename == "" {
265 if fn, ok := part.Params["name"]; ok && fn != "" {
266 filename = fn
267 }
268 }
269 // Fallback: check Params for filename
270 if filename == "" {
271 if fn, ok := part.Params["filename"]; ok && fn != "" {
272 filename = fn
273 }
274 }
275
276 // Add as attachment if it has a disposition or a filename (and not just plain text)
277 if filename != "" && (part.Disposition == "attachment" || part.Disposition == "inline" || part.MIMEType != "text") {
278 attachments = append(attachments, Attachment{
279 Filename: filename,
280 PartID: partID,
281 Encoding: part.Encoding, // Store encoding for proper decoding
282 })
283 }
284 }
285
286 var findParts func(*imap.BodyStructure, string)
287 findParts = func(bs *imap.BodyStructure, prefix string) {
288 // If this is a non-multipart message, check the body structure itself
289 if len(bs.Parts) == 0 {
290 partID := prefix
291 if partID == "" {
292 partID = "1"
293 }
294 checkPart(bs, partID)
295 return
296 }
297
298 // Iterate through parts
299 for i, part := range bs.Parts {
300 partID := fmt.Sprintf("%d", i+1)
301 if prefix != "" {
302 partID = fmt.Sprintf("%s.%d", prefix, i+1)
303 }
304
305 checkPart(part, partID)
306
307 if len(part.Parts) > 0 {
308 findParts(part, partID)
309 }
310 }
311 }
312 findParts(msg.BodyStructure, "")
313
314 var body string
315 if textPartID != "" {
316 partMessages := make(chan *imap.Message, 1)
317 partDone := make(chan error, 1)
318
319 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
320 section, err := imap.ParseBodySectionName(fetchItem)
321 if err != nil {
322 return "", nil, err
323 }
324
325 go func() {
326 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
327 }()
328
329 if err := <-partDone; err != nil {
330 return "", nil, err
331 }
332
333 partMsg := <-partMessages
334 if partMsg != nil {
335 literal := partMsg.GetBody(section)
336 if literal != nil {
337 // The new decoding logic starts here
338 buf, _ := ioutil.ReadAll(literal)
339 mr, err := mail.CreateReader(bytes.NewReader(buf))
340 if err != nil {
341 body = string(buf)
342 } else {
343 p, err := mr.NextPart()
344 if err != nil {
345 body = string(buf)
346 } else {
347 encoding := p.Header.Get("Content-Transfer-Encoding")
348 bodyBytes, _ := ioutil.ReadAll(p.Body)
349
350 switch strings.ToLower(encoding) {
351 case "base64":
352 decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
353 if err == nil {
354 body = string(decoded)
355 } else {
356 body = string(bodyBytes)
357 }
358 case "quoted-printable":
359 decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes))))
360 if err == nil {
361 body = string(decoded)
362 } else {
363 body = string(bodyBytes)
364 }
365 default:
366 body = string(bodyBytes)
367 }
368 }
369 }
370 }
371 }
372 }
373
374 return body, attachments, nil
375}
376
377func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
378 c, err := connect(account)
379 if err != nil {
380 return nil, err
381 }
382 defer c.Logout()
383
384 if _, err := c.Select("INBOX", false); err != nil {
385 return nil, err
386 }
387
388 seqset := new(imap.SeqSet)
389 seqset.AddNum(uid)
390
391 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
392 section, err := imap.ParseBodySectionName(fetchItem)
393 if err != nil {
394 return nil, err
395 }
396
397 messages := make(chan *imap.Message, 1)
398 done := make(chan error, 1)
399 go func() {
400 done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
401 }()
402
403 if err := <-done; err != nil {
404 return nil, err
405 }
406
407 msg := <-messages
408 if msg == nil {
409 return nil, fmt.Errorf("could not fetch attachment")
410 }
411
412 literal := msg.GetBody(section)
413 if literal == nil {
414 return nil, fmt.Errorf("could not get attachment body")
415 }
416
417 rawBytes, err := ioutil.ReadAll(literal)
418 if err != nil {
419 return nil, err
420 }
421
422 switch strings.ToLower(encoding) {
423 case "base64":
424 decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
425 decoded, err := ioutil.ReadAll(decoder)
426 if err == nil {
427 return decoded, nil
428 }
429 return rawBytes, nil
430 case "quoted-printable":
431 decoded, err := ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
432 if err == nil {
433 return decoded, nil
434 }
435 return rawBytes, nil
436 default:
437 return rawBytes, nil
438 }
439}
440
441func moveEmail(account *config.Account, uid uint32, destMailbox string) error {
442 c, err := connect(account)
443 if err != nil {
444 return err
445 }
446 defer c.Logout()
447
448 if _, err := c.Select("INBOX", false); err != nil {
449 return err
450 }
451
452 seqSet := new(imap.SeqSet)
453 seqSet.AddNum(uid)
454
455 return c.UidMove(seqSet, destMailbox)
456}
457
458func DeleteEmail(account *config.Account, uid uint32) error {
459 c, err := connect(account)
460 if err != nil {
461 return err
462 }
463 defer c.Logout()
464
465 if _, err := c.Select("INBOX", false); err != nil {
466 return err
467 }
468
469 seqSet := new(imap.SeqSet)
470 seqSet.AddNum(uid)
471
472 item := imap.FormatFlagsOp(imap.AddFlags, true)
473 flags := []interface{}{imap.DeletedFlag}
474
475 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
476 return err
477 }
478
479 return c.Expunge(nil)
480}
481
482func ArchiveEmail(account *config.Account, uid uint32) error {
483 var archiveMailbox string
484 switch account.ServiceProvider {
485 case "gmail":
486 archiveMailbox = "[Gmail]/All Mail"
487 default:
488 archiveMailbox = "Archive"
489 }
490 return moveEmail(account, uid, archiveMailbox)
491}