1package fetcher
2
3import (
4 "bytes"
5 "crypto/tls"
6 "encoding/base64"
7 "fmt"
8 "io"
9 "io/ioutil"
10 "mime"
11 "mime/quotedprintable"
12 "os"
13 "strings"
14 "time"
15
16 "github.com/emersion/go-imap"
17 "github.com/emersion/go-imap/client"
18 "github.com/emersion/go-message/mail"
19 "github.com/floatpane/matcha/config"
20 "golang.org/x/text/encoding/ianaindex"
21 "golang.org/x/text/transform"
22)
23
24// Attachment holds data for an email attachment.
25type Attachment struct {
26 Filename string
27 PartID string // Keep PartID to fetch on demand
28 Data []byte
29 Encoding string // Store encoding for proper decoding
30 MIMEType string // Full MIME type (e.g., image/png)
31 ContentID string // Content-ID for inline assets (e.g., cid: references)
32 Inline bool // True when the part is meant to be displayed inline
33}
34
35type Email struct {
36 UID uint32
37 From string
38 To []string
39 Subject string
40 Body string
41 Date time.Time
42 MessageID string
43 References []string
44 Attachments []Attachment
45 AccountID string // ID of the account this email belongs to
46}
47
48func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
49 mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
50 if err != nil {
51 body, _ := ioutil.ReadAll(reader)
52 return string(body), nil
53 }
54
55 charset := "utf-8"
56 if params["charset"] != "" {
57 charset = strings.ToLower(params["charset"])
58 }
59
60 encoding, err := ianaindex.IANA.Encoding(charset)
61 if err != nil || encoding == nil {
62 encoding, _ = ianaindex.IANA.Encoding("utf-8")
63 }
64
65 transformReader := transform.NewReader(reader, encoding.NewDecoder())
66 decodedBody, err := ioutil.ReadAll(transformReader)
67 if err != nil {
68 return "", err
69 }
70
71 if strings.HasPrefix(mediaType, "multipart/") {
72 return "[This is a multipart message]", nil
73 }
74
75 return string(decodedBody), nil
76}
77
78func decodeHeader(header string) string {
79 dec := new(mime.WordDecoder)
80 dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
81 encoding, err := ianaindex.IANA.Encoding(charset)
82 if err != nil {
83 return nil, err
84 }
85 return transform.NewReader(input, encoding.NewDecoder()), nil
86 }
87 decoded, err := dec.DecodeHeader(header)
88 if err != nil {
89 return header
90 }
91 return decoded
92}
93
94func decodeAttachmentData(rawBytes []byte, encoding string) ([]byte, error) {
95 switch strings.ToLower(encoding) {
96 case "base64":
97 decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
98 return ioutil.ReadAll(decoder)
99 case "quoted-printable":
100 return ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
101 default:
102 return rawBytes, nil
103 }
104}
105
106func connect(account *config.Account) (*client.Client, error) {
107 imapServer := account.GetIMAPServer()
108 imapPort := account.GetIMAPPort()
109
110 if imapServer == "" {
111 return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
112 }
113
114 addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
115
116 tlsConfig := &tls.Config{
117 ServerName: imapServer,
118 InsecureSkipVerify: account.Insecure,
119 }
120
121 var c *client.Client
122 var err error
123
124 // If using standard non-implicit ports (1143 or 143), use Dial + STARTTLS
125 if imapPort == 1143 || imapPort == 143 {
126 c, err = client.Dial(addr)
127 if err != nil {
128 return nil, err
129 }
130 if err := c.StartTLS(tlsConfig); err != nil {
131 return nil, err
132 }
133 } else {
134 // Otherwise default to implicit TLS (port 993)
135 c, err = client.DialTLS(addr, tlsConfig)
136 if err != nil {
137 return nil, err
138 }
139 }
140
141 if err := c.Login(account.Email, account.Password); err != nil {
142 return nil, err
143 }
144
145 return c, nil
146}
147
148func getSentMailbox(account *config.Account) string {
149 switch account.ServiceProvider {
150 case "gmail":
151 return "[Gmail]/Sent Mail"
152 case "icloud":
153 return "Sent Messages"
154 default:
155 return "Sent"
156 }
157}
158
159// getMailboxByAttr finds a mailbox with the given IMAP attribute (e.g., \All, \Sent, \Trash).
160func getMailboxByAttr(c *client.Client, attr string) (string, error) {
161 mailboxes := make(chan *imap.MailboxInfo, 10)
162 done := make(chan error, 1)
163 go func() {
164 done <- c.List("", "*", mailboxes)
165 }()
166
167 var foundMailbox string
168 for m := range mailboxes {
169 for _, a := range m.Attributes {
170 if a == attr {
171 foundMailbox = m.Name
172 break
173 }
174 }
175 }
176
177 if err := <-done; err != nil {
178 return "", err
179 }
180
181 if foundMailbox == "" {
182 return "", fmt.Errorf("no mailbox found with attribute %s", attr)
183 }
184
185 return foundMailbox, nil
186}
187
188func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) {
189 c, err := connect(account)
190 if err != nil {
191 return nil, err
192 }
193 defer c.Logout()
194
195 mbox, err := c.Select(mailbox, false)
196 if err != nil {
197 return nil, err
198 }
199
200 if mbox.Messages == 0 {
201 return []Email{}, nil
202 }
203
204 var allEmails []Email
205
206 // Start from the top minus offset
207 cursor := uint32(0)
208 if mbox.Messages > offset {
209 cursor = mbox.Messages - offset
210 } else {
211 return []Email{}, nil
212 }
213
214 // Determine if we should filter
215 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
216 if fetchEmail == "" {
217 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
218 }
219 isSentMailbox := mailbox == getSentMailbox(account)
220
221 // Loop until we have enough emails or run out of messages
222 for len(allEmails) < int(limit) && cursor > 0 {
223 // Determine chunk size
224 // Fetch at least 'limit' or 50 messages to reduce round trips
225 chunkSize := limit
226 if chunkSize < 50 {
227 chunkSize = 50
228 }
229
230 from := uint32(1)
231 if cursor > uint32(chunkSize) {
232 from = cursor - uint32(chunkSize) + 1
233 }
234
235 seqset := new(imap.SeqSet)
236 seqset.AddRange(from, cursor)
237
238 messages := make(chan *imap.Message, chunkSize)
239 done := make(chan error, 1)
240 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
241
242 go func() {
243 done <- c.Fetch(seqset, fetchItems, messages)
244 }()
245
246 var batchMsgs []*imap.Message
247 for msg := range messages {
248 batchMsgs = append(batchMsgs, msg)
249 }
250
251 if err := <-done; err != nil {
252 return nil, err
253 }
254
255 // Filter messages in this batch
256 var batchEmails []Email
257 for _, msg := range batchMsgs {
258 if msg == nil || msg.Envelope == nil {
259 continue
260 }
261
262 var fromAddr string
263 if len(msg.Envelope.From) > 0 {
264 fromAddr = msg.Envelope.From[0].Address()
265 }
266
267 var toAddrList []string
268 for _, addr := range msg.Envelope.To {
269 toAddrList = append(toAddrList, addr.Address())
270 }
271 for _, addr := range msg.Envelope.Cc {
272 toAddrList = append(toAddrList, addr.Address())
273 }
274
275 matched := false
276 if isSentMailbox {
277 if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
278 matched = true
279 }
280 } else {
281 for _, r := range toAddrList {
282 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
283 matched = true
284 break
285 }
286 }
287 }
288
289 if !matched {
290 continue
291 }
292
293 batchEmails = append(batchEmails, Email{
294 UID: msg.Uid,
295 From: fromAddr,
296 To: toAddrList,
297 Subject: decodeHeader(msg.Envelope.Subject),
298 Date: msg.Envelope.Date,
299 AccountID: account.ID,
300 })
301 }
302
303 // Sort batch Newest -> Oldest (since IMAP usually returns Oldest->Newest or arbitrary)
304 // Assuming seqset order or standard behavior, we want to ensure we append Newest emails first
305 // so that the final list is correct.
306 // Actually, let's just sort the batch by UID desc (Newest first)
307 // Simple bubble sort for small batch
308 for i := 0; i < len(batchEmails); i++ {
309 for j := i + 1; j < len(batchEmails); j++ {
310 if batchEmails[j].UID > batchEmails[i].UID {
311 batchEmails[i], batchEmails[j] = batchEmails[j], batchEmails[i]
312 }
313 }
314 }
315
316 // Append to allEmails
317 allEmails = append(allEmails, batchEmails...)
318
319 // Update cursor for next iteration
320 cursor = from - 1
321 }
322
323 // Trim if we have too many
324 if len(allEmails) > int(limit) {
325 allEmails = allEmails[:limit]
326 }
327
328 return allEmails, nil
329}
330
331func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
332 c, err := connect(account)
333 if err != nil {
334 return "", nil, err
335 }
336 defer c.Logout()
337
338 if _, err := c.Select(mailbox, false); err != nil {
339 return "", nil, err
340 }
341
342 seqset := new(imap.SeqSet)
343 seqset.AddNum(uid)
344
345 fetchInlinePart := func(partID, encoding string) ([]byte, error) {
346 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
347 section, err := imap.ParseBodySectionName(fetchItem)
348 if err != nil {
349 return nil, err
350 }
351
352 partMessages := make(chan *imap.Message, 1)
353 partDone := make(chan error, 1)
354 go func() {
355 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
356 }()
357
358 if err := <-partDone; err != nil {
359 return nil, err
360 }
361
362 partMsg := <-partMessages
363 if partMsg == nil {
364 return nil, fmt.Errorf("could not fetch inline part %s", partID)
365 }
366
367 literal := partMsg.GetBody(section)
368 if literal == nil {
369 return nil, fmt.Errorf("could not get inline part body %s", partID)
370 }
371
372 rawBytes, err := ioutil.ReadAll(literal)
373 if err != nil {
374 return nil, err
375 }
376
377 return decodeAttachmentData(rawBytes, encoding)
378 }
379
380 messages := make(chan *imap.Message, 1)
381 done := make(chan error, 1)
382 fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
383 go func() {
384 done <- c.UidFetch(seqset, fetchItems, messages)
385 }()
386
387 if err := <-done; err != nil {
388 return "", nil, err
389 }
390
391 msg := <-messages
392 if msg == nil || msg.BodyStructure == nil {
393 return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
394 }
395
396 var plainPartID, plainPartEncoding string
397 var htmlPartID, htmlPartEncoding string
398 var attachments []Attachment
399 var checkPart func(part *imap.BodyStructure, partID string)
400 checkPart = func(part *imap.BodyStructure, partID string) {
401 // Check for text content (prefer html over plain)
402 if part.MIMEType == "text" {
403 sub := strings.ToLower(part.MIMESubType)
404 switch sub {
405 case "html":
406 if htmlPartID == "" {
407 htmlPartID = partID
408 htmlPartEncoding = part.Encoding
409 }
410 case "plain":
411 if plainPartID == "" {
412 plainPartID = partID
413 plainPartEncoding = part.Encoding
414 }
415 }
416 }
417
418 // Check for attachments using multiple methods
419 filename := ""
420 // First try the Filename() method which handles various cases
421 if fn, err := part.Filename(); err == nil && fn != "" {
422 filename = fn
423 }
424 // Fallback: check DispositionParams
425 if filename == "" {
426 if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
427 filename = fn
428 }
429 }
430 // Fallback: check Params (for name parameter)
431 if filename == "" {
432 if fn, ok := part.Params["name"]; ok && fn != "" {
433 filename = fn
434 }
435 }
436 // Fallback: check Params for filename
437 if filename == "" {
438 if fn, ok := part.Params["filename"]; ok && fn != "" {
439 filename = fn
440 }
441 }
442
443 // Add as attachment if it has a disposition or a filename (and not just plain text).
444 // Allow inline parts without filenames (common for cid images).
445 contentID := strings.Trim(part.Id, "<>")
446 mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType))
447 isCID := contentID != ""
448 isInline := part.Disposition == "inline" || isCID
449
450 if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
451 filename = "inline"
452 }
453 if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
454 att := Attachment{
455 Filename: filename,
456 PartID: partID,
457 Encoding: part.Encoding, // Store encoding for proper decoding
458 MIMEType: mimeType,
459 ContentID: contentID,
460 Inline: isInline,
461 }
462 if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
463 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
464 att.Data = data
465 }
466 }
467 attachments = append(attachments, att)
468 }
469 }
470
471 var findParts func(*imap.BodyStructure, string)
472 findParts = func(bs *imap.BodyStructure, prefix string) {
473 // If this is a non-multipart message, check the body structure itself
474 if len(bs.Parts) == 0 {
475 partID := prefix
476 if partID == "" {
477 partID = "1"
478 }
479 checkPart(bs, partID)
480 return
481 }
482
483 // Iterate through parts
484 for i, part := range bs.Parts {
485 partID := fmt.Sprintf("%d", i+1)
486 if prefix != "" {
487 partID = fmt.Sprintf("%s.%d", prefix, i+1)
488 }
489
490 checkPart(part, partID)
491
492 if len(part.Parts) > 0 {
493 findParts(part, partID)
494 }
495 }
496 }
497 findParts(msg.BodyStructure, "")
498
499 var body string
500 textPartID := ""
501 textPartEncoding := ""
502 if htmlPartID != "" {
503 textPartID = htmlPartID
504 textPartEncoding = htmlPartEncoding
505 } else if plainPartID != "" {
506 textPartID = plainPartID
507 textPartEncoding = plainPartEncoding
508 }
509 if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
510 msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
511 fmt.Print(msg)
512 if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
513 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
514 _, _ = f.WriteString(msg)
515 _ = f.Close()
516 }
517 }
518 }
519 if textPartID != "" {
520 partMessages := make(chan *imap.Message, 1)
521 partDone := make(chan error, 1)
522
523 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
524 section, err := imap.ParseBodySectionName(fetchItem)
525 if err != nil {
526 return "", nil, err
527 }
528
529 go func() {
530 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
531 }()
532
533 if err := <-partDone; err != nil {
534 return "", nil, err
535 }
536
537 partMsg := <-partMessages
538 if partMsg != nil {
539 literal := partMsg.GetBody(section)
540 if literal != nil {
541 buf, _ := ioutil.ReadAll(literal)
542 // Use the encoding from BodyStructure to decode
543 if decoded, err := decodeAttachmentData(buf, textPartEncoding); err == nil {
544 body = string(decoded)
545 } else {
546 body = string(buf)
547 }
548 }
549 }
550 }
551
552 return body, attachments, nil
553}
554
555func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
556 c, err := connect(account)
557 if err != nil {
558 return nil, err
559 }
560 defer c.Logout()
561
562 if _, err := c.Select(mailbox, false); err != nil {
563 return nil, err
564 }
565
566 seqset := new(imap.SeqSet)
567 seqset.AddNum(uid)
568
569 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
570 section, err := imap.ParseBodySectionName(fetchItem)
571 if err != nil {
572 return nil, err
573 }
574
575 messages := make(chan *imap.Message, 1)
576 done := make(chan error, 1)
577 go func() {
578 done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
579 }()
580
581 if err := <-done; err != nil {
582 return nil, err
583 }
584
585 msg := <-messages
586 if msg == nil {
587 return nil, fmt.Errorf("could not fetch attachment")
588 }
589
590 literal := msg.GetBody(section)
591 if literal == nil {
592 return nil, fmt.Errorf("could not get attachment body")
593 }
594
595 rawBytes, err := ioutil.ReadAll(literal)
596 if err != nil {
597 return nil, err
598 }
599
600 decoded, err := decodeAttachmentData(rawBytes, encoding)
601 if err != nil {
602 return rawBytes, nil
603 }
604 return decoded, nil
605}
606
607func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
608 c, err := connect(account)
609 if err != nil {
610 return err
611 }
612 defer c.Logout()
613
614 if _, err := c.Select(sourceMailbox, false); err != nil {
615 return err
616 }
617
618 seqSet := new(imap.SeqSet)
619 seqSet.AddNum(uid)
620
621 return c.UidMove(seqSet, destMailbox)
622}
623
624func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
625 c, err := connect(account)
626 if err != nil {
627 return err
628 }
629 defer c.Logout()
630
631 if _, err := c.Select(mailbox, false); err != nil {
632 return err
633 }
634
635 seqSet := new(imap.SeqSet)
636 seqSet.AddNum(uid)
637
638 item := imap.FormatFlagsOp(imap.AddFlags, true)
639 flags := []interface{}{imap.DeletedFlag}
640
641 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
642 return err
643 }
644
645 return c.Expunge(nil)
646}
647
648func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
649 c, err := connect(account)
650 if err != nil {
651 return err
652 }
653 defer c.Logout()
654
655 var archiveMailbox string
656 switch account.ServiceProvider {
657 case "gmail":
658 // For Gmail, find the mailbox with the \All attribute
659 archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr)
660 if err != nil {
661 // Fallback to hardcoded path if attribute lookup fails
662 archiveMailbox = "[Gmail]/All Mail"
663 }
664 default:
665 archiveMailbox = "Archive"
666 }
667
668 if _, err := c.Select(mailbox, false); err != nil {
669 return err
670 }
671
672 seqSet := new(imap.SeqSet)
673 seqSet.AddNum(uid)
674
675 return c.UidMove(seqSet, archiveMailbox)
676}
677
678// Convenience wrappers defaulting to INBOX for existing call sites.
679
680func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
681 return FetchMailboxEmails(account, "INBOX", limit, offset)
682}
683
684func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
685 return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
686}
687
688func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
689 return FetchEmailBodyFromMailbox(account, "INBOX", uid)
690}
691
692func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
693 return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
694}
695
696func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
697 return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
698}
699
700func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
701 return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
702}
703
704func DeleteEmail(account *config.Account, uid uint32) error {
705 return DeleteEmailFromMailbox(account, "INBOX", uid)
706}
707
708func DeleteSentEmail(account *config.Account, uid uint32) error {
709 return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
710}
711
712func ArchiveEmail(account *config.Account, uid uint32) error {
713 return ArchiveEmailFromMailbox(account, "INBOX", uid)
714}
715
716func ArchiveSentEmail(account *config.Account, uid uint32) error {
717 return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
718}
719
720// getTrashMailbox returns the trash mailbox name for the account
721func getTrashMailbox(account *config.Account) string {
722 switch account.ServiceProvider {
723 case "gmail":
724 return "[Gmail]/Trash"
725 case "icloud":
726 return "Deleted Messages"
727 default:
728 return "Trash"
729 }
730}
731
732// getArchiveMailbox returns the archive/all mail mailbox name for the account
733func getArchiveMailbox(account *config.Account) string {
734 switch account.ServiceProvider {
735 case "gmail":
736 return "[Gmail]/All Mail"
737 case "icloud":
738 return "Archive"
739 default:
740 return "Archive"
741 }
742}
743
744// FetchTrashEmails fetches emails from the trash folder
745func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
746 c, err := connect(account)
747 if err != nil {
748 return nil, err
749 }
750 defer c.Logout()
751
752 // Try to find trash by attribute first
753 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
754 if err != nil {
755 // Fallback to hardcoded path
756 trashMailbox = getTrashMailbox(account)
757 }
758
759 return FetchMailboxEmails(account, trashMailbox, limit, offset)
760}
761
762// FetchArchiveEmails fetches emails from the archive/all mail folder
763// Archive contains all emails, so we match where user is sender OR recipient
764func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
765 c, err := connect(account)
766 if err != nil {
767 return nil, err
768 }
769 defer c.Logout()
770
771 // Try to find archive by attribute first (Gmail uses \All)
772 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
773 if err != nil {
774 // Fallback to hardcoded path
775 archiveMailbox = getArchiveMailbox(account)
776 }
777
778 mbox, err := c.Select(archiveMailbox, false)
779 if err != nil {
780 return nil, err
781 }
782
783 if mbox.Messages == 0 {
784 return []Email{}, nil
785 }
786
787 to := mbox.Messages - offset
788 from := uint32(1)
789 if to > limit {
790 from = to - limit + 1
791 }
792
793 if to < 1 {
794 return []Email{}, nil
795 }
796
797 seqset := new(imap.SeqSet)
798 seqset.AddRange(from, to)
799
800 messages := make(chan *imap.Message, limit)
801 done := make(chan error, 1)
802 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
803 go func() {
804 done <- c.Fetch(seqset, fetchItems, messages)
805 }()
806
807 var msgs []*imap.Message
808 for msg := range messages {
809 msgs = append(msgs, msg)
810 }
811
812 if err := <-done; err != nil {
813 return nil, err
814 }
815
816 // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
817 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
818 if fetchEmail == "" {
819 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
820 }
821
822 var emails []Email
823 for _, msg := range msgs {
824 if msg == nil || msg.Envelope == nil {
825 continue
826 }
827
828 var fromAddr string
829 if len(msg.Envelope.From) > 0 {
830 fromAddr = msg.Envelope.From[0].Address()
831 }
832
833 var toAddrList []string
834 for _, addr := range msg.Envelope.To {
835 toAddrList = append(toAddrList, addr.Address())
836 }
837 for _, addr := range msg.Envelope.Cc {
838 toAddrList = append(toAddrList, addr.Address())
839 }
840
841 // For archive/All Mail, match emails where user is sender OR recipient
842 matched := false
843 // Check if user is the sender
844 if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
845 matched = true
846 }
847 // Check if user is a recipient
848 if !matched {
849 for _, r := range toAddrList {
850 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
851 matched = true
852 break
853 }
854 }
855 }
856
857 if !matched {
858 continue
859 }
860
861 emails = append(emails, Email{
862 UID: msg.Uid,
863 From: fromAddr,
864 To: toAddrList,
865 Subject: decodeHeader(msg.Envelope.Subject),
866 Date: msg.Envelope.Date,
867 AccountID: account.ID,
868 })
869 }
870
871 // Reverse to get newest first
872 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
873 emails[i], emails[j] = emails[j], emails[i]
874 }
875
876 return emails, nil
877}
878
879// FetchTrashEmailBody fetches the body of an email from trash
880func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
881 c, err := connect(account)
882 if err != nil {
883 return "", nil, err
884 }
885 defer c.Logout()
886
887 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
888 if err != nil {
889 trashMailbox = getTrashMailbox(account)
890 }
891
892 return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
893}
894
895// FetchArchiveEmailBody fetches the body of an email from archive
896func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
897 c, err := connect(account)
898 if err != nil {
899 return "", nil, err
900 }
901 defer c.Logout()
902
903 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
904 if err != nil {
905 archiveMailbox = getArchiveMailbox(account)
906 }
907
908 return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
909}
910
911// FetchTrashAttachment fetches an attachment from trash
912func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
913 c, err := connect(account)
914 if err != nil {
915 return nil, err
916 }
917 defer c.Logout()
918
919 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
920 if err != nil {
921 trashMailbox = getTrashMailbox(account)
922 }
923
924 return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
925}
926
927// FetchArchiveAttachment fetches an attachment from archive
928func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
929 c, err := connect(account)
930 if err != nil {
931 return nil, err
932 }
933 defer c.Logout()
934
935 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
936 if err != nil {
937 archiveMailbox = getArchiveMailbox(account)
938 }
939
940 return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
941}
942
943// DeleteTrashEmail permanently deletes an email from trash
944func DeleteTrashEmail(account *config.Account, uid uint32) error {
945 c, err := connect(account)
946 if err != nil {
947 return err
948 }
949 defer c.Logout()
950
951 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
952 if err != nil {
953 trashMailbox = getTrashMailbox(account)
954 }
955
956 return DeleteEmailFromMailbox(account, trashMailbox, uid)
957}
958
959// DeleteArchiveEmail deletes an email from archive (moves to trash)
960func DeleteArchiveEmail(account *config.Account, uid uint32) error {
961 c, err := connect(account)
962 if err != nil {
963 return err
964 }
965 defer c.Logout()
966
967 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
968 if err != nil {
969 archiveMailbox = getArchiveMailbox(account)
970 }
971
972 return DeleteEmailFromMailbox(account, archiveMailbox, uid)
973}