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}