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 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchItem("BODY[]")}
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 bodyLiteral := msg.GetBody(&imap.BodySectionName{})
156 if bodyLiteral == nil {
157 log.Println("Could not get message body")
158 continue
159 }
160
161 mr, err := mail.CreateReader(bodyLiteral)
162 if err != nil {
163 log.Printf("Error creating mail reader: %v", err)
164 continue
165 }
166
167 header := mr.Header
168 fromAddrs, _ := header.AddressList("From")
169 toAddrs, _ := header.AddressList("To")
170 subject := decodeHeader(header.Get("Subject"))
171 date, _ := header.Date()
172 messageID := header.Get("Message-ID")
173 references := header.Get("References")
174
175 var fromAddr string
176 if len(fromAddrs) > 0 {
177 fromAddr = fromAddrs[0].Address
178 }
179
180 var toAddrList []string
181 for _, addr := range toAddrs {
182 toAddrList = append(toAddrList, addr.Address)
183 }
184
185 var body string
186 var attachments []Attachment
187 for {
188 p, err := mr.NextPart()
189 if err == io.EOF {
190 break
191 } else if err != nil {
192 log.Printf("Error getting next part: %v", err)
193 break
194 }
195
196 // Correctly parse Content-Disposition
197 cdHeader := p.Header.Get("Content-Disposition")
198 if cdHeader != "" {
199 disposition, params, err := mime.ParseMediaType(cdHeader)
200 if err == nil && (disposition == "attachment" || disposition == "inline") {
201 filename := params["filename"]
202 if filename != "" {
203 partBody, _ := ioutil.ReadAll(p.Body)
204 encoding := p.Header.Get("Content-Transfer-Encoding")
205 if strings.ToLower(encoding) == "base64" {
206 decoded, decodeErr := base64.StdEncoding.DecodeString(string(partBody))
207 if decodeErr == nil {
208 partBody = decoded
209 }
210 }
211 attachments = append(attachments, Attachment{Filename: filename, Data: partBody})
212 continue // Skip to next part
213 }
214 }
215 }
216
217 // Process body part if not an attachment
218 mediaType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
219 if (mediaType == "text/plain" || mediaType == "text/html") && body == "" {
220 decodedPart, decodeErr := decodePart(p.Body, p.Header)
221 if decodeErr == nil {
222 body = decodedPart
223 }
224 }
225 }
226
227 emails = append(emails, Email{
228 UID: msg.Uid,
229 From: fromAddr,
230 To: toAddrList,
231 Subject: subject,
232 Body: body,
233 Date: date,
234 MessageID: messageID,
235 References: strings.Fields(references),
236 Attachments: attachments,
237 })
238 }
239
240 if err := <-done; err != nil {
241 return nil, err
242 }
243
244 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
245 emails[i], emails[j] = emails[j], emails[i]
246 }
247
248 return emails, nil
249}
250
251func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error {
252 c, err := connect(cfg)
253 if err != nil {
254 return err
255 }
256 defer c.Logout()
257
258 if _, err := c.Select("INBOX", false); err != nil {
259 return err
260 }
261
262 seqSet := new(imap.SeqSet)
263 seqSet.AddNum(uid)
264
265 return c.UidMove(seqSet, destMailbox)
266}
267
268func DeleteEmail(cfg *config.Config, uid uint32) error {
269 var trashMailbox string
270 switch cfg.ServiceProvider {
271 case "gmail":
272 trashMailbox = "[Gmail]/Trash"
273 default:
274 trashMailbox = "Trash"
275 }
276 return moveEmail(cfg, uid, trashMailbox)
277}
278
279func ArchiveEmail(cfg *config.Config, uid uint32) error {
280 var archiveMailbox string
281 switch cfg.ServiceProvider {
282 case "gmail":
283 archiveMailbox = "[Gmail]/All Mail"
284 default:
285 archiveMailbox = "Archive"
286 }
287 return moveEmail(cfg, uid, archiveMailbox)
288}