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 Encoding string // Store encoding for proper decoding
28}
29
30type Email struct {
31 UID uint32
32 From string
33 To []string
34 Subject string
35 Body string
36 Date time.Time
37 MessageID string
38 References []string
39 Attachments []Attachment
40 AccountID string // ID of the account this email belongs to
41}
42
43func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
44 mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
45 if err != nil {
46 body, _ := ioutil.ReadAll(reader)
47 return string(body), nil
48 }
49
50 charset := "utf-8"
51 if params["charset"] != "" {
52 charset = strings.ToLower(params["charset"])
53 }
54
55 encoding, err := ianaindex.IANA.Encoding(charset)
56 if err != nil || encoding == nil {
57 encoding, _ = ianaindex.IANA.Encoding("utf-8")
58 }
59
60 transformReader := transform.NewReader(reader, encoding.NewDecoder())
61 decodedBody, err := ioutil.ReadAll(transformReader)
62 if err != nil {
63 return "", err
64 }
65
66 if strings.HasPrefix(mediaType, "multipart/") {
67 return "[This is a multipart message]", nil
68 }
69
70 return string(decodedBody), nil
71}
72
73func decodeHeader(header string) string {
74 dec := new(mime.WordDecoder)
75 dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
76 encoding, err := ianaindex.IANA.Encoding(charset)
77 if err != nil {
78 return nil, err
79 }
80 return transform.NewReader(input, encoding.NewDecoder()), nil
81 }
82 decoded, err := dec.DecodeHeader(header)
83 if err != nil {
84 return header
85 }
86 return decoded
87}
88
89func connect(account *config.Account) (*client.Client, error) {
90 imapServer := account.GetIMAPServer()
91 imapPort := account.GetIMAPPort()
92
93 if imapServer == "" {
94 return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
95 }
96
97 addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
98 c, err := client.DialTLS(addr, nil)
99 if err != nil {
100 return nil, err
101 }
102
103 if err := c.Login(account.Email, account.Password); err != nil {
104 return nil, err
105 }
106
107 return c, nil
108}
109
110func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
111 c, err := connect(account)
112 if err != nil {
113 return nil, err
114 }
115 defer c.Logout()
116
117 mbox, err := c.Select("INBOX", false)
118 if err != nil {
119 return nil, err
120 }
121
122 if mbox.Messages == 0 {
123 return []Email{}, nil
124 }
125
126 to := mbox.Messages - offset
127 from := uint32(1)
128 if to > limit {
129 from = to - limit + 1
130 }
131
132 if to < 1 {
133 return []Email{}, nil
134 }
135
136 seqset := new(imap.SeqSet)
137 seqset.AddRange(from, to)
138
139 messages := make(chan *imap.Message, limit)
140 done := make(chan error, 1)
141 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
142 go func() {
143 done <- c.Fetch(seqset, fetchItems, messages)
144 }()
145
146 var msgs []*imap.Message
147 for msg := range messages {
148 msgs = append(msgs, msg)
149 }
150
151 if err := <-done; err != nil {
152 return nil, err
153 }
154
155 var emails []Email
156 for _, msg := range msgs {
157 if msg == nil || msg.Envelope == nil {
158 continue
159 }
160
161 var fromAddr string
162 if len(msg.Envelope.From) > 0 {
163 fromAddr = msg.Envelope.From[0].Address()
164 }
165
166 var toAddrList []string
167 for _, addr := range msg.Envelope.To {
168 toAddrList = append(toAddrList, addr.Address())
169 }
170
171 emails = append(emails, Email{
172 UID: msg.Uid,
173 From: fromAddr,
174 To: toAddrList,
175 Subject: decodeHeader(msg.Envelope.Subject),
176 Date: msg.Envelope.Date,
177 AccountID: account.ID,
178 })
179 }
180
181 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
182 emails[i], emails[j] = emails[j], emails[i]
183 }
184
185 return emails, nil
186}
187
188func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
189 c, err := connect(account)
190 if err != nil {
191 return "", nil, err
192 }
193 defer c.Logout()
194
195 if _, err := c.Select("INBOX", false); err != nil {
196 return "", nil, err
197 }
198
199 seqset := new(imap.SeqSet)
200 seqset.AddNum(uid)
201
202 messages := make(chan *imap.Message, 1)
203 done := make(chan error, 1)
204 fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
205 go func() {
206 done <- c.UidFetch(seqset, fetchItems, messages)
207 }()
208
209 if err := <-done; err != nil {
210 return "", nil, err
211 }
212
213 msg := <-messages
214 if msg == nil || msg.BodyStructure == nil {
215 return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
216 }
217
218 var textPartID string
219 var attachments []Attachment
220 var checkPart func(part *imap.BodyStructure, partID string)
221 checkPart = func(part *imap.BodyStructure, partID string) {
222 // Check for text content
223 if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" {
224 textPartID = partID
225 }
226
227 // Check for attachments using multiple methods
228 filename := ""
229 // First try the Filename() method which handles various cases
230 if fn, err := part.Filename(); err == nil && fn != "" {
231 filename = fn
232 }
233 // Fallback: check DispositionParams
234 if filename == "" {
235 if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
236 filename = fn
237 }
238 }
239 // Fallback: check Params (for name parameter)
240 if filename == "" {
241 if fn, ok := part.Params["name"]; ok && fn != "" {
242 filename = fn
243 }
244 }
245 // Fallback: check Params for filename
246 if filename == "" {
247 if fn, ok := part.Params["filename"]; ok && fn != "" {
248 filename = fn
249 }
250 }
251
252 // Add as attachment if it has a disposition or a filename (and not just plain text)
253 if filename != "" && (part.Disposition == "attachment" || part.Disposition == "inline" || part.MIMEType != "text") {
254 attachments = append(attachments, Attachment{
255 Filename: filename,
256 PartID: partID,
257 Encoding: part.Encoding, // Store encoding for proper decoding
258 })
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(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
354 c, err := connect(account)
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 rawBytes, err := ioutil.ReadAll(literal)
394 if err != nil {
395 return nil, err
396 }
397
398 switch strings.ToLower(encoding) {
399 case "base64":
400 decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
401 decoded, err := ioutil.ReadAll(decoder)
402 if err == nil {
403 return decoded, nil
404 }
405 return rawBytes, nil
406 case "quoted-printable":
407 decoded, err := ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
408 if err == nil {
409 return decoded, nil
410 }
411 return rawBytes, nil
412 default:
413 return rawBytes, nil
414 }
415}
416
417func moveEmail(account *config.Account, uid uint32, destMailbox string) error {
418 c, err := connect(account)
419 if err != nil {
420 return err
421 }
422 defer c.Logout()
423
424 if _, err := c.Select("INBOX", false); err != nil {
425 return err
426 }
427
428 seqSet := new(imap.SeqSet)
429 seqSet.AddNum(uid)
430
431 return c.UidMove(seqSet, destMailbox)
432}
433
434func DeleteEmail(account *config.Account, uid uint32) error {
435 c, err := connect(account)
436 if err != nil {
437 return err
438 }
439 defer c.Logout()
440
441 if _, err := c.Select("INBOX", false); err != nil {
442 return err
443 }
444
445 seqSet := new(imap.SeqSet)
446 seqSet.AddNum(uid)
447
448 item := imap.FormatFlagsOp(imap.AddFlags, true)
449 flags := []interface{}{imap.DeletedFlag}
450
451 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
452 return err
453 }
454
455 return c.Expunge(nil)
456}
457
458func ArchiveEmail(account *config.Account, uid uint32) error {
459 var archiveMailbox string
460 switch account.ServiceProvider {
461 case "gmail":
462 archiveMailbox = "[Gmail]/All Mail"
463 default:
464 archiveMailbox = "Archive"
465 }
466 return moveEmail(account, uid, archiveMailbox)
467}