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	var allEmails []Email
183
184	// Start from the top minus offset
185	cursor := uint32(0)
186	if mbox.Messages > offset {
187		cursor = mbox.Messages - offset
188	} else {
189		return []Email{}, nil
190	}
191
192	// Determine if we should filter
193	fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
194	if fetchEmail == "" {
195		fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
196	}
197	isSentMailbox := mailbox == getSentMailbox(account)
198
199	// Loop until we have enough emails or run out of messages
200	for len(allEmails) < int(limit) && cursor > 0 {
201		// Determine chunk size
202		// Fetch at least 'limit' or 50 messages to reduce round trips
203		chunkSize := limit
204		if chunkSize < 50 {
205			chunkSize = 50
206		}
207
208		from := uint32(1)
209		if cursor > uint32(chunkSize) {
210			from = cursor - uint32(chunkSize) + 1
211		}
212
213		seqset := new(imap.SeqSet)
214		seqset.AddRange(from, cursor)
215
216		messages := make(chan *imap.Message, chunkSize)
217		done := make(chan error, 1)
218		fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
219
220		go func() {
221			done <- c.Fetch(seqset, fetchItems, messages)
222		}()
223
224		var batchMsgs []*imap.Message
225		for msg := range messages {
226			batchMsgs = append(batchMsgs, msg)
227		}
228
229		if err := <-done; err != nil {
230			return nil, err
231		}
232
233		// Filter messages in this batch
234		var batchEmails []Email
235		for _, msg := range batchMsgs {
236			if msg == nil || msg.Envelope == nil {
237				continue
238			}
239
240			var fromAddr string
241			if len(msg.Envelope.From) > 0 {
242				fromAddr = msg.Envelope.From[0].Address()
243			}
244
245			var toAddrList []string
246			for _, addr := range msg.Envelope.To {
247				toAddrList = append(toAddrList, addr.Address())
248			}
249			for _, addr := range msg.Envelope.Cc {
250				toAddrList = append(toAddrList, addr.Address())
251			}
252
253			matched := false
254			if isSentMailbox {
255				if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
256					matched = true
257				}
258			} else {
259				for _, r := range toAddrList {
260					if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
261						matched = true
262						break
263					}
264				}
265			}
266
267			if !matched {
268				continue
269			}
270
271			batchEmails = append(batchEmails, Email{
272				UID:       msg.Uid,
273				From:      fromAddr,
274				To:        toAddrList,
275				Subject:   decodeHeader(msg.Envelope.Subject),
276				Date:      msg.Envelope.Date,
277				AccountID: account.ID,
278			})
279		}
280
281		// Sort batch Newest -> Oldest (since IMAP usually returns Oldest->Newest or arbitrary)
282		// Assuming seqset order or standard behavior, we want to ensure we append Newest emails first
283		// so that the final list is correct.
284		// Actually, let's just sort the batch by UID desc (Newest first)
285		// Simple bubble sort for small batch
286		for i := 0; i < len(batchEmails); i++ {
287			for j := i + 1; j < len(batchEmails); j++ {
288				if batchEmails[j].UID > batchEmails[i].UID {
289					batchEmails[i], batchEmails[j] = batchEmails[j], batchEmails[i]
290				}
291			}
292		}
293
294		// Append to allEmails
295		allEmails = append(allEmails, batchEmails...)
296
297		// Update cursor for next iteration
298		cursor = from - 1
299	}
300
301	// Trim if we have too many
302	if len(allEmails) > int(limit) {
303		allEmails = allEmails[:limit]
304	}
305
306	return allEmails, nil
307}
308
309func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
310	c, err := connect(account)
311	if err != nil {
312		return "", nil, err
313	}
314	defer c.Logout()
315
316	if _, err := c.Select(mailbox, false); err != nil {
317		return "", nil, err
318	}
319
320	seqset := new(imap.SeqSet)
321	seqset.AddNum(uid)
322
323	fetchInlinePart := func(partID, encoding string) ([]byte, error) {
324		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
325		section, err := imap.ParseBodySectionName(fetchItem)
326		if err != nil {
327			return nil, err
328		}
329
330		partMessages := make(chan *imap.Message, 1)
331		partDone := make(chan error, 1)
332		go func() {
333			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
334		}()
335
336		if err := <-partDone; err != nil {
337			return nil, err
338		}
339
340		partMsg := <-partMessages
341		if partMsg == nil {
342			return nil, fmt.Errorf("could not fetch inline part %s", partID)
343		}
344
345		literal := partMsg.GetBody(section)
346		if literal == nil {
347			return nil, fmt.Errorf("could not get inline part body %s", partID)
348		}
349
350		rawBytes, err := ioutil.ReadAll(literal)
351		if err != nil {
352			return nil, err
353		}
354
355		return decodeAttachmentData(rawBytes, encoding)
356	}
357
358	messages := make(chan *imap.Message, 1)
359	done := make(chan error, 1)
360	fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
361	go func() {
362		done <- c.UidFetch(seqset, fetchItems, messages)
363	}()
364
365	if err := <-done; err != nil {
366		return "", nil, err
367	}
368
369	msg := <-messages
370	if msg == nil || msg.BodyStructure == nil {
371		return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
372	}
373
374	var plainPartID, plainPartEncoding string
375	var htmlPartID, htmlPartEncoding string
376	var attachments []Attachment
377	var checkPart func(part *imap.BodyStructure, partID string)
378	checkPart = func(part *imap.BodyStructure, partID string) {
379		// Check for text content (prefer html over plain)
380		if part.MIMEType == "text" {
381			sub := strings.ToLower(part.MIMESubType)
382			switch sub {
383			case "html":
384				if htmlPartID == "" {
385					htmlPartID = partID
386					htmlPartEncoding = part.Encoding
387				}
388			case "plain":
389				if plainPartID == "" {
390					plainPartID = partID
391					plainPartEncoding = part.Encoding
392				}
393			}
394		}
395
396		// Check for attachments using multiple methods
397		filename := ""
398		// First try the Filename() method which handles various cases
399		if fn, err := part.Filename(); err == nil && fn != "" {
400			filename = fn
401		}
402		// Fallback: check DispositionParams
403		if filename == "" {
404			if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
405				filename = fn
406			}
407		}
408		// Fallback: check Params (for name parameter)
409		if filename == "" {
410			if fn, ok := part.Params["name"]; ok && fn != "" {
411				filename = fn
412			}
413		}
414		// Fallback: check Params for filename
415		if filename == "" {
416			if fn, ok := part.Params["filename"]; ok && fn != "" {
417				filename = fn
418			}
419		}
420
421		// Add as attachment if it has a disposition or a filename (and not just plain text).
422		// Allow inline parts without filenames (common for cid images).
423		contentID := strings.Trim(part.Id, "<>")
424		mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType))
425		isCID := contentID != ""
426		isInline := part.Disposition == "inline" || isCID
427
428		if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
429			filename = "inline"
430		}
431		if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
432			att := Attachment{
433				Filename:  filename,
434				PartID:    partID,
435				Encoding:  part.Encoding, // Store encoding for proper decoding
436				MIMEType:  mimeType,
437				ContentID: contentID,
438				Inline:    isInline,
439			}
440			if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
441				if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
442					att.Data = data
443				}
444			}
445			attachments = append(attachments, att)
446		}
447	}
448
449	var findParts func(*imap.BodyStructure, string)
450	findParts = func(bs *imap.BodyStructure, prefix string) {
451		// If this is a non-multipart message, check the body structure itself
452		if len(bs.Parts) == 0 {
453			partID := prefix
454			if partID == "" {
455				partID = "1"
456			}
457			checkPart(bs, partID)
458			return
459		}
460
461		// Iterate through parts
462		for i, part := range bs.Parts {
463			partID := fmt.Sprintf("%d", i+1)
464			if prefix != "" {
465				partID = fmt.Sprintf("%s.%d", prefix, i+1)
466			}
467
468			checkPart(part, partID)
469
470			if len(part.Parts) > 0 {
471				findParts(part, partID)
472			}
473		}
474	}
475	findParts(msg.BodyStructure, "")
476
477	var body string
478	textPartID := ""
479	textPartEncoding := ""
480	if htmlPartID != "" {
481		textPartID = htmlPartID
482		textPartEncoding = htmlPartEncoding
483	} else if plainPartID != "" {
484		textPartID = plainPartID
485		textPartEncoding = plainPartEncoding
486	}
487	if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
488		msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
489		fmt.Print(msg)
490		if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
491			if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
492				_, _ = f.WriteString(msg)
493				_ = f.Close()
494			}
495		}
496	}
497	if textPartID != "" {
498		partMessages := make(chan *imap.Message, 1)
499		partDone := make(chan error, 1)
500
501		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
502		section, err := imap.ParseBodySectionName(fetchItem)
503		if err != nil {
504			return "", nil, err
505		}
506
507		go func() {
508			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
509		}()
510
511		if err := <-partDone; err != nil {
512			return "", nil, err
513		}
514
515		partMsg := <-partMessages
516		if partMsg != nil {
517			literal := partMsg.GetBody(section)
518			if literal != nil {
519				buf, _ := ioutil.ReadAll(literal)
520				// Use the encoding from BodyStructure to decode
521				if decoded, err := decodeAttachmentData(buf, textPartEncoding); err == nil {
522					body = string(decoded)
523				} else {
524					body = string(buf)
525				}
526			}
527		}
528	}
529
530	return body, attachments, nil
531}
532
533func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
534	c, err := connect(account)
535	if err != nil {
536		return nil, err
537	}
538	defer c.Logout()
539
540	if _, err := c.Select(mailbox, false); err != nil {
541		return nil, err
542	}
543
544	seqset := new(imap.SeqSet)
545	seqset.AddNum(uid)
546
547	fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
548	section, err := imap.ParseBodySectionName(fetchItem)
549	if err != nil {
550		return nil, err
551	}
552
553	messages := make(chan *imap.Message, 1)
554	done := make(chan error, 1)
555	go func() {
556		done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
557	}()
558
559	if err := <-done; err != nil {
560		return nil, err
561	}
562
563	msg := <-messages
564	if msg == nil {
565		return nil, fmt.Errorf("could not fetch attachment")
566	}
567
568	literal := msg.GetBody(section)
569	if literal == nil {
570		return nil, fmt.Errorf("could not get attachment body")
571	}
572
573	rawBytes, err := ioutil.ReadAll(literal)
574	if err != nil {
575		return nil, err
576	}
577
578	decoded, err := decodeAttachmentData(rawBytes, encoding)
579	if err != nil {
580		return rawBytes, nil
581	}
582	return decoded, nil
583}
584
585func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
586	c, err := connect(account)
587	if err != nil {
588		return err
589	}
590	defer c.Logout()
591
592	if _, err := c.Select(sourceMailbox, false); err != nil {
593		return err
594	}
595
596	seqSet := new(imap.SeqSet)
597	seqSet.AddNum(uid)
598
599	return c.UidMove(seqSet, destMailbox)
600}
601
602func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
603	c, err := connect(account)
604	if err != nil {
605		return err
606	}
607	defer c.Logout()
608
609	if _, err := c.Select(mailbox, false); err != nil {
610		return err
611	}
612
613	seqSet := new(imap.SeqSet)
614	seqSet.AddNum(uid)
615
616	item := imap.FormatFlagsOp(imap.AddFlags, true)
617	flags := []interface{}{imap.DeletedFlag}
618
619	if err := c.UidStore(seqSet, item, flags, nil); err != nil {
620		return err
621	}
622
623	return c.Expunge(nil)
624}
625
626func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
627	c, err := connect(account)
628	if err != nil {
629		return err
630	}
631	defer c.Logout()
632
633	var archiveMailbox string
634	switch account.ServiceProvider {
635	case "gmail":
636		// For Gmail, find the mailbox with the \All attribute
637		archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr)
638		if err != nil {
639			// Fallback to hardcoded path if attribute lookup fails
640			archiveMailbox = "[Gmail]/All Mail"
641		}
642	default:
643		archiveMailbox = "Archive"
644	}
645
646	if _, err := c.Select(mailbox, false); err != nil {
647		return err
648	}
649
650	seqSet := new(imap.SeqSet)
651	seqSet.AddNum(uid)
652
653	return c.UidMove(seqSet, archiveMailbox)
654}
655
656// Convenience wrappers defaulting to INBOX for existing call sites.
657
658func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
659	return FetchMailboxEmails(account, "INBOX", limit, offset)
660}
661
662func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
663	return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
664}
665
666func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
667	return FetchEmailBodyFromMailbox(account, "INBOX", uid)
668}
669
670func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
671	return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
672}
673
674func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
675	return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
676}
677
678func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
679	return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
680}
681
682func DeleteEmail(account *config.Account, uid uint32) error {
683	return DeleteEmailFromMailbox(account, "INBOX", uid)
684}
685
686func DeleteSentEmail(account *config.Account, uid uint32) error {
687	return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
688}
689
690func ArchiveEmail(account *config.Account, uid uint32) error {
691	return ArchiveEmailFromMailbox(account, "INBOX", uid)
692}
693
694func ArchiveSentEmail(account *config.Account, uid uint32) error {
695	return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
696}
697
698// getTrashMailbox returns the trash mailbox name for the account
699func getTrashMailbox(account *config.Account) string {
700	switch account.ServiceProvider {
701	case "gmail":
702		return "[Gmail]/Trash"
703	case "icloud":
704		return "Deleted Messages"
705	default:
706		return "Trash"
707	}
708}
709
710// getArchiveMailbox returns the archive/all mail mailbox name for the account
711func getArchiveMailbox(account *config.Account) string {
712	switch account.ServiceProvider {
713	case "gmail":
714		return "[Gmail]/All Mail"
715	case "icloud":
716		return "Archive"
717	default:
718		return "Archive"
719	}
720}
721
722// FetchTrashEmails fetches emails from the trash folder
723func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
724	c, err := connect(account)
725	if err != nil {
726		return nil, err
727	}
728	defer c.Logout()
729
730	// Try to find trash by attribute first
731	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
732	if err != nil {
733		// Fallback to hardcoded path
734		trashMailbox = getTrashMailbox(account)
735	}
736
737	return FetchMailboxEmails(account, trashMailbox, limit, offset)
738}
739
740// FetchArchiveEmails fetches emails from the archive/all mail folder
741// Archive contains all emails, so we match where user is sender OR recipient
742func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
743	c, err := connect(account)
744	if err != nil {
745		return nil, err
746	}
747	defer c.Logout()
748
749	// Try to find archive by attribute first (Gmail uses \All)
750	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
751	if err != nil {
752		// Fallback to hardcoded path
753		archiveMailbox = getArchiveMailbox(account)
754	}
755
756	mbox, err := c.Select(archiveMailbox, false)
757	if err != nil {
758		return nil, err
759	}
760
761	if mbox.Messages == 0 {
762		return []Email{}, nil
763	}
764
765	to := mbox.Messages - offset
766	from := uint32(1)
767	if to > limit {
768		from = to - limit + 1
769	}
770
771	if to < 1 {
772		return []Email{}, nil
773	}
774
775	seqset := new(imap.SeqSet)
776	seqset.AddRange(from, to)
777
778	messages := make(chan *imap.Message, limit)
779	done := make(chan error, 1)
780	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
781	go func() {
782		done <- c.Fetch(seqset, fetchItems, messages)
783	}()
784
785	var msgs []*imap.Message
786	for msg := range messages {
787		msgs = append(msgs, msg)
788	}
789
790	if err := <-done; err != nil {
791		return nil, err
792	}
793
794	// Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
795	fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
796	if fetchEmail == "" {
797		fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
798	}
799
800	var emails []Email
801	for _, msg := range msgs {
802		if msg == nil || msg.Envelope == nil {
803			continue
804		}
805
806		var fromAddr string
807		if len(msg.Envelope.From) > 0 {
808			fromAddr = msg.Envelope.From[0].Address()
809		}
810
811		var toAddrList []string
812		for _, addr := range msg.Envelope.To {
813			toAddrList = append(toAddrList, addr.Address())
814		}
815		for _, addr := range msg.Envelope.Cc {
816			toAddrList = append(toAddrList, addr.Address())
817		}
818
819		// For archive/All Mail, match emails where user is sender OR recipient
820		matched := false
821		// Check if user is the sender
822		if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
823			matched = true
824		}
825		// Check if user is a recipient
826		if !matched {
827			for _, r := range toAddrList {
828				if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
829					matched = true
830					break
831				}
832			}
833		}
834
835		if !matched {
836			continue
837		}
838
839		emails = append(emails, Email{
840			UID:       msg.Uid,
841			From:      fromAddr,
842			To:        toAddrList,
843			Subject:   decodeHeader(msg.Envelope.Subject),
844			Date:      msg.Envelope.Date,
845			AccountID: account.ID,
846		})
847	}
848
849	// Reverse to get newest first
850	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
851		emails[i], emails[j] = emails[j], emails[i]
852	}
853
854	return emails, nil
855}
856
857// FetchTrashEmailBody fetches the body of an email from trash
858func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
859	c, err := connect(account)
860	if err != nil {
861		return "", nil, err
862	}
863	defer c.Logout()
864
865	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
866	if err != nil {
867		trashMailbox = getTrashMailbox(account)
868	}
869
870	return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
871}
872
873// FetchArchiveEmailBody fetches the body of an email from archive
874func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
875	c, err := connect(account)
876	if err != nil {
877		return "", nil, err
878	}
879	defer c.Logout()
880
881	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
882	if err != nil {
883		archiveMailbox = getArchiveMailbox(account)
884	}
885
886	return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
887}
888
889// FetchTrashAttachment fetches an attachment from trash
890func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
891	c, err := connect(account)
892	if err != nil {
893		return nil, err
894	}
895	defer c.Logout()
896
897	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
898	if err != nil {
899		trashMailbox = getTrashMailbox(account)
900	}
901
902	return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
903}
904
905// FetchArchiveAttachment fetches an attachment from archive
906func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
907	c, err := connect(account)
908	if err != nil {
909		return nil, err
910	}
911	defer c.Logout()
912
913	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
914	if err != nil {
915		archiveMailbox = getArchiveMailbox(account)
916	}
917
918	return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
919}
920
921// DeleteTrashEmail permanently deletes an email from trash
922func DeleteTrashEmail(account *config.Account, uid uint32) error {
923	c, err := connect(account)
924	if err != nil {
925		return err
926	}
927	defer c.Logout()
928
929	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
930	if err != nil {
931		trashMailbox = getTrashMailbox(account)
932	}
933
934	return DeleteEmailFromMailbox(account, trashMailbox, uid)
935}
936
937// DeleteArchiveEmail deletes an email from archive (moves to trash)
938func DeleteArchiveEmail(account *config.Account, uid uint32) error {
939	c, err := connect(account)
940	if err != nil {
941		return err
942	}
943	defer c.Logout()
944
945	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
946	if err != nil {
947		archiveMailbox = getArchiveMailbox(account)
948	}
949
950	return DeleteEmailFromMailbox(account, archiveMailbox, uid)
951}