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}