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 FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
111	c, err := connect(account)
112	if err != nil {
113		return nil, err
114	}
115	defer c.Logout()
116
117	mbox, err := c.Select("INBOX", false)
118	if err != nil {
119		return nil, err
120	}
121
122	if mbox.Messages == 0 {
123		return []Email{}, nil
124	}
125
126	to := mbox.Messages - offset
127	from := uint32(1)
128	if to > limit {
129		from = to - limit + 1
130	}
131
132	if to < 1 {
133		return []Email{}, nil
134	}
135
136	seqset := new(imap.SeqSet)
137	seqset.AddRange(from, to)
138
139	messages := make(chan *imap.Message, limit)
140	done := make(chan error, 1)
141	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
142	go func() {
143		done <- c.Fetch(seqset, fetchItems, messages)
144	}()
145
146	var msgs []*imap.Message
147	for msg := range messages {
148		msgs = append(msgs, msg)
149	}
150
151	if err := <-done; err != nil {
152		return nil, err
153	}
154
155	var emails []Email
156	for _, msg := range msgs {
157		if msg == nil || msg.Envelope == nil {
158			continue
159		}
160
161		var fromAddr string
162		if len(msg.Envelope.From) > 0 {
163			fromAddr = msg.Envelope.From[0].Address()
164		}
165
166		var toAddrList []string
167		// Build recipient list from To and Cc for matching and display
168		for _, addr := range msg.Envelope.To {
169			toAddrList = append(toAddrList, addr.Address())
170		}
171		for _, addr := range msg.Envelope.Cc {
172			toAddrList = append(toAddrList, addr.Address())
173		}
174
175		// Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
176		fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
177		if fetchEmail == "" {
178			fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
179		}
180
181		// Check if any recipient matches the fetchEmail
182		matched := false
183		for _, r := range toAddrList {
184			if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
185				matched = true
186				break
187			}
188		}
189
190		if !matched {
191			// Skip messages not addressed to the configured fetch email
192			continue
193		}
194
195		emails = append(emails, Email{
196			UID:       msg.Uid,
197			From:      fromAddr,
198			To:        toAddrList,
199			Subject:   decodeHeader(msg.Envelope.Subject),
200			Date:      msg.Envelope.Date,
201			AccountID: account.ID,
202		})
203	}
204
205	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
206		emails[i], emails[j] = emails[j], emails[i]
207	}
208
209	return emails, nil
210}
211
212func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
213	c, err := connect(account)
214	if err != nil {
215		return "", nil, err
216	}
217	defer c.Logout()
218
219	if _, err := c.Select("INBOX", false); err != nil {
220		return "", nil, err
221	}
222
223	seqset := new(imap.SeqSet)
224	seqset.AddNum(uid)
225
226	messages := make(chan *imap.Message, 1)
227	done := make(chan error, 1)
228	fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
229	go func() {
230		done <- c.UidFetch(seqset, fetchItems, messages)
231	}()
232
233	if err := <-done; err != nil {
234		return "", nil, err
235	}
236
237	msg := <-messages
238	if msg == nil || msg.BodyStructure == nil {
239		return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
240	}
241
242	var textPartID string
243	var attachments []Attachment
244	var checkPart func(part *imap.BodyStructure, partID string)
245	checkPart = func(part *imap.BodyStructure, partID string) {
246		// Check for text content
247		if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" {
248			textPartID = partID
249		}
250
251		// Check for attachments using multiple methods
252		filename := ""
253		// First try the Filename() method which handles various cases
254		if fn, err := part.Filename(); err == nil && fn != "" {
255			filename = fn
256		}
257		// Fallback: check DispositionParams
258		if filename == "" {
259			if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
260				filename = fn
261			}
262		}
263		// Fallback: check Params (for name parameter)
264		if filename == "" {
265			if fn, ok := part.Params["name"]; ok && fn != "" {
266				filename = fn
267			}
268		}
269		// Fallback: check Params for filename
270		if filename == "" {
271			if fn, ok := part.Params["filename"]; ok && fn != "" {
272				filename = fn
273			}
274		}
275
276		// Add as attachment if it has a disposition or a filename (and not just plain text)
277		if filename != "" && (part.Disposition == "attachment" || part.Disposition == "inline" || part.MIMEType != "text") {
278			attachments = append(attachments, Attachment{
279				Filename: filename,
280				PartID:   partID,
281				Encoding: part.Encoding, // Store encoding for proper decoding
282			})
283		}
284	}
285
286	var findParts func(*imap.BodyStructure, string)
287	findParts = func(bs *imap.BodyStructure, prefix string) {
288		// If this is a non-multipart message, check the body structure itself
289		if len(bs.Parts) == 0 {
290			partID := prefix
291			if partID == "" {
292				partID = "1"
293			}
294			checkPart(bs, partID)
295			return
296		}
297
298		// Iterate through parts
299		for i, part := range bs.Parts {
300			partID := fmt.Sprintf("%d", i+1)
301			if prefix != "" {
302				partID = fmt.Sprintf("%s.%d", prefix, i+1)
303			}
304
305			checkPart(part, partID)
306
307			if len(part.Parts) > 0 {
308				findParts(part, partID)
309			}
310		}
311	}
312	findParts(msg.BodyStructure, "")
313
314	var body string
315	if textPartID != "" {
316		partMessages := make(chan *imap.Message, 1)
317		partDone := make(chan error, 1)
318
319		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
320		section, err := imap.ParseBodySectionName(fetchItem)
321		if err != nil {
322			return "", nil, err
323		}
324
325		go func() {
326			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
327		}()
328
329		if err := <-partDone; err != nil {
330			return "", nil, err
331		}
332
333		partMsg := <-partMessages
334		if partMsg != nil {
335			literal := partMsg.GetBody(section)
336			if literal != nil {
337				// The new decoding logic starts here
338				buf, _ := ioutil.ReadAll(literal)
339				mr, err := mail.CreateReader(bytes.NewReader(buf))
340				if err != nil {
341					body = string(buf)
342				} else {
343					p, err := mr.NextPart()
344					if err != nil {
345						body = string(buf)
346					} else {
347						encoding := p.Header.Get("Content-Transfer-Encoding")
348						bodyBytes, _ := ioutil.ReadAll(p.Body)
349
350						switch strings.ToLower(encoding) {
351						case "base64":
352							decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
353							if err == nil {
354								body = string(decoded)
355							} else {
356								body = string(bodyBytes)
357							}
358						case "quoted-printable":
359							decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes))))
360							if err == nil {
361								body = string(decoded)
362							} else {
363								body = string(bodyBytes)
364							}
365						default:
366							body = string(bodyBytes)
367						}
368					}
369				}
370			}
371		}
372	}
373
374	return body, attachments, nil
375}
376
377func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
378	c, err := connect(account)
379	if err != nil {
380		return nil, err
381	}
382	defer c.Logout()
383
384	if _, err := c.Select("INBOX", false); err != nil {
385		return nil, err
386	}
387
388	seqset := new(imap.SeqSet)
389	seqset.AddNum(uid)
390
391	fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
392	section, err := imap.ParseBodySectionName(fetchItem)
393	if err != nil {
394		return nil, err
395	}
396
397	messages := make(chan *imap.Message, 1)
398	done := make(chan error, 1)
399	go func() {
400		done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
401	}()
402
403	if err := <-done; err != nil {
404		return nil, err
405	}
406
407	msg := <-messages
408	if msg == nil {
409		return nil, fmt.Errorf("could not fetch attachment")
410	}
411
412	literal := msg.GetBody(section)
413	if literal == nil {
414		return nil, fmt.Errorf("could not get attachment body")
415	}
416
417	rawBytes, err := ioutil.ReadAll(literal)
418	if err != nil {
419		return nil, err
420	}
421
422	switch strings.ToLower(encoding) {
423	case "base64":
424		decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
425		decoded, err := ioutil.ReadAll(decoder)
426		if err == nil {
427			return decoded, nil
428		}
429		return rawBytes, nil
430	case "quoted-printable":
431		decoded, err := ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
432		if err == nil {
433			return decoded, nil
434		}
435		return rawBytes, nil
436	default:
437		return rawBytes, nil
438	}
439}
440
441func moveEmail(account *config.Account, uid uint32, destMailbox string) error {
442	c, err := connect(account)
443	if err != nil {
444		return err
445	}
446	defer c.Logout()
447
448	if _, err := c.Select("INBOX", false); err != nil {
449		return err
450	}
451
452	seqSet := new(imap.SeqSet)
453	seqSet.AddNum(uid)
454
455	return c.UidMove(seqSet, destMailbox)
456}
457
458func DeleteEmail(account *config.Account, uid uint32) error {
459	c, err := connect(account)
460	if err != nil {
461		return err
462	}
463	defer c.Logout()
464
465	if _, err := c.Select("INBOX", false); err != nil {
466		return err
467	}
468
469	seqSet := new(imap.SeqSet)
470	seqSet.AddNum(uid)
471
472	item := imap.FormatFlagsOp(imap.AddFlags, true)
473	flags := []interface{}{imap.DeletedFlag}
474
475	if err := c.UidStore(seqSet, item, flags, nil); err != nil {
476		return err
477	}
478
479	return c.Expunge(nil)
480}
481
482func ArchiveEmail(account *config.Account, uid uint32) error {
483	var archiveMailbox string
484	switch account.ServiceProvider {
485	case "gmail":
486		archiveMailbox = "[Gmail]/All Mail"
487	default:
488		archiveMailbox = "Archive"
489	}
490	return moveEmail(account, uid, archiveMailbox)
491}