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}
 28
 29type Email struct {
 30	UID         uint32
 31	From        string
 32	To          []string
 33	Subject     string
 34	Body        string
 35	Date        time.Time
 36	MessageID   string
 37	References  []string
 38	Attachments []Attachment
 39	AccountID   string // ID of the account this email belongs to
 40}
 41
 42func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
 43	mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
 44	if err != nil {
 45		body, _ := ioutil.ReadAll(reader)
 46		return string(body), nil
 47	}
 48
 49	charset := "utf-8"
 50	if params["charset"] != "" {
 51		charset = strings.ToLower(params["charset"])
 52	}
 53
 54	encoding, err := ianaindex.IANA.Encoding(charset)
 55	if err != nil || encoding == nil {
 56		encoding, _ = ianaindex.IANA.Encoding("utf-8")
 57	}
 58
 59	transformReader := transform.NewReader(reader, encoding.NewDecoder())
 60	decodedBody, err := ioutil.ReadAll(transformReader)
 61	if err != nil {
 62		return "", err
 63	}
 64
 65	if strings.HasPrefix(mediaType, "multipart/") {
 66		return "[This is a multipart message]", nil
 67	}
 68
 69	return string(decodedBody), nil
 70}
 71
 72func decodeHeader(header string) string {
 73	dec := new(mime.WordDecoder)
 74	dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
 75		encoding, err := ianaindex.IANA.Encoding(charset)
 76		if err != nil {
 77			return nil, err
 78		}
 79		return transform.NewReader(input, encoding.NewDecoder()), nil
 80	}
 81	decoded, err := dec.DecodeHeader(header)
 82	if err != nil {
 83		return header
 84	}
 85	return decoded
 86}
 87
 88func connect(account *config.Account) (*client.Client, error) {
 89	imapServer := account.GetIMAPServer()
 90	imapPort := account.GetIMAPPort()
 91
 92	if imapServer == "" {
 93		return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
 94	}
 95
 96	addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
 97	c, err := client.DialTLS(addr, nil)
 98	if err != nil {
 99		return nil, err
100	}
101
102	if err := c.Login(account.Email, account.Password); err != nil {
103		return nil, err
104	}
105
106	return c, nil
107}
108
109func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
110	c, err := connect(account)
111	if err != nil {
112		return nil, err
113	}
114	defer c.Logout()
115
116	mbox, err := c.Select("INBOX", false)
117	if err != nil {
118		return nil, err
119	}
120
121	if mbox.Messages == 0 {
122		return []Email{}, nil
123	}
124
125	to := mbox.Messages - offset
126	from := uint32(1)
127	if to > limit {
128		from = to - limit + 1
129	}
130
131	if to < 1 {
132		return []Email{}, nil
133	}
134
135	seqset := new(imap.SeqSet)
136	seqset.AddRange(from, to)
137
138	messages := make(chan *imap.Message, limit)
139	done := make(chan error, 1)
140	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
141	go func() {
142		done <- c.Fetch(seqset, fetchItems, messages)
143	}()
144
145	var msgs []*imap.Message
146	for msg := range messages {
147		msgs = append(msgs, msg)
148	}
149
150	if err := <-done; err != nil {
151		return nil, err
152	}
153
154	var emails []Email
155	for _, msg := range msgs {
156		if msg == nil || msg.Envelope == nil {
157			continue
158		}
159
160		var fromAddr string
161		if len(msg.Envelope.From) > 0 {
162			fromAddr = msg.Envelope.From[0].Address()
163		}
164
165		var toAddrList []string
166		for _, addr := range msg.Envelope.To {
167			toAddrList = append(toAddrList, addr.Address())
168		}
169
170		emails = append(emails, Email{
171			UID:       msg.Uid,
172			From:      fromAddr,
173			To:        toAddrList,
174			Subject:   decodeHeader(msg.Envelope.Subject),
175			Date:      msg.Envelope.Date,
176			AccountID: account.ID,
177		})
178	}
179
180	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
181		emails[i], emails[j] = emails[j], emails[i]
182	}
183
184	return emails, nil
185}
186
187func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
188	c, err := connect(account)
189	if err != nil {
190		return "", nil, err
191	}
192	defer c.Logout()
193
194	if _, err := c.Select("INBOX", false); err != nil {
195		return "", nil, err
196	}
197
198	seqset := new(imap.SeqSet)
199	seqset.AddNum(uid)
200
201	messages := make(chan *imap.Message, 1)
202	done := make(chan error, 1)
203	fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
204	go func() {
205		done <- c.UidFetch(seqset, fetchItems, messages)
206	}()
207
208	if err := <-done; err != nil {
209		return "", nil, err
210	}
211
212	msg := <-messages
213	if msg == nil || msg.BodyStructure == nil {
214		return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
215	}
216
217	var textPartID string
218	var attachments []Attachment
219	var checkPart func(part *imap.BodyStructure, partID string)
220	checkPart = func(part *imap.BodyStructure, partID string) {
221		// Check for text content
222		if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" {
223			textPartID = partID
224		}
225
226		// Check for attachments using multiple methods
227		filename := ""
228		// First try the Filename() method which handles various cases
229		if fn, err := part.Filename(); err == nil && fn != "" {
230			filename = fn
231		}
232		// Fallback: check DispositionParams
233		if filename == "" {
234			if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
235				filename = fn
236			}
237		}
238		// Fallback: check Params (for name parameter)
239		if filename == "" {
240			if fn, ok := part.Params["name"]; ok && fn != "" {
241				filename = fn
242			}
243		}
244		// Fallback: check Params for filename
245		if filename == "" {
246			if fn, ok := part.Params["filename"]; ok && fn != "" {
247				filename = fn
248			}
249		}
250
251		// Add as attachment if it has a disposition or a filename (and not just plain text)
252		if filename != "" && (part.Disposition == "attachment" || part.Disposition == "inline" || part.MIMEType != "text") {
253			attachments = append(attachments, Attachment{Filename: filename, PartID: partID})
254		}
255	}
256
257	var findParts func(*imap.BodyStructure, string)
258	findParts = func(bs *imap.BodyStructure, prefix string) {
259		// If this is a non-multipart message, check the body structure itself
260		if len(bs.Parts) == 0 {
261			partID := prefix
262			if partID == "" {
263				partID = "1"
264			}
265			checkPart(bs, partID)
266			return
267		}
268
269		// Iterate through parts
270		for i, part := range bs.Parts {
271			partID := fmt.Sprintf("%d", i+1)
272			if prefix != "" {
273				partID = fmt.Sprintf("%s.%d", prefix, i+1)
274			}
275
276			checkPart(part, partID)
277
278			if len(part.Parts) > 0 {
279				findParts(part, partID)
280			}
281		}
282	}
283	findParts(msg.BodyStructure, "")
284
285	var body string
286	if textPartID != "" {
287		partMessages := make(chan *imap.Message, 1)
288		partDone := make(chan error, 1)
289
290		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
291		section, err := imap.ParseBodySectionName(fetchItem)
292		if err != nil {
293			return "", nil, err
294		}
295
296		go func() {
297			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
298		}()
299
300		if err := <-partDone; err != nil {
301			return "", nil, err
302		}
303
304		partMsg := <-partMessages
305		if partMsg != nil {
306			literal := partMsg.GetBody(section)
307			if literal != nil {
308				// The new decoding logic starts here
309				buf, _ := ioutil.ReadAll(literal)
310				mr, err := mail.CreateReader(bytes.NewReader(buf))
311				if err != nil {
312					body = string(buf)
313				} else {
314					p, err := mr.NextPart()
315					if err != nil {
316						body = string(buf)
317					} else {
318						encoding := p.Header.Get("Content-Transfer-Encoding")
319						bodyBytes, _ := ioutil.ReadAll(p.Body)
320
321						switch strings.ToLower(encoding) {
322						case "base64":
323							decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
324							if err == nil {
325								body = string(decoded)
326							} else {
327								body = string(bodyBytes)
328							}
329						case "quoted-printable":
330							decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes))))
331							if err == nil {
332								body = string(decoded)
333							} else {
334								body = string(bodyBytes)
335							}
336						default:
337							body = string(bodyBytes)
338						}
339					}
340				}
341			}
342		}
343	}
344
345	return body, attachments, nil
346}
347
348func FetchAttachment(account *config.Account, uid uint32, partID string) ([]byte, error) {
349	c, err := connect(account)
350	if err != nil {
351		return nil, err
352	}
353	defer c.Logout()
354
355	if _, err := c.Select("INBOX", false); err != nil {
356		return nil, err
357	}
358
359	seqset := new(imap.SeqSet)
360	seqset.AddNum(uid)
361
362	fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
363	section, err := imap.ParseBodySectionName(fetchItem)
364	if err != nil {
365		return nil, err
366	}
367
368	messages := make(chan *imap.Message, 1)
369	done := make(chan error, 1)
370	go func() {
371		done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
372	}()
373
374	if err := <-done; err != nil {
375		return nil, err
376	}
377
378	msg := <-messages
379	if msg == nil {
380		return nil, fmt.Errorf("could not fetch attachment")
381	}
382
383	literal := msg.GetBody(section)
384	if literal == nil {
385		return nil, fmt.Errorf("could not get attachment body")
386	}
387
388	return ioutil.ReadAll(literal)
389}
390
391func moveEmail(account *config.Account, uid uint32, destMailbox string) error {
392	c, err := connect(account)
393	if err != nil {
394		return err
395	}
396	defer c.Logout()
397
398	if _, err := c.Select("INBOX", false); err != nil {
399		return err
400	}
401
402	seqSet := new(imap.SeqSet)
403	seqSet.AddNum(uid)
404
405	return c.UidMove(seqSet, destMailbox)
406}
407
408func DeleteEmail(account *config.Account, uid uint32) error {
409	c, err := connect(account)
410	if err != nil {
411		return err
412	}
413	defer c.Logout()
414
415	if _, err := c.Select("INBOX", false); err != nil {
416		return err
417	}
418
419	seqSet := new(imap.SeqSet)
420	seqSet.AddNum(uid)
421
422	item := imap.FormatFlagsOp(imap.AddFlags, true)
423	flags := []interface{}{imap.DeletedFlag}
424
425	if err := c.UidStore(seqSet, item, flags, nil); err != nil {
426		return err
427	}
428
429	return c.Expunge(nil)
430}
431
432func ArchiveEmail(account *config.Account, uid uint32) error {
433	var archiveMailbox string
434	switch account.ServiceProvider {
435	case "gmail":
436		archiveMailbox = "[Gmail]/All Mail"
437	default:
438		archiveMailbox = "Archive"
439	}
440	return moveEmail(account, uid, archiveMailbox)
441}