fetcher.go

  1package fetcher
  2
  3import (
  4	"bytes"
  5	"encoding/base64"
  6	"fmt"
  7	"io"
  8	"io/ioutil"
  9	"mime"
 10	"mime/quotedprintable"
 11	"strings"
 12	"time"
 13
 14	"github.com/emersion/go-imap"
 15	"github.com/emersion/go-imap/client"
 16	"github.com/emersion/go-message/mail"
 17	"github.com/floatpane/matcha/config"
 18	"golang.org/x/text/encoding/ianaindex"
 19	"golang.org/x/text/transform"
 20)
 21
 22// Attachment holds data for an email attachment.
 23type Attachment struct {
 24	Filename string
 25	PartID   string // Keep PartID to fetch on demand
 26	Data     []byte
 27	Encoding string // Store encoding for proper decoding
 28}
 29
 30type Email struct {
 31	UID         uint32
 32	From        string
 33	To          []string
 34	Subject     string
 35	Body        string
 36	Date        time.Time
 37	MessageID   string
 38	References  []string
 39	Attachments []Attachment
 40	AccountID   string // ID of the account this email belongs to
 41}
 42
 43func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
 44	mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
 45	if err != nil {
 46		body, _ := ioutil.ReadAll(reader)
 47		return string(body), nil
 48	}
 49
 50	charset := "utf-8"
 51	if params["charset"] != "" {
 52		charset = strings.ToLower(params["charset"])
 53	}
 54
 55	encoding, err := ianaindex.IANA.Encoding(charset)
 56	if err != nil || encoding == nil {
 57		encoding, _ = ianaindex.IANA.Encoding("utf-8")
 58	}
 59
 60	transformReader := transform.NewReader(reader, encoding.NewDecoder())
 61	decodedBody, err := ioutil.ReadAll(transformReader)
 62	if err != nil {
 63		return "", err
 64	}
 65
 66	if strings.HasPrefix(mediaType, "multipart/") {
 67		return "[This is a multipart message]", nil
 68	}
 69
 70	return string(decodedBody), nil
 71}
 72
 73func decodeHeader(header string) string {
 74	dec := new(mime.WordDecoder)
 75	dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
 76		encoding, err := ianaindex.IANA.Encoding(charset)
 77		if err != nil {
 78			return nil, err
 79		}
 80		return transform.NewReader(input, encoding.NewDecoder()), nil
 81	}
 82	decoded, err := dec.DecodeHeader(header)
 83	if err != nil {
 84		return header
 85	}
 86	return decoded
 87}
 88
 89func connect(account *config.Account) (*client.Client, error) {
 90	imapServer := account.GetIMAPServer()
 91	imapPort := account.GetIMAPPort()
 92
 93	if imapServer == "" {
 94		return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
 95	}
 96
 97	addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
 98	c, err := client.DialTLS(addr, nil)
 99	if err != nil {
100		return nil, err
101	}
102
103	if err := c.Login(account.Email, account.Password); err != nil {
104		return nil, err
105	}
106
107	return c, nil
108}
109
110func getSentMailbox(account *config.Account) string {
111	switch account.ServiceProvider {
112	case "gmail":
113		return "[Gmail]/Sent Mail"
114	case "icloud":
115		return "Sent Messages"
116	default:
117		return "Sent"
118	}
119}
120
121func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) {
122	c, err := connect(account)
123	if err != nil {
124		return nil, err
125	}
126	defer c.Logout()
127
128	mbox, err := c.Select(mailbox, false)
129	if err != nil {
130		return nil, err
131	}
132
133	if mbox.Messages == 0 {
134		return []Email{}, nil
135	}
136
137	to := mbox.Messages - offset
138	from := uint32(1)
139	if to > limit {
140		from = to - limit + 1
141	}
142
143	if to < 1 {
144		return []Email{}, nil
145	}
146
147	seqset := new(imap.SeqSet)
148	seqset.AddRange(from, to)
149
150	messages := make(chan *imap.Message, limit)
151	done := make(chan error, 1)
152	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
153	go func() {
154		done <- c.Fetch(seqset, fetchItems, messages)
155	}()
156
157	var msgs []*imap.Message
158	for msg := range messages {
159		msgs = append(msgs, msg)
160	}
161
162	if err := <-done; err != nil {
163		return nil, err
164	}
165
166	var emails []Email
167	for _, msg := range msgs {
168		if msg == nil || msg.Envelope == nil {
169			continue
170		}
171
172		var fromAddr string
173		if len(msg.Envelope.From) > 0 {
174			fromAddr = msg.Envelope.From[0].Address()
175		}
176
177		var toAddrList []string
178		// Build recipient list from To and Cc for matching and display
179		for _, addr := range msg.Envelope.To {
180			toAddrList = append(toAddrList, addr.Address())
181		}
182		for _, addr := range msg.Envelope.Cc {
183			toAddrList = append(toAddrList, addr.Address())
184		}
185
186		// Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
187		fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
188		if fetchEmail == "" {
189			fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
190		}
191
192		// Check if any recipient matches the fetchEmail
193		matched := false
194		for _, r := range toAddrList {
195			if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
196				matched = true
197				break
198			}
199		}
200
201		if !matched {
202			// Skip messages not addressed to the configured fetch email
203			continue
204		}
205
206		emails = append(emails, Email{
207			UID:       msg.Uid,
208			From:      fromAddr,
209			To:        toAddrList,
210			Subject:   decodeHeader(msg.Envelope.Subject),
211			Date:      msg.Envelope.Date,
212			AccountID: account.ID,
213		})
214	}
215
216	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
217		emails[i], emails[j] = emails[j], emails[i]
218	}
219
220	return emails, nil
221}
222
223func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
224	c, err := connect(account)
225	if err != nil {
226		return "", nil, err
227	}
228	defer c.Logout()
229
230	if _, err := c.Select(mailbox, false); err != nil {
231		return "", nil, err
232	}
233
234	seqset := new(imap.SeqSet)
235	seqset.AddNum(uid)
236
237	messages := make(chan *imap.Message, 1)
238	done := make(chan error, 1)
239	fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
240	go func() {
241		done <- c.UidFetch(seqset, fetchItems, messages)
242	}()
243
244	if err := <-done; err != nil {
245		return "", nil, err
246	}
247
248	msg := <-messages
249	if msg == nil || msg.BodyStructure == nil {
250		return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
251	}
252
253	var textPartID string
254	var attachments []Attachment
255	var checkPart func(part *imap.BodyStructure, partID string)
256	checkPart = func(part *imap.BodyStructure, partID string) {
257		// Check for text content
258		if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" {
259			textPartID = partID
260		}
261
262		// Check for attachments using multiple methods
263		filename := ""
264		// First try the Filename() method which handles various cases
265		if fn, err := part.Filename(); err == nil && fn != "" {
266			filename = fn
267		}
268		// Fallback: check DispositionParams
269		if filename == "" {
270			if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
271				filename = fn
272			}
273		}
274		// Fallback: check Params (for name parameter)
275		if filename == "" {
276			if fn, ok := part.Params["name"]; ok && fn != "" {
277				filename = fn
278			}
279		}
280		// Fallback: check Params for filename
281		if filename == "" {
282			if fn, ok := part.Params["filename"]; ok && fn != "" {
283				filename = fn
284			}
285		}
286
287		// Add as attachment if it has a disposition or a filename (and not just plain text)
288		if filename != "" && (part.Disposition == "attachment" || part.Disposition == "inline" || part.MIMEType != "text") {
289			attachments = append(attachments, Attachment{
290				Filename: filename,
291				PartID:   partID,
292				Encoding: part.Encoding, // Store encoding for proper decoding
293			})
294		}
295	}
296
297	var findParts func(*imap.BodyStructure, string)
298	findParts = func(bs *imap.BodyStructure, prefix string) {
299		// If this is a non-multipart message, check the body structure itself
300		if len(bs.Parts) == 0 {
301			partID := prefix
302			if partID == "" {
303				partID = "1"
304			}
305			checkPart(bs, partID)
306			return
307		}
308
309		// Iterate through parts
310		for i, part := range bs.Parts {
311			partID := fmt.Sprintf("%d", i+1)
312			if prefix != "" {
313				partID = fmt.Sprintf("%s.%d", prefix, i+1)
314			}
315
316			checkPart(part, partID)
317
318			if len(part.Parts) > 0 {
319				findParts(part, partID)
320			}
321		}
322	}
323	findParts(msg.BodyStructure, "")
324
325	var body string
326	if textPartID != "" {
327		partMessages := make(chan *imap.Message, 1)
328		partDone := make(chan error, 1)
329
330		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
331		section, err := imap.ParseBodySectionName(fetchItem)
332		if err != nil {
333			return "", nil, err
334		}
335
336		go func() {
337			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
338		}()
339
340		if err := <-partDone; err != nil {
341			return "", nil, err
342		}
343
344		partMsg := <-partMessages
345		if partMsg != nil {
346			literal := partMsg.GetBody(section)
347			if literal != nil {
348				// The new decoding logic starts here
349				buf, _ := ioutil.ReadAll(literal)
350				mr, err := mail.CreateReader(bytes.NewReader(buf))
351				if err != nil {
352					body = string(buf)
353				} else {
354					p, err := mr.NextPart()
355					if err != nil {
356						body = string(buf)
357					} else {
358						encoding := p.Header.Get("Content-Transfer-Encoding")
359						bodyBytes, _ := ioutil.ReadAll(p.Body)
360
361						switch strings.ToLower(encoding) {
362						case "base64":
363							decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
364							if err == nil {
365								body = string(decoded)
366							} else {
367								body = string(bodyBytes)
368							}
369						case "quoted-printable":
370							decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes))))
371							if err == nil {
372								body = string(decoded)
373							} else {
374								body = string(bodyBytes)
375							}
376						default:
377							body = string(bodyBytes)
378						}
379					}
380				}
381			}
382		}
383	}
384
385	return body, attachments, nil
386}
387
388func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
389	c, err := connect(account)
390	if err != nil {
391		return nil, err
392	}
393	defer c.Logout()
394
395	if _, err := c.Select(mailbox, false); err != nil {
396		return nil, err
397	}
398
399	seqset := new(imap.SeqSet)
400	seqset.AddNum(uid)
401
402	fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
403	section, err := imap.ParseBodySectionName(fetchItem)
404	if err != nil {
405		return nil, err
406	}
407
408	messages := make(chan *imap.Message, 1)
409	done := make(chan error, 1)
410	go func() {
411		done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
412	}()
413
414	if err := <-done; err != nil {
415		return nil, err
416	}
417
418	msg := <-messages
419	if msg == nil {
420		return nil, fmt.Errorf("could not fetch attachment")
421	}
422
423	literal := msg.GetBody(section)
424	if literal == nil {
425		return nil, fmt.Errorf("could not get attachment body")
426	}
427
428	rawBytes, err := ioutil.ReadAll(literal)
429	if err != nil {
430		return nil, err
431	}
432
433	switch strings.ToLower(encoding) {
434	case "base64":
435		decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
436		decoded, err := ioutil.ReadAll(decoder)
437		if err == nil {
438			return decoded, nil
439		}
440		return rawBytes, nil
441	case "quoted-printable":
442		decoded, err := ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
443		if err == nil {
444			return decoded, nil
445		}
446		return rawBytes, nil
447	default:
448		return rawBytes, nil
449	}
450}
451
452func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
453	c, err := connect(account)
454	if err != nil {
455		return err
456	}
457	defer c.Logout()
458
459	if _, err := c.Select(sourceMailbox, false); err != nil {
460		return err
461	}
462
463	seqSet := new(imap.SeqSet)
464	seqSet.AddNum(uid)
465
466	return c.UidMove(seqSet, destMailbox)
467}
468
469func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
470	c, err := connect(account)
471	if err != nil {
472		return err
473	}
474	defer c.Logout()
475
476	if _, err := c.Select(mailbox, false); err != nil {
477		return err
478	}
479
480	seqSet := new(imap.SeqSet)
481	seqSet.AddNum(uid)
482
483	item := imap.FormatFlagsOp(imap.AddFlags, true)
484	flags := []interface{}{imap.DeletedFlag}
485
486	if err := c.UidStore(seqSet, item, flags, nil); err != nil {
487		return err
488	}
489
490	return c.Expunge(nil)
491}
492
493func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
494	var archiveMailbox string
495	switch account.ServiceProvider {
496	case "gmail":
497		archiveMailbox = "[Gmail]/All Mail"
498	default:
499		archiveMailbox = "Archive"
500	}
501	return moveEmail(account, uid, mailbox, archiveMailbox)
502}
503
504// Convenience wrappers defaulting to INBOX for existing call sites.
505
506func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
507	return FetchMailboxEmails(account, "INBOX", limit, offset)
508}
509
510func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
511	return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
512}
513
514func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
515	return FetchEmailBodyFromMailbox(account, "INBOX", uid)
516}
517
518func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
519	return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
520}
521
522func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
523	return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
524}
525
526func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
527	return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
528}
529
530func DeleteEmail(account *config.Account, uid uint32) error {
531	return DeleteEmailFromMailbox(account, "INBOX", uid)
532}
533
534func DeleteSentEmail(account *config.Account, uid uint32) error {
535	return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
536}
537
538func ArchiveEmail(account *config.Account, uid uint32) error {
539	return ArchiveEmailFromMailbox(account, "INBOX", uid)
540}
541
542func ArchiveSentEmail(account *config.Account, uid uint32) error {
543	return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
544}