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}