fetcher.go

  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}
688
689// getTrashMailbox returns the trash mailbox name for the account
690func getTrashMailbox(account *config.Account) string {
691	switch account.ServiceProvider {
692	case "gmail":
693		return "[Gmail]/Trash"
694	case "icloud":
695		return "Deleted Messages"
696	default:
697		return "Trash"
698	}
699}
700
701// getArchiveMailbox returns the archive/all mail mailbox name for the account
702func getArchiveMailbox(account *config.Account) string {
703	switch account.ServiceProvider {
704	case "gmail":
705		return "[Gmail]/All Mail"
706	case "icloud":
707		return "Archive"
708	default:
709		return "Archive"
710	}
711}
712
713// FetchTrashEmails fetches emails from the trash folder
714func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
715	c, err := connect(account)
716	if err != nil {
717		return nil, err
718	}
719	defer c.Logout()
720
721	// Try to find trash by attribute first
722	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
723	if err != nil {
724		// Fallback to hardcoded path
725		trashMailbox = getTrashMailbox(account)
726	}
727
728	return FetchMailboxEmails(account, trashMailbox, limit, offset)
729}
730
731// FetchArchiveEmails fetches emails from the archive/all mail folder
732// Archive contains all emails, so we match where user is sender OR recipient
733func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
734	c, err := connect(account)
735	if err != nil {
736		return nil, err
737	}
738	defer c.Logout()
739
740	// Try to find archive by attribute first (Gmail uses \All)
741	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
742	if err != nil {
743		// Fallback to hardcoded path
744		archiveMailbox = getArchiveMailbox(account)
745	}
746
747	mbox, err := c.Select(archiveMailbox, false)
748	if err != nil {
749		return nil, err
750	}
751
752	if mbox.Messages == 0 {
753		return []Email{}, nil
754	}
755
756	to := mbox.Messages - offset
757	from := uint32(1)
758	if to > limit {
759		from = to - limit + 1
760	}
761
762	if to < 1 {
763		return []Email{}, nil
764	}
765
766	seqset := new(imap.SeqSet)
767	seqset.AddRange(from, to)
768
769	messages := make(chan *imap.Message, limit)
770	done := make(chan error, 1)
771	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
772	go func() {
773		done <- c.Fetch(seqset, fetchItems, messages)
774	}()
775
776	var msgs []*imap.Message
777	for msg := range messages {
778		msgs = append(msgs, msg)
779	}
780
781	if err := <-done; err != nil {
782		return nil, err
783	}
784
785	// Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
786	fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
787	if fetchEmail == "" {
788		fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
789	}
790
791	var emails []Email
792	for _, msg := range msgs {
793		if msg == nil || msg.Envelope == nil {
794			continue
795		}
796
797		var fromAddr string
798		if len(msg.Envelope.From) > 0 {
799			fromAddr = msg.Envelope.From[0].Address()
800		}
801
802		var toAddrList []string
803		for _, addr := range msg.Envelope.To {
804			toAddrList = append(toAddrList, addr.Address())
805		}
806		for _, addr := range msg.Envelope.Cc {
807			toAddrList = append(toAddrList, addr.Address())
808		}
809
810		// For archive/All Mail, match emails where user is sender OR recipient
811		matched := false
812		// Check if user is the sender
813		if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
814			matched = true
815		}
816		// Check if user is a recipient
817		if !matched {
818			for _, r := range toAddrList {
819				if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
820					matched = true
821					break
822				}
823			}
824		}
825
826		if !matched {
827			continue
828		}
829
830		emails = append(emails, Email{
831			UID:       msg.Uid,
832			From:      fromAddr,
833			To:        toAddrList,
834			Subject:   decodeHeader(msg.Envelope.Subject),
835			Date:      msg.Envelope.Date,
836			AccountID: account.ID,
837		})
838	}
839
840	// Reverse to get newest first
841	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
842		emails[i], emails[j] = emails[j], emails[i]
843	}
844
845	return emails, nil
846}
847
848// FetchTrashEmailBody fetches the body of an email from trash
849func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
850	c, err := connect(account)
851	if err != nil {
852		return "", nil, err
853	}
854	defer c.Logout()
855
856	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
857	if err != nil {
858		trashMailbox = getTrashMailbox(account)
859	}
860
861	return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
862}
863
864// FetchArchiveEmailBody fetches the body of an email from archive
865func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
866	c, err := connect(account)
867	if err != nil {
868		return "", nil, err
869	}
870	defer c.Logout()
871
872	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
873	if err != nil {
874		archiveMailbox = getArchiveMailbox(account)
875	}
876
877	return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
878}
879
880// FetchTrashAttachment fetches an attachment from trash
881func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
882	c, err := connect(account)
883	if err != nil {
884		return nil, err
885	}
886	defer c.Logout()
887
888	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
889	if err != nil {
890		trashMailbox = getTrashMailbox(account)
891	}
892
893	return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
894}
895
896// FetchArchiveAttachment fetches an attachment from archive
897func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
898	c, err := connect(account)
899	if err != nil {
900		return nil, err
901	}
902	defer c.Logout()
903
904	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
905	if err != nil {
906		archiveMailbox = getArchiveMailbox(account)
907	}
908
909	return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
910}
911
912// DeleteTrashEmail permanently deletes an email from trash
913func DeleteTrashEmail(account *config.Account, uid uint32) error {
914	c, err := connect(account)
915	if err != nil {
916		return err
917	}
918	defer c.Logout()
919
920	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
921	if err != nil {
922		trashMailbox = getTrashMailbox(account)
923	}
924
925	return DeleteEmailFromMailbox(account, trashMailbox, uid)
926}
927
928// DeleteArchiveEmail deletes an email from archive (moves to trash)
929func DeleteArchiveEmail(account *config.Account, uid uint32) error {
930	c, err := connect(account)
931	if err != nil {
932		return err
933	}
934	defer c.Logout()
935
936	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
937	if err != nil {
938		archiveMailbox = getArchiveMailbox(account)
939	}
940
941	return DeleteEmailFromMailbox(account, archiveMailbox, uid)
942}