1package fetcher
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "io"
8 "io/ioutil"
9 "mime"
10 "mime/quotedprintable"
11 "os"
12 "strings"
13 "time"
14
15 "github.com/emersion/go-imap"
16 "github.com/emersion/go-imap/client"
17 "github.com/emersion/go-message/mail"
18 "github.com/floatpane/matcha/config"
19 "golang.org/x/text/encoding/ianaindex"
20 "golang.org/x/text/transform"
21)
22
23// Attachment holds data for an email attachment.
24type Attachment struct {
25 Filename string
26 PartID string // Keep PartID to fetch on demand
27 Data []byte
28 Encoding string // Store encoding for proper decoding
29 MIMEType string // Full MIME type (e.g., image/png)
30 ContentID string // Content-ID for inline assets (e.g., cid: references)
31 Inline bool // True when the part is meant to be displayed inline
32}
33
34type Email struct {
35 UID uint32
36 From string
37 To []string
38 Subject string
39 Body string
40 Date time.Time
41 MessageID string
42 References []string
43 Attachments []Attachment
44 AccountID string // ID of the account this email belongs to
45}
46
47func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
48 mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
49 if err != nil {
50 body, _ := ioutil.ReadAll(reader)
51 return string(body), nil
52 }
53
54 charset := "utf-8"
55 if params["charset"] != "" {
56 charset = strings.ToLower(params["charset"])
57 }
58
59 encoding, err := ianaindex.IANA.Encoding(charset)
60 if err != nil || encoding == nil {
61 encoding, _ = ianaindex.IANA.Encoding("utf-8")
62 }
63
64 transformReader := transform.NewReader(reader, encoding.NewDecoder())
65 decodedBody, err := ioutil.ReadAll(transformReader)
66 if err != nil {
67 return "", err
68 }
69
70 if strings.HasPrefix(mediaType, "multipart/") {
71 return "[This is a multipart message]", nil
72 }
73
74 return string(decodedBody), nil
75}
76
77func decodeHeader(header string) string {
78 dec := new(mime.WordDecoder)
79 dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
80 encoding, err := ianaindex.IANA.Encoding(charset)
81 if err != nil {
82 return nil, err
83 }
84 return transform.NewReader(input, encoding.NewDecoder()), nil
85 }
86 decoded, err := dec.DecodeHeader(header)
87 if err != nil {
88 return header
89 }
90 return decoded
91}
92
93func decodeAttachmentData(rawBytes []byte, encoding string) ([]byte, error) {
94 switch strings.ToLower(encoding) {
95 case "base64":
96 decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
97 return ioutil.ReadAll(decoder)
98 case "quoted-printable":
99 return ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
100 default:
101 return rawBytes, nil
102 }
103}
104
105func connect(account *config.Account) (*client.Client, error) {
106 imapServer := account.GetIMAPServer()
107 imapPort := account.GetIMAPPort()
108
109 if imapServer == "" {
110 return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
111 }
112
113 addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
114 c, err := client.DialTLS(addr, nil)
115 if err != nil {
116 return nil, err
117 }
118
119 if err := c.Login(account.Email, account.Password); err != nil {
120 return nil, err
121 }
122
123 return c, nil
124}
125
126func getSentMailbox(account *config.Account) string {
127 switch account.ServiceProvider {
128 case "gmail":
129 return "[Gmail]/Sent Mail"
130 case "icloud":
131 return "Sent Messages"
132 default:
133 return "Sent"
134 }
135}
136
137// getMailboxByAttr finds a mailbox with the given IMAP attribute (e.g., \All, \Sent, \Trash).
138func getMailboxByAttr(c *client.Client, attr string) (string, error) {
139 mailboxes := make(chan *imap.MailboxInfo, 10)
140 done := make(chan error, 1)
141 go func() {
142 done <- c.List("", "*", mailboxes)
143 }()
144
145 var foundMailbox string
146 for m := range mailboxes {
147 for _, a := range m.Attributes {
148 if a == attr {
149 foundMailbox = m.Name
150 break
151 }
152 }
153 }
154
155 if err := <-done; err != nil {
156 return "", err
157 }
158
159 if foundMailbox == "" {
160 return "", fmt.Errorf("no mailbox found with attribute %s", attr)
161 }
162
163 return foundMailbox, nil
164}
165
166func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) {
167 c, err := connect(account)
168 if err != nil {
169 return nil, err
170 }
171 defer c.Logout()
172
173 mbox, err := c.Select(mailbox, false)
174 if err != nil {
175 return nil, err
176 }
177
178 if mbox.Messages == 0 {
179 return []Email{}, nil
180 }
181
182 to := mbox.Messages - offset
183 from := uint32(1)
184 if to > limit {
185 from = to - limit + 1
186 }
187
188 if to < 1 {
189 return []Email{}, nil
190 }
191
192 seqset := new(imap.SeqSet)
193 seqset.AddRange(from, to)
194
195 messages := make(chan *imap.Message, limit)
196 done := make(chan error, 1)
197 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
198 go func() {
199 done <- c.Fetch(seqset, fetchItems, messages)
200 }()
201
202 var msgs []*imap.Message
203 for msg := range messages {
204 msgs = append(msgs, msg)
205 }
206
207 if err := <-done; err != nil {
208 return nil, err
209 }
210
211 var emails []Email
212 for _, msg := range msgs {
213 if msg == nil || msg.Envelope == nil {
214 continue
215 }
216
217 var fromAddr string
218 if len(msg.Envelope.From) > 0 {
219 fromAddr = msg.Envelope.From[0].Address()
220 }
221
222 var toAddrList []string
223 // Build recipient list from To and Cc for matching and display
224 for _, addr := range msg.Envelope.To {
225 toAddrList = append(toAddrList, addr.Address())
226 }
227 for _, addr := range msg.Envelope.Cc {
228 toAddrList = append(toAddrList, addr.Address())
229 }
230
231 // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
232 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
233 if fetchEmail == "" {
234 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
235 }
236
237 // Determine if this is a sent mailbox
238 isSentMailbox := mailbox == getSentMailbox(account)
239
240 // Apply different filtering logic based on mailbox type
241 matched := false
242 if isSentMailbox {
243 // For sent mailbox, check if the sender matches the fetchEmail
244 if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
245 matched = true
246 }
247 } else {
248 // For inbox and other mailboxes, check if any recipient matches the fetchEmail
249 for _, r := range toAddrList {
250 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
251 matched = true
252 break
253 }
254 }
255 }
256
257 if !matched {
258 // Skip messages not matching the filter criteria
259 continue
260 }
261
262 emails = append(emails, Email{
263 UID: msg.Uid,
264 From: fromAddr,
265 To: toAddrList,
266 Subject: decodeHeader(msg.Envelope.Subject),
267 Date: msg.Envelope.Date,
268 AccountID: account.ID,
269 })
270 }
271
272 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
273 emails[i], emails[j] = emails[j], emails[i]
274 }
275
276 return emails, nil
277}
278
279func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
280 c, err := connect(account)
281 if err != nil {
282 return "", nil, err
283 }
284 defer c.Logout()
285
286 if _, err := c.Select(mailbox, false); err != nil {
287 return "", nil, err
288 }
289
290 seqset := new(imap.SeqSet)
291 seqset.AddNum(uid)
292
293 fetchInlinePart := func(partID, encoding string) ([]byte, error) {
294 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
295 section, err := imap.ParseBodySectionName(fetchItem)
296 if err != nil {
297 return nil, err
298 }
299
300 partMessages := make(chan *imap.Message, 1)
301 partDone := make(chan error, 1)
302 go func() {
303 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
304 }()
305
306 if err := <-partDone; err != nil {
307 return nil, err
308 }
309
310 partMsg := <-partMessages
311 if partMsg == nil {
312 return nil, fmt.Errorf("could not fetch inline part %s", partID)
313 }
314
315 literal := partMsg.GetBody(section)
316 if literal == nil {
317 return nil, fmt.Errorf("could not get inline part body %s", partID)
318 }
319
320 rawBytes, err := ioutil.ReadAll(literal)
321 if err != nil {
322 return nil, err
323 }
324
325 return decodeAttachmentData(rawBytes, encoding)
326 }
327
328 messages := make(chan *imap.Message, 1)
329 done := make(chan error, 1)
330 fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
331 go func() {
332 done <- c.UidFetch(seqset, fetchItems, messages)
333 }()
334
335 if err := <-done; err != nil {
336 return "", nil, err
337 }
338
339 msg := <-messages
340 if msg == nil || msg.BodyStructure == nil {
341 return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
342 }
343
344 var plainPartID string
345 var htmlPartID string
346 var attachments []Attachment
347 var checkPart func(part *imap.BodyStructure, partID string)
348 checkPart = func(part *imap.BodyStructure, partID string) {
349 // Check for text content (prefer html over plain)
350 if part.MIMEType == "text" {
351 sub := strings.ToLower(part.MIMESubType)
352 switch sub {
353 case "html":
354 if htmlPartID == "" {
355 htmlPartID = partID
356 }
357 case "plain":
358 if plainPartID == "" {
359 plainPartID = partID
360 }
361 }
362 }
363
364 // Check for attachments using multiple methods
365 filename := ""
366 // First try the Filename() method which handles various cases
367 if fn, err := part.Filename(); err == nil && fn != "" {
368 filename = fn
369 }
370 // Fallback: check DispositionParams
371 if filename == "" {
372 if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
373 filename = fn
374 }
375 }
376 // Fallback: check Params (for name parameter)
377 if filename == "" {
378 if fn, ok := part.Params["name"]; ok && fn != "" {
379 filename = fn
380 }
381 }
382 // Fallback: check Params for filename
383 if filename == "" {
384 if fn, ok := part.Params["filename"]; ok && fn != "" {
385 filename = fn
386 }
387 }
388
389 // Add as attachment if it has a disposition or a filename (and not just plain text).
390 // Allow inline parts without filenames (common for cid images).
391 contentID := strings.Trim(part.Id, "<>")
392 mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType))
393 isCID := contentID != ""
394 isInline := part.Disposition == "inline" || isCID
395
396 if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
397 filename = "inline"
398 }
399 if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
400 att := Attachment{
401 Filename: filename,
402 PartID: partID,
403 Encoding: part.Encoding, // Store encoding for proper decoding
404 MIMEType: mimeType,
405 ContentID: contentID,
406 Inline: isInline,
407 }
408 if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
409 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
410 att.Data = data
411 }
412 }
413 attachments = append(attachments, att)
414 }
415 }
416
417 var findParts func(*imap.BodyStructure, string)
418 findParts = func(bs *imap.BodyStructure, prefix string) {
419 // If this is a non-multipart message, check the body structure itself
420 if len(bs.Parts) == 0 {
421 partID := prefix
422 if partID == "" {
423 partID = "1"
424 }
425 checkPart(bs, partID)
426 return
427 }
428
429 // Iterate through parts
430 for i, part := range bs.Parts {
431 partID := fmt.Sprintf("%d", i+1)
432 if prefix != "" {
433 partID = fmt.Sprintf("%s.%d", prefix, i+1)
434 }
435
436 checkPart(part, partID)
437
438 if len(part.Parts) > 0 {
439 findParts(part, partID)
440 }
441 }
442 }
443 findParts(msg.BodyStructure, "")
444
445 var body string
446 textPartID := ""
447 if htmlPartID != "" {
448 textPartID = htmlPartID
449 } else if plainPartID != "" {
450 textPartID = plainPartID
451 }
452 if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
453 msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
454 fmt.Print(msg)
455 if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
456 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
457 _, _ = f.WriteString(msg)
458 _ = f.Close()
459 }
460 }
461 }
462 if textPartID != "" {
463 partMessages := make(chan *imap.Message, 1)
464 partDone := make(chan error, 1)
465
466 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
467 section, err := imap.ParseBodySectionName(fetchItem)
468 if err != nil {
469 return "", nil, err
470 }
471
472 go func() {
473 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
474 }()
475
476 if err := <-partDone; err != nil {
477 return "", nil, err
478 }
479
480 partMsg := <-partMessages
481 if partMsg != nil {
482 literal := partMsg.GetBody(section)
483 if literal != nil {
484 // The new decoding logic starts here
485 buf, _ := ioutil.ReadAll(literal)
486 mr, err := mail.CreateReader(bytes.NewReader(buf))
487 if err != nil {
488 body = string(buf)
489 } else {
490 p, err := mr.NextPart()
491 if err != nil {
492 body = string(buf)
493 } else {
494 encoding := p.Header.Get("Content-Transfer-Encoding")
495 bodyBytes, _ := ioutil.ReadAll(p.Body)
496
497 switch strings.ToLower(encoding) {
498 case "base64":
499 decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
500 if err == nil {
501 body = string(decoded)
502 } else {
503 body = string(bodyBytes)
504 }
505 case "quoted-printable":
506 decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes))))
507 if err == nil {
508 body = string(decoded)
509 } else {
510 body = string(bodyBytes)
511 }
512 default:
513 body = string(bodyBytes)
514 }
515 }
516 }
517 }
518 }
519 }
520
521 return body, attachments, nil
522}
523
524func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
525 c, err := connect(account)
526 if err != nil {
527 return nil, err
528 }
529 defer c.Logout()
530
531 if _, err := c.Select(mailbox, false); err != nil {
532 return nil, err
533 }
534
535 seqset := new(imap.SeqSet)
536 seqset.AddNum(uid)
537
538 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
539 section, err := imap.ParseBodySectionName(fetchItem)
540 if err != nil {
541 return nil, err
542 }
543
544 messages := make(chan *imap.Message, 1)
545 done := make(chan error, 1)
546 go func() {
547 done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
548 }()
549
550 if err := <-done; err != nil {
551 return nil, err
552 }
553
554 msg := <-messages
555 if msg == nil {
556 return nil, fmt.Errorf("could not fetch attachment")
557 }
558
559 literal := msg.GetBody(section)
560 if literal == nil {
561 return nil, fmt.Errorf("could not get attachment body")
562 }
563
564 rawBytes, err := ioutil.ReadAll(literal)
565 if err != nil {
566 return nil, err
567 }
568
569 decoded, err := decodeAttachmentData(rawBytes, encoding)
570 if err != nil {
571 return rawBytes, nil
572 }
573 return decoded, nil
574}
575
576func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
577 c, err := connect(account)
578 if err != nil {
579 return err
580 }
581 defer c.Logout()
582
583 if _, err := c.Select(sourceMailbox, false); err != nil {
584 return err
585 }
586
587 seqSet := new(imap.SeqSet)
588 seqSet.AddNum(uid)
589
590 return c.UidMove(seqSet, destMailbox)
591}
592
593func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
594 c, err := connect(account)
595 if err != nil {
596 return err
597 }
598 defer c.Logout()
599
600 if _, err := c.Select(mailbox, false); err != nil {
601 return err
602 }
603
604 seqSet := new(imap.SeqSet)
605 seqSet.AddNum(uid)
606
607 item := imap.FormatFlagsOp(imap.AddFlags, true)
608 flags := []interface{}{imap.DeletedFlag}
609
610 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
611 return err
612 }
613
614 return c.Expunge(nil)
615}
616
617func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
618 c, err := connect(account)
619 if err != nil {
620 return err
621 }
622 defer c.Logout()
623
624 var archiveMailbox string
625 switch account.ServiceProvider {
626 case "gmail":
627 // For Gmail, find the mailbox with the \All attribute
628 archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr)
629 if err != nil {
630 // Fallback to hardcoded path if attribute lookup fails
631 archiveMailbox = "[Gmail]/All Mail"
632 }
633 default:
634 archiveMailbox = "Archive"
635 }
636
637 if _, err := c.Select(mailbox, false); err != nil {
638 return err
639 }
640
641 seqSet := new(imap.SeqSet)
642 seqSet.AddNum(uid)
643
644 return c.UidMove(seqSet, archiveMailbox)
645}
646
647// Convenience wrappers defaulting to INBOX for existing call sites.
648
649func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
650 return FetchMailboxEmails(account, "INBOX", limit, offset)
651}
652
653func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
654 return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
655}
656
657func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
658 return FetchEmailBodyFromMailbox(account, "INBOX", uid)
659}
660
661func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
662 return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
663}
664
665func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
666 return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
667}
668
669func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
670 return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
671}
672
673func DeleteEmail(account *config.Account, uid uint32) error {
674 return DeleteEmailFromMailbox(account, "INBOX", uid)
675}
676
677func DeleteSentEmail(account *config.Account, uid uint32) error {
678 return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
679}
680
681func ArchiveEmail(account *config.Account, uid uint32) error {
682 return ArchiveEmailFromMailbox(account, "INBOX", uid)
683}
684
685func ArchiveSentEmail(account *config.Account, uid uint32) error {
686 return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
687}