1package fetcher
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "io"
8 "io/ioutil"
9 "mime"
10 "mime/quotedprintable"
11 "os"
12 "strings"
13 "time"
14
15 "github.com/emersion/go-imap"
16 "github.com/emersion/go-imap/client"
17 "github.com/emersion/go-message/mail"
18 "github.com/floatpane/matcha/config"
19 "golang.org/x/text/encoding/ianaindex"
20 "golang.org/x/text/transform"
21)
22
23// Attachment holds data for an email attachment.
24type Attachment struct {
25 Filename string
26 PartID string // Keep PartID to fetch on demand
27 Data []byte
28 Encoding string // Store encoding for proper decoding
29 MIMEType string // Full MIME type (e.g., image/png)
30 ContentID string // Content-ID for inline assets (e.g., cid: references)
31 Inline bool // True when the part is meant to be displayed inline
32}
33
34type Email struct {
35 UID uint32
36 From string
37 To []string
38 Subject string
39 Body string
40 Date time.Time
41 MessageID string
42 References []string
43 Attachments []Attachment
44 AccountID string // ID of the account this email belongs to
45}
46
47func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
48 mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
49 if err != nil {
50 body, _ := ioutil.ReadAll(reader)
51 return string(body), nil
52 }
53
54 charset := "utf-8"
55 if params["charset"] != "" {
56 charset = strings.ToLower(params["charset"])
57 }
58
59 encoding, err := ianaindex.IANA.Encoding(charset)
60 if err != nil || encoding == nil {
61 encoding, _ = ianaindex.IANA.Encoding("utf-8")
62 }
63
64 transformReader := transform.NewReader(reader, encoding.NewDecoder())
65 decodedBody, err := ioutil.ReadAll(transformReader)
66 if err != nil {
67 return "", err
68 }
69
70 if strings.HasPrefix(mediaType, "multipart/") {
71 return "[This is a multipart message]", nil
72 }
73
74 return string(decodedBody), nil
75}
76
77func decodeHeader(header string) string {
78 dec := new(mime.WordDecoder)
79 dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
80 encoding, err := ianaindex.IANA.Encoding(charset)
81 if err != nil {
82 return nil, err
83 }
84 return transform.NewReader(input, encoding.NewDecoder()), nil
85 }
86 decoded, err := dec.DecodeHeader(header)
87 if err != nil {
88 return header
89 }
90 return decoded
91}
92
93func decodeAttachmentData(rawBytes []byte, encoding string) ([]byte, error) {
94 switch strings.ToLower(encoding) {
95 case "base64":
96 decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
97 return ioutil.ReadAll(decoder)
98 case "quoted-printable":
99 return ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
100 default:
101 return rawBytes, nil
102 }
103}
104
105func connect(account *config.Account) (*client.Client, error) {
106 imapServer := account.GetIMAPServer()
107 imapPort := account.GetIMAPPort()
108
109 if imapServer == "" {
110 return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
111 }
112
113 addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
114 c, err := client.DialTLS(addr, nil)
115 if err != nil {
116 return nil, err
117 }
118
119 if err := c.Login(account.Email, account.Password); err != nil {
120 return nil, err
121 }
122
123 return c, nil
124}
125
126func getSentMailbox(account *config.Account) string {
127 switch account.ServiceProvider {
128 case "gmail":
129 return "[Gmail]/Sent Mail"
130 case "icloud":
131 return "Sent Messages"
132 default:
133 return "Sent"
134 }
135}
136
137func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) {
138 c, err := connect(account)
139 if err != nil {
140 return nil, err
141 }
142 defer c.Logout()
143
144 mbox, err := c.Select(mailbox, false)
145 if err != nil {
146 return nil, err
147 }
148
149 if mbox.Messages == 0 {
150 return []Email{}, nil
151 }
152
153 to := mbox.Messages - offset
154 from := uint32(1)
155 if to > limit {
156 from = to - limit + 1
157 }
158
159 if to < 1 {
160 return []Email{}, nil
161 }
162
163 seqset := new(imap.SeqSet)
164 seqset.AddRange(from, to)
165
166 messages := make(chan *imap.Message, limit)
167 done := make(chan error, 1)
168 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
169 go func() {
170 done <- c.Fetch(seqset, fetchItems, messages)
171 }()
172
173 var msgs []*imap.Message
174 for msg := range messages {
175 msgs = append(msgs, msg)
176 }
177
178 if err := <-done; err != nil {
179 return nil, err
180 }
181
182 var emails []Email
183 for _, msg := range msgs {
184 if msg == nil || msg.Envelope == nil {
185 continue
186 }
187
188 var fromAddr string
189 if len(msg.Envelope.From) > 0 {
190 fromAddr = msg.Envelope.From[0].Address()
191 }
192
193 var toAddrList []string
194 // Build recipient list from To and Cc for matching and display
195 for _, addr := range msg.Envelope.To {
196 toAddrList = append(toAddrList, addr.Address())
197 }
198 for _, addr := range msg.Envelope.Cc {
199 toAddrList = append(toAddrList, addr.Address())
200 }
201
202 // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
203 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
204 if fetchEmail == "" {
205 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
206 }
207
208 // Check if any recipient matches the fetchEmail
209 matched := false
210 for _, r := range toAddrList {
211 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
212 matched = true
213 break
214 }
215 }
216
217 if !matched {
218 // Skip messages not addressed to the configured fetch email
219 continue
220 }
221
222 emails = append(emails, Email{
223 UID: msg.Uid,
224 From: fromAddr,
225 To: toAddrList,
226 Subject: decodeHeader(msg.Envelope.Subject),
227 Date: msg.Envelope.Date,
228 AccountID: account.ID,
229 })
230 }
231
232 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
233 emails[i], emails[j] = emails[j], emails[i]
234 }
235
236 return emails, nil
237}
238
239func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
240 c, err := connect(account)
241 if err != nil {
242 return "", nil, err
243 }
244 defer c.Logout()
245
246 if _, err := c.Select(mailbox, false); err != nil {
247 return "", nil, err
248 }
249
250 seqset := new(imap.SeqSet)
251 seqset.AddNum(uid)
252
253 fetchInlinePart := func(partID, encoding string) ([]byte, error) {
254 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
255 section, err := imap.ParseBodySectionName(fetchItem)
256 if err != nil {
257 return nil, err
258 }
259
260 partMessages := make(chan *imap.Message, 1)
261 partDone := make(chan error, 1)
262 go func() {
263 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
264 }()
265
266 if err := <-partDone; err != nil {
267 return nil, err
268 }
269
270 partMsg := <-partMessages
271 if partMsg == nil {
272 return nil, fmt.Errorf("could not fetch inline part %s", partID)
273 }
274
275 literal := partMsg.GetBody(section)
276 if literal == nil {
277 return nil, fmt.Errorf("could not get inline part body %s", partID)
278 }
279
280 rawBytes, err := ioutil.ReadAll(literal)
281 if err != nil {
282 return nil, err
283 }
284
285 return decodeAttachmentData(rawBytes, encoding)
286 }
287
288 messages := make(chan *imap.Message, 1)
289 done := make(chan error, 1)
290 fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
291 go func() {
292 done <- c.UidFetch(seqset, fetchItems, messages)
293 }()
294
295 if err := <-done; err != nil {
296 return "", nil, err
297 }
298
299 msg := <-messages
300 if msg == nil || msg.BodyStructure == nil {
301 return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
302 }
303
304 var plainPartID string
305 var htmlPartID string
306 var attachments []Attachment
307 var checkPart func(part *imap.BodyStructure, partID string)
308 checkPart = func(part *imap.BodyStructure, partID string) {
309 // Check for text content (prefer html over plain)
310 if part.MIMEType == "text" {
311 sub := strings.ToLower(part.MIMESubType)
312 switch sub {
313 case "html":
314 if htmlPartID == "" {
315 htmlPartID = partID
316 }
317 case "plain":
318 if plainPartID == "" {
319 plainPartID = partID
320 }
321 }
322 }
323
324 // Check for attachments using multiple methods
325 filename := ""
326 // First try the Filename() method which handles various cases
327 if fn, err := part.Filename(); err == nil && fn != "" {
328 filename = fn
329 }
330 // Fallback: check DispositionParams
331 if filename == "" {
332 if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
333 filename = fn
334 }
335 }
336 // Fallback: check Params (for name parameter)
337 if filename == "" {
338 if fn, ok := part.Params["name"]; ok && fn != "" {
339 filename = fn
340 }
341 }
342 // Fallback: check Params for filename
343 if filename == "" {
344 if fn, ok := part.Params["filename"]; ok && fn != "" {
345 filename = fn
346 }
347 }
348
349 // Add as attachment if it has a disposition or a filename (and not just plain text).
350 // Allow inline parts without filenames (common for cid images).
351 contentID := strings.Trim(part.Id, "<>")
352 mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType))
353 isCID := contentID != ""
354 isInline := part.Disposition == "inline" || isCID
355
356 if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
357 filename = "inline"
358 }
359 if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
360 att := Attachment{
361 Filename: filename,
362 PartID: partID,
363 Encoding: part.Encoding, // Store encoding for proper decoding
364 MIMEType: mimeType,
365 ContentID: contentID,
366 Inline: isInline,
367 }
368 if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
369 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
370 att.Data = data
371 }
372 }
373 attachments = append(attachments, att)
374 }
375 }
376
377 var findParts func(*imap.BodyStructure, string)
378 findParts = func(bs *imap.BodyStructure, prefix string) {
379 // If this is a non-multipart message, check the body structure itself
380 if len(bs.Parts) == 0 {
381 partID := prefix
382 if partID == "" {
383 partID = "1"
384 }
385 checkPart(bs, partID)
386 return
387 }
388
389 // Iterate through parts
390 for i, part := range bs.Parts {
391 partID := fmt.Sprintf("%d", i+1)
392 if prefix != "" {
393 partID = fmt.Sprintf("%s.%d", prefix, i+1)
394 }
395
396 checkPart(part, partID)
397
398 if len(part.Parts) > 0 {
399 findParts(part, partID)
400 }
401 }
402 }
403 findParts(msg.BodyStructure, "")
404
405 var body string
406 textPartID := ""
407 if htmlPartID != "" {
408 textPartID = htmlPartID
409 } else if plainPartID != "" {
410 textPartID = plainPartID
411 }
412 if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
413 msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
414 fmt.Print(msg)
415 if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
416 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
417 _, _ = f.WriteString(msg)
418 _ = f.Close()
419 }
420 }
421 }
422 if textPartID != "" {
423 partMessages := make(chan *imap.Message, 1)
424 partDone := make(chan error, 1)
425
426 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
427 section, err := imap.ParseBodySectionName(fetchItem)
428 if err != nil {
429 return "", nil, err
430 }
431
432 go func() {
433 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
434 }()
435
436 if err := <-partDone; err != nil {
437 return "", nil, err
438 }
439
440 partMsg := <-partMessages
441 if partMsg != nil {
442 literal := partMsg.GetBody(section)
443 if literal != nil {
444 // The new decoding logic starts here
445 buf, _ := ioutil.ReadAll(literal)
446 mr, err := mail.CreateReader(bytes.NewReader(buf))
447 if err != nil {
448 body = string(buf)
449 } else {
450 p, err := mr.NextPart()
451 if err != nil {
452 body = string(buf)
453 } else {
454 encoding := p.Header.Get("Content-Transfer-Encoding")
455 bodyBytes, _ := ioutil.ReadAll(p.Body)
456
457 switch strings.ToLower(encoding) {
458 case "base64":
459 decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
460 if err == nil {
461 body = string(decoded)
462 } else {
463 body = string(bodyBytes)
464 }
465 case "quoted-printable":
466 decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes))))
467 if err == nil {
468 body = string(decoded)
469 } else {
470 body = string(bodyBytes)
471 }
472 default:
473 body = string(bodyBytes)
474 }
475 }
476 }
477 }
478 }
479 }
480
481 return body, attachments, nil
482}
483
484func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
485 c, err := connect(account)
486 if err != nil {
487 return nil, err
488 }
489 defer c.Logout()
490
491 if _, err := c.Select(mailbox, false); err != nil {
492 return nil, err
493 }
494
495 seqset := new(imap.SeqSet)
496 seqset.AddNum(uid)
497
498 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
499 section, err := imap.ParseBodySectionName(fetchItem)
500 if err != nil {
501 return nil, err
502 }
503
504 messages := make(chan *imap.Message, 1)
505 done := make(chan error, 1)
506 go func() {
507 done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
508 }()
509
510 if err := <-done; err != nil {
511 return nil, err
512 }
513
514 msg := <-messages
515 if msg == nil {
516 return nil, fmt.Errorf("could not fetch attachment")
517 }
518
519 literal := msg.GetBody(section)
520 if literal == nil {
521 return nil, fmt.Errorf("could not get attachment body")
522 }
523
524 rawBytes, err := ioutil.ReadAll(literal)
525 if err != nil {
526 return nil, err
527 }
528
529 decoded, err := decodeAttachmentData(rawBytes, encoding)
530 if err != nil {
531 return rawBytes, nil
532 }
533 return decoded, nil
534}
535
536func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
537 c, err := connect(account)
538 if err != nil {
539 return err
540 }
541 defer c.Logout()
542
543 if _, err := c.Select(sourceMailbox, false); err != nil {
544 return err
545 }
546
547 seqSet := new(imap.SeqSet)
548 seqSet.AddNum(uid)
549
550 return c.UidMove(seqSet, destMailbox)
551}
552
553func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
554 c, err := connect(account)
555 if err != nil {
556 return err
557 }
558 defer c.Logout()
559
560 if _, err := c.Select(mailbox, false); err != nil {
561 return err
562 }
563
564 seqSet := new(imap.SeqSet)
565 seqSet.AddNum(uid)
566
567 item := imap.FormatFlagsOp(imap.AddFlags, true)
568 flags := []interface{}{imap.DeletedFlag}
569
570 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
571 return err
572 }
573
574 return c.Expunge(nil)
575}
576
577func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
578 var archiveMailbox string
579 switch account.ServiceProvider {
580 case "gmail":
581 archiveMailbox = "[Gmail]/All Mail"
582 default:
583 archiveMailbox = "Archive"
584 }
585 return moveEmail(account, uid, mailbox, archiveMailbox)
586}
587
588// Convenience wrappers defaulting to INBOX for existing call sites.
589
590func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
591 return FetchMailboxEmails(account, "INBOX", limit, offset)
592}
593
594func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
595 return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
596}
597
598func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
599 return FetchEmailBodyFromMailbox(account, "INBOX", uid)
600}
601
602func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
603 return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
604}
605
606func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
607 return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
608}
609
610func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
611 return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
612}
613
614func DeleteEmail(account *config.Account, uid uint32) error {
615 return DeleteEmailFromMailbox(account, "INBOX", uid)
616}
617
618func DeleteSentEmail(account *config.Account, uid uint32) error {
619 return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
620}
621
622func ArchiveEmail(account *config.Account, uid uint32) error {
623 return ArchiveEmailFromMailbox(account, "INBOX", uid)
624}
625
626func ArchiveSentEmail(account *config.Account, uid uint32) error {
627 return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
628}