fetcher.go

  1package fetcher
  2
  3import (
  4	"encoding/base64"
  5	"fmt"
  6	"io"
  7	"io/ioutil"
  8	"log"
  9	"mime"
 10	"strings"
 11	"time"
 12
 13	"github.com/andrinoff/email-cli/config"
 14	"github.com/emersion/go-imap"
 15	"github.com/emersion/go-imap/client"
 16	"github.com/emersion/go-message/mail"
 17	"golang.org/x/text/encoding/ianaindex"
 18	"golang.org/x/text/transform"
 19)
 20
 21// Attachment holds data for an email attachment.
 22type Attachment struct {
 23	Filename string
 24	Data     []byte
 25}
 26
 27type Email struct {
 28	UID         uint32
 29	From        string
 30	To          []string
 31	Subject     string
 32	Body        string
 33	Date        time.Time
 34	MessageID   string
 35	References  []string
 36	Attachments []Attachment
 37}
 38
 39func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
 40	mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
 41	if err != nil {
 42		body, _ := ioutil.ReadAll(reader)
 43		return string(body), nil
 44	}
 45
 46	charset := "utf-8"
 47	if params["charset"] != "" {
 48		charset = strings.ToLower(params["charset"])
 49	}
 50
 51	encoding, err := ianaindex.IANA.Encoding(charset)
 52	if err != nil || encoding == nil {
 53		encoding, _ = ianaindex.IANA.Encoding("utf-8")
 54	}
 55
 56	transformReader := transform.NewReader(reader, encoding.NewDecoder())
 57	decodedBody, err := ioutil.ReadAll(transformReader)
 58	if err != nil {
 59		return "", err
 60	}
 61
 62	if strings.HasPrefix(mediaType, "multipart/") {
 63		return "[This is a multipart message]", nil
 64	}
 65
 66	return string(decodedBody), nil
 67}
 68
 69func decodeHeader(header string) string {
 70	dec := new(mime.WordDecoder)
 71	dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
 72		encoding, err := ianaindex.IANA.Encoding(charset)
 73		if err != nil {
 74			return nil, err
 75		}
 76		return transform.NewReader(input, encoding.NewDecoder()), nil
 77	}
 78	decoded, err := dec.DecodeHeader(header)
 79	if err != nil {
 80		return header
 81	}
 82	return decoded
 83}
 84
 85func connect(cfg *config.Config) (*client.Client, error) {
 86	var imapServer string
 87	var imapPort int
 88
 89	switch cfg.ServiceProvider {
 90	case "gmail":
 91		imapServer = "imap.gmail.com"
 92		imapPort = 993
 93	case "icloud":
 94		imapServer = "imap.mail.me.com"
 95		imapPort = 993
 96	default:
 97		return nil, fmt.Errorf("unsupported service_provider: %s", cfg.ServiceProvider)
 98	}
 99
100	addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
101	c, err := client.DialTLS(addr, nil)
102	if err != nil {
103		return nil, err
104	}
105
106	if err := c.Login(cfg.Email, cfg.Password); err != nil {
107		return nil, err
108	}
109
110	return c, nil
111}
112
113func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
114	c, err := connect(cfg)
115	if err != nil {
116		return nil, err
117	}
118	defer c.Logout()
119
120	mbox, err := c.Select("INBOX", false)
121	if err != nil {
122		return nil, err
123	}
124
125	if mbox.Messages == 0 {
126		return []Email{}, nil
127	}
128
129	to := mbox.Messages - offset
130	from := uint32(1)
131	if to > limit {
132		from = to - limit + 1
133	}
134
135	if to < 1 {
136		return []Email{}, nil
137	}
138
139	seqset := new(imap.SeqSet)
140	seqset.AddRange(from, to)
141
142	messages := make(chan *imap.Message, limit)
143	done := make(chan error, 1)
144	// Only fetch Envelope and UID for the inbox view
145	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
146	go func() {
147		done <- c.Fetch(seqset, fetchItems, messages)
148	}()
149
150	// Collect messages from the channel into a slice
151	var msgs []*imap.Message
152	for msg := range messages {
153		msgs = append(msgs, msg)
154	}
155
156	// Wait for the fetch to complete and check for errors
157	if err := <-done; err != nil {
158		return nil, err
159	}
160
161	var emails []Email
162	for _, msg := range msgs {
163		if msg == nil || msg.Envelope == nil {
164			continue
165		}
166
167		var fromAddr string
168		if len(msg.Envelope.From) > 0 {
169			fromAddr = msg.Envelope.From[0].Address()
170		}
171
172		var toAddrList []string
173		for _, addr := range msg.Envelope.To {
174			toAddrList = append(toAddrList, addr.Address())
175		}
176
177		emails = append(emails, Email{
178			UID:     msg.Uid,
179			From:    fromAddr,
180			To:      toAddrList,
181			Subject: decodeHeader(msg.Envelope.Subject),
182			Date:    msg.Envelope.Date,
183		})
184	}
185
186	// Reverse the order to show newest first
187	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
188		emails[i], emails[j] = emails[j], emails[i]
189	}
190
191	return emails, nil
192}
193
194func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error) {
195	c, err := connect(cfg)
196	if err != nil {
197		return "", nil, err
198	}
199	defer c.Logout()
200
201	if _, err := c.Select("INBOX", false); err != nil {
202		return "", nil, err
203	}
204
205	seqset := new(imap.SeqSet)
206	seqset.AddNum(uid)
207
208	messages := make(chan *imap.Message, 1)
209	done := make(chan error, 1)
210	fetchItems := []imap.FetchItem{imap.FetchItem("BODY[]")}
211	go func() {
212		done <- c.UidFetch(seqset, fetchItems, messages)
213	}()
214
215	// Wait for the fetch operation to complete first.
216	if err := <-done; err != nil {
217		return "", nil, err
218	}
219
220	// Now that the fetch is complete, check for a message.
221	var msg *imap.Message
222	select {
223	case msg = <-messages:
224		if msg == nil {
225			return "", nil, fmt.Errorf("fetched a nil message")
226		}
227	default:
228		return "", nil, fmt.Errorf("no message found with UID %d", uid)
229	}
230
231	bodyLiteral := msg.GetBody(&imap.BodySectionName{})
232	if bodyLiteral == nil {
233		return "", nil, fmt.Errorf("could not get message body")
234	}
235
236	mr, err := mail.CreateReader(bodyLiteral)
237	if err != nil {
238		return "", nil, fmt.Errorf("error creating mail reader: %v", err)
239	}
240
241	var body string
242	var attachments []Attachment
243	for {
244		p, err := mr.NextPart()
245		if err == io.EOF {
246			break
247		} else if err != nil {
248			log.Printf("Error getting next part: %v", err)
249			break
250		}
251
252		cdHeader := p.Header.Get("Content-Disposition")
253		if cdHeader != "" {
254			disposition, params, err := mime.ParseMediaType(cdHeader)
255			if err == nil && (disposition == "attachment" || disposition == "inline") {
256				filename := params["filename"]
257				if filename != "" {
258					partBody, _ := ioutil.ReadAll(p.Body)
259					encoding := p.Header.Get("Content-Transfer-Encoding")
260					if strings.ToLower(encoding) == "base64" {
261						decoded, decodeErr := base64.StdEncoding.DecodeString(string(partBody))
262						if decodeErr == nil {
263							partBody = decoded
264						}
265					}
266					attachments = append(attachments, Attachment{Filename: filename, Data: partBody})
267					continue
268				}
269			}
270		}
271
272		mediaType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
273		if (mediaType == "text/plain" || mediaType == "text/html") && body == "" {
274			decodedPart, decodeErr := decodePart(p.Body, p.Header)
275			if decodeErr == nil {
276				body = decodedPart
277			}
278		}
279	}
280
281	return body, attachments, nil
282}
283
284func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error {
285	c, err := connect(cfg)
286	if err != nil {
287		return err
288	}
289	defer c.Logout()
290
291	if _, err := c.Select("INBOX", false); err != nil {
292		return err
293	}
294
295	seqSet := new(imap.SeqSet)
296	seqSet.AddNum(uid)
297
298	return c.UidMove(seqSet, destMailbox)
299}
300
301func DeleteEmail(cfg *config.Config, uid uint32) error {
302	c, err := connect(cfg)
303	if err != nil {
304		return err
305	}
306	defer c.Logout()
307
308	if _, err := c.Select("INBOX", false); err != nil {
309		return err
310	}
311
312	seqSet := new(imap.SeqSet)
313	seqSet.AddNum(uid)
314
315	item := imap.FormatFlagsOp(imap.AddFlags, true)
316	flags := []interface{}{imap.DeletedFlag}
317
318	if err := c.UidStore(seqSet, item, flags, nil); err != nil {
319		return err
320	}
321
322	return c.Expunge(nil)
323}
324
325func ArchiveEmail(cfg *config.Config, uid uint32) error {
326	var archiveMailbox string
327	switch cfg.ServiceProvider {
328	case "gmail":
329		archiveMailbox = "[Gmail]/All Mail"
330	default:
331		archiveMailbox = "Archive"
332	}
333	return moveEmail(cfg, uid, archiveMailbox)
334}