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