fetcher.go

  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}