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
137func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) {
138	c, err := connect(account)
139	if err != nil {
140		return nil, err
141	}
142	defer c.Logout()
143
144	mbox, err := c.Select(mailbox, false)
145	if err != nil {
146		return nil, err
147	}
148
149	if mbox.Messages == 0 {
150		return []Email{}, nil
151	}
152
153	to := mbox.Messages - offset
154	from := uint32(1)
155	if to > limit {
156		from = to - limit + 1
157	}
158
159	if to < 1 {
160		return []Email{}, nil
161	}
162
163	seqset := new(imap.SeqSet)
164	seqset.AddRange(from, to)
165
166	messages := make(chan *imap.Message, limit)
167	done := make(chan error, 1)
168	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
169	go func() {
170		done <- c.Fetch(seqset, fetchItems, messages)
171	}()
172
173	var msgs []*imap.Message
174	for msg := range messages {
175		msgs = append(msgs, msg)
176	}
177
178	if err := <-done; err != nil {
179		return nil, err
180	}
181
182	var emails []Email
183	for _, msg := range msgs {
184		if msg == nil || msg.Envelope == nil {
185			continue
186		}
187
188		var fromAddr string
189		if len(msg.Envelope.From) > 0 {
190			fromAddr = msg.Envelope.From[0].Address()
191		}
192
193		var toAddrList []string
194		// Build recipient list from To and Cc for matching and display
195		for _, addr := range msg.Envelope.To {
196			toAddrList = append(toAddrList, addr.Address())
197		}
198		for _, addr := range msg.Envelope.Cc {
199			toAddrList = append(toAddrList, addr.Address())
200		}
201
202		// Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
203		fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
204		if fetchEmail == "" {
205			fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
206		}
207
208		// Determine if this is a sent mailbox
209		isSentMailbox := mailbox == getSentMailbox(account)
210
211		// Apply different filtering logic based on mailbox type
212		matched := false
213		if isSentMailbox {
214			// For sent mailbox, check if the sender matches the fetchEmail
215			if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
216				matched = true
217			}
218		} else {
219			// For inbox and other mailboxes, check if any recipient matches the fetchEmail
220			for _, r := range toAddrList {
221				if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
222					matched = true
223					break
224				}
225			}
226		}
227
228		if !matched {
229			// Skip messages not matching the filter criteria
230			continue
231		}
232
233		emails = append(emails, Email{
234			UID:       msg.Uid,
235			From:      fromAddr,
236			To:        toAddrList,
237			Subject:   decodeHeader(msg.Envelope.Subject),
238			Date:      msg.Envelope.Date,
239			AccountID: account.ID,
240		})
241	}
242
243	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
244		emails[i], emails[j] = emails[j], emails[i]
245	}
246
247	return emails, nil
248}
249
250func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
251	c, err := connect(account)
252	if err != nil {
253		return "", nil, err
254	}
255	defer c.Logout()
256
257	if _, err := c.Select(mailbox, false); err != nil {
258		return "", nil, err
259	}
260
261	seqset := new(imap.SeqSet)
262	seqset.AddNum(uid)
263
264	fetchInlinePart := func(partID, encoding string) ([]byte, error) {
265		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
266		section, err := imap.ParseBodySectionName(fetchItem)
267		if err != nil {
268			return nil, err
269		}
270
271		partMessages := make(chan *imap.Message, 1)
272		partDone := make(chan error, 1)
273		go func() {
274			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
275		}()
276
277		if err := <-partDone; err != nil {
278			return nil, err
279		}
280
281		partMsg := <-partMessages
282		if partMsg == nil {
283			return nil, fmt.Errorf("could not fetch inline part %s", partID)
284		}
285
286		literal := partMsg.GetBody(section)
287		if literal == nil {
288			return nil, fmt.Errorf("could not get inline part body %s", partID)
289		}
290
291		rawBytes, err := ioutil.ReadAll(literal)
292		if err != nil {
293			return nil, err
294		}
295
296		return decodeAttachmentData(rawBytes, encoding)
297	}
298
299	messages := make(chan *imap.Message, 1)
300	done := make(chan error, 1)
301	fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
302	go func() {
303		done <- c.UidFetch(seqset, fetchItems, messages)
304	}()
305
306	if err := <-done; err != nil {
307		return "", nil, err
308	}
309
310	msg := <-messages
311	if msg == nil || msg.BodyStructure == nil {
312		return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
313	}
314
315	var plainPartID string
316	var htmlPartID string
317	var attachments []Attachment
318	var checkPart func(part *imap.BodyStructure, partID string)
319	checkPart = func(part *imap.BodyStructure, partID string) {
320		// Check for text content (prefer html over plain)
321		if part.MIMEType == "text" {
322			sub := strings.ToLower(part.MIMESubType)
323			switch sub {
324			case "html":
325				if htmlPartID == "" {
326					htmlPartID = partID
327				}
328			case "plain":
329				if plainPartID == "" {
330					plainPartID = partID
331				}
332			}
333		}
334
335		// Check for attachments using multiple methods
336		filename := ""
337		// First try the Filename() method which handles various cases
338		if fn, err := part.Filename(); err == nil && fn != "" {
339			filename = fn
340		}
341		// Fallback: check DispositionParams
342		if filename == "" {
343			if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
344				filename = fn
345			}
346		}
347		// Fallback: check Params (for name parameter)
348		if filename == "" {
349			if fn, ok := part.Params["name"]; ok && fn != "" {
350				filename = fn
351			}
352		}
353		// Fallback: check Params for filename
354		if filename == "" {
355			if fn, ok := part.Params["filename"]; ok && fn != "" {
356				filename = fn
357			}
358		}
359
360		// Add as attachment if it has a disposition or a filename (and not just plain text).
361		// Allow inline parts without filenames (common for cid images).
362		contentID := strings.Trim(part.Id, "<>")
363		mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType))
364		isCID := contentID != ""
365		isInline := part.Disposition == "inline" || isCID
366
367		if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
368			filename = "inline"
369		}
370		if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
371			att := Attachment{
372				Filename:  filename,
373				PartID:    partID,
374				Encoding:  part.Encoding, // Store encoding for proper decoding
375				MIMEType:  mimeType,
376				ContentID: contentID,
377				Inline:    isInline,
378			}
379			if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
380				if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
381					att.Data = data
382				}
383			}
384			attachments = append(attachments, att)
385		}
386	}
387
388	var findParts func(*imap.BodyStructure, string)
389	findParts = func(bs *imap.BodyStructure, prefix string) {
390		// If this is a non-multipart message, check the body structure itself
391		if len(bs.Parts) == 0 {
392			partID := prefix
393			if partID == "" {
394				partID = "1"
395			}
396			checkPart(bs, partID)
397			return
398		}
399
400		// Iterate through parts
401		for i, part := range bs.Parts {
402			partID := fmt.Sprintf("%d", i+1)
403			if prefix != "" {
404				partID = fmt.Sprintf("%s.%d", prefix, i+1)
405			}
406
407			checkPart(part, partID)
408
409			if len(part.Parts) > 0 {
410				findParts(part, partID)
411			}
412		}
413	}
414	findParts(msg.BodyStructure, "")
415
416	var body string
417	textPartID := ""
418	if htmlPartID != "" {
419		textPartID = htmlPartID
420	} else if plainPartID != "" {
421		textPartID = plainPartID
422	}
423	if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
424		msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
425		fmt.Print(msg)
426		if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
427			if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
428				_, _ = f.WriteString(msg)
429				_ = f.Close()
430			}
431		}
432	}
433	if textPartID != "" {
434		partMessages := make(chan *imap.Message, 1)
435		partDone := make(chan error, 1)
436
437		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
438		section, err := imap.ParseBodySectionName(fetchItem)
439		if err != nil {
440			return "", nil, err
441		}
442
443		go func() {
444			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
445		}()
446
447		if err := <-partDone; err != nil {
448			return "", nil, err
449		}
450
451		partMsg := <-partMessages
452		if partMsg != nil {
453			literal := partMsg.GetBody(section)
454			if literal != nil {
455				// The new decoding logic starts here
456				buf, _ := ioutil.ReadAll(literal)
457				mr, err := mail.CreateReader(bytes.NewReader(buf))
458				if err != nil {
459					body = string(buf)
460				} else {
461					p, err := mr.NextPart()
462					if err != nil {
463						body = string(buf)
464					} else {
465						encoding := p.Header.Get("Content-Transfer-Encoding")
466						bodyBytes, _ := ioutil.ReadAll(p.Body)
467
468						switch strings.ToLower(encoding) {
469						case "base64":
470							decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
471							if err == nil {
472								body = string(decoded)
473							} else {
474								body = string(bodyBytes)
475							}
476						case "quoted-printable":
477							decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes))))
478							if err == nil {
479								body = string(decoded)
480							} else {
481								body = string(bodyBytes)
482							}
483						default:
484							body = string(bodyBytes)
485						}
486					}
487				}
488			}
489		}
490	}
491
492	return body, attachments, nil
493}
494
495func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
496	c, err := connect(account)
497	if err != nil {
498		return nil, err
499	}
500	defer c.Logout()
501
502	if _, err := c.Select(mailbox, false); err != nil {
503		return nil, err
504	}
505
506	seqset := new(imap.SeqSet)
507	seqset.AddNum(uid)
508
509	fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
510	section, err := imap.ParseBodySectionName(fetchItem)
511	if err != nil {
512		return nil, err
513	}
514
515	messages := make(chan *imap.Message, 1)
516	done := make(chan error, 1)
517	go func() {
518		done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
519	}()
520
521	if err := <-done; err != nil {
522		return nil, err
523	}
524
525	msg := <-messages
526	if msg == nil {
527		return nil, fmt.Errorf("could not fetch attachment")
528	}
529
530	literal := msg.GetBody(section)
531	if literal == nil {
532		return nil, fmt.Errorf("could not get attachment body")
533	}
534
535	rawBytes, err := ioutil.ReadAll(literal)
536	if err != nil {
537		return nil, err
538	}
539
540	decoded, err := decodeAttachmentData(rawBytes, encoding)
541	if err != nil {
542		return rawBytes, nil
543	}
544	return decoded, nil
545}
546
547func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
548	c, err := connect(account)
549	if err != nil {
550		return err
551	}
552	defer c.Logout()
553
554	if _, err := c.Select(sourceMailbox, false); err != nil {
555		return err
556	}
557
558	seqSet := new(imap.SeqSet)
559	seqSet.AddNum(uid)
560
561	return c.UidMove(seqSet, destMailbox)
562}
563
564func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
565	c, err := connect(account)
566	if err != nil {
567		return err
568	}
569	defer c.Logout()
570
571	if _, err := c.Select(mailbox, false); err != nil {
572		return err
573	}
574
575	seqSet := new(imap.SeqSet)
576	seqSet.AddNum(uid)
577
578	item := imap.FormatFlagsOp(imap.AddFlags, true)
579	flags := []interface{}{imap.DeletedFlag}
580
581	if err := c.UidStore(seqSet, item, flags, nil); err != nil {
582		return err
583	}
584
585	return c.Expunge(nil)
586}
587
588func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
589	var archiveMailbox string
590	switch account.ServiceProvider {
591	case "gmail":
592		archiveMailbox = "[Gmail]/All Mail"
593	default:
594		archiveMailbox = "Archive"
595	}
596	return moveEmail(account, uid, mailbox, archiveMailbox)
597}
598
599// Convenience wrappers defaulting to INBOX for existing call sites.
600
601func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
602	return FetchMailboxEmails(account, "INBOX", limit, offset)
603}
604
605func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
606	return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
607}
608
609func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
610	return FetchEmailBodyFromMailbox(account, "INBOX", uid)
611}
612
613func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
614	return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
615}
616
617func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
618	return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
619}
620
621func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
622	return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
623}
624
625func DeleteEmail(account *config.Account, uid uint32) error {
626	return DeleteEmailFromMailbox(account, "INBOX", uid)
627}
628
629func DeleteSentEmail(account *config.Account, uid uint32) error {
630	return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
631}
632
633func ArchiveEmail(account *config.Account, uid uint32) error {
634	return ArchiveEmailFromMailbox(account, "INBOX", uid)
635}
636
637func ArchiveSentEmail(account *config.Account, uid uint32) error {
638	return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
639}