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/emersion/go-imap"
15 "github.com/emersion/go-imap/client"
16 "github.com/emersion/go-message/mail"
17 "github.com/floatpane/matcha/config"
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 checkPart func(part *imap.BodyStructure, partID string)
225 checkPart = func(part *imap.BodyStructure, partID string) {
226 // Check for text content
227 if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" {
228 textPartID = partID
229 }
230
231 // Check for attachments using multiple methods
232 filename := ""
233 // First try the Filename() method which handles various cases
234 if fn, err := part.Filename(); err == nil && fn != "" {
235 filename = fn
236 }
237 // Fallback: check DispositionParams
238 if filename == "" {
239 if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
240 filename = fn
241 }
242 }
243 // Fallback: check Params (for name parameter)
244 if filename == "" {
245 if fn, ok := part.Params["name"]; ok && fn != "" {
246 filename = fn
247 }
248 }
249 // Fallback: check Params for filename
250 if filename == "" {
251 if fn, ok := part.Params["filename"]; ok && fn != "" {
252 filename = fn
253 }
254 }
255
256 // Add as attachment if it has a disposition or a filename (and not just plain text)
257 if filename != "" && (part.Disposition == "attachment" || part.Disposition == "inline" || part.MIMEType != "text") {
258 attachments = append(attachments, Attachment{Filename: filename, PartID: partID})
259 }
260 }
261
262 var findParts func(*imap.BodyStructure, string)
263 findParts = func(bs *imap.BodyStructure, prefix string) {
264 // If this is a non-multipart message, check the body structure itself
265 if len(bs.Parts) == 0 {
266 partID := prefix
267 if partID == "" {
268 partID = "1"
269 }
270 checkPart(bs, partID)
271 return
272 }
273
274 // Iterate through parts
275 for i, part := range bs.Parts {
276 partID := fmt.Sprintf("%d", i+1)
277 if prefix != "" {
278 partID = fmt.Sprintf("%s.%d", prefix, i+1)
279 }
280
281 checkPart(part, partID)
282
283 if len(part.Parts) > 0 {
284 findParts(part, partID)
285 }
286 }
287 }
288 findParts(msg.BodyStructure, "")
289
290 var body string
291 if textPartID != "" {
292 partMessages := make(chan *imap.Message, 1)
293 partDone := make(chan error, 1)
294
295 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
296 section, err := imap.ParseBodySectionName(fetchItem)
297 if err != nil {
298 return "", nil, err
299 }
300
301 go func() {
302 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
303 }()
304
305 if err := <-partDone; err != nil {
306 return "", nil, err
307 }
308
309 partMsg := <-partMessages
310 if partMsg != nil {
311 literal := partMsg.GetBody(section)
312 if literal != nil {
313 // The new decoding logic starts here
314 buf, _ := ioutil.ReadAll(literal)
315 mr, err := mail.CreateReader(bytes.NewReader(buf))
316 if err != nil {
317 body = string(buf)
318 } else {
319 p, err := mr.NextPart()
320 if err != nil {
321 body = string(buf)
322 } else {
323 encoding := p.Header.Get("Content-Transfer-Encoding")
324 bodyBytes, _ := ioutil.ReadAll(p.Body)
325
326 switch strings.ToLower(encoding) {
327 case "base64":
328 decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
329 if err == nil {
330 body = string(decoded)
331 } else {
332 body = string(bodyBytes)
333 }
334 case "quoted-printable":
335 decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes))))
336 if err == nil {
337 body = string(decoded)
338 } else {
339 body = string(bodyBytes)
340 }
341 default:
342 body = string(bodyBytes)
343 }
344 }
345 }
346 }
347 }
348 }
349
350 return body, attachments, nil
351}
352
353func FetchAttachment(cfg *config.Config, uid uint32, partID string) ([]byte, error) {
354 c, err := connect(cfg)
355 if err != nil {
356 return nil, err
357 }
358 defer c.Logout()
359
360 if _, err := c.Select("INBOX", false); err != nil {
361 return nil, err
362 }
363
364 seqset := new(imap.SeqSet)
365 seqset.AddNum(uid)
366
367 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
368 section, err := imap.ParseBodySectionName(fetchItem)
369 if err != nil {
370 return nil, err
371 }
372
373 messages := make(chan *imap.Message, 1)
374 done := make(chan error, 1)
375 go func() {
376 done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
377 }()
378
379 if err := <-done; err != nil {
380 return nil, err
381 }
382
383 msg := <-messages
384 if msg == nil {
385 return nil, fmt.Errorf("could not fetch attachment")
386 }
387
388 literal := msg.GetBody(section)
389 if literal == nil {
390 return nil, fmt.Errorf("could not get attachment body")
391 }
392
393 return ioutil.ReadAll(literal)
394}
395
396func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error {
397 c, err := connect(cfg)
398 if err != nil {
399 return err
400 }
401 defer c.Logout()
402
403 if _, err := c.Select("INBOX", false); err != nil {
404 return err
405 }
406
407 seqSet := new(imap.SeqSet)
408 seqSet.AddNum(uid)
409
410 return c.UidMove(seqSet, destMailbox)
411}
412
413func DeleteEmail(cfg *config.Config, uid uint32) error {
414 c, err := connect(cfg)
415 if err != nil {
416 return err
417 }
418 defer c.Logout()
419
420 if _, err := c.Select("INBOX", false); err != nil {
421 return err
422 }
423
424 seqSet := new(imap.SeqSet)
425 seqSet.AddNum(uid)
426
427 item := imap.FormatFlagsOp(imap.AddFlags, true)
428 flags := []interface{}{imap.DeletedFlag}
429
430 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
431 return err
432 }
433
434 return c.Expunge(nil)
435}
436
437func ArchiveEmail(cfg *config.Config, uid uint32) error {
438 var archiveMailbox string
439 switch cfg.ServiceProvider {
440 case "gmail":
441 archiveMailbox = "[Gmail]/All Mail"
442 default:
443 archiveMailbox = "Archive"
444 }
445 return moveEmail(cfg, uid, archiveMailbox)
446}