fetcher.go

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