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 // Determine if this is a sent mailbox
209 isSentMailbox := mailbox == getSentMailbox(account)
210
211 // Apply different filtering logic based on mailbox type
212 matched := false
213 if isSentMailbox {
214 // For sent mailbox, check if the sender matches the fetchEmail
215 if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
216 matched = true
217 }
218 } else {
219 // For inbox and other mailboxes, check if any recipient matches the fetchEmail
220 for _, r := range toAddrList {
221 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
222 matched = true
223 break
224 }
225 }
226 }
227
228 if !matched {
229 // Skip messages not matching the filter criteria
230 continue
231 }
232
233 emails = append(emails, Email{
234 UID: msg.Uid,
235 From: fromAddr,
236 To: toAddrList,
237 Subject: decodeHeader(msg.Envelope.Subject),
238 Date: msg.Envelope.Date,
239 AccountID: account.ID,
240 })
241 }
242
243 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
244 emails[i], emails[j] = emails[j], emails[i]
245 }
246
247 return emails, nil
248}
249
250func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
251 c, err := connect(account)
252 if err != nil {
253 return "", nil, err
254 }
255 defer c.Logout()
256
257 if _, err := c.Select(mailbox, false); err != nil {
258 return "", nil, err
259 }
260
261 seqset := new(imap.SeqSet)
262 seqset.AddNum(uid)
263
264 fetchInlinePart := func(partID, encoding string) ([]byte, error) {
265 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
266 section, err := imap.ParseBodySectionName(fetchItem)
267 if err != nil {
268 return nil, err
269 }
270
271 partMessages := make(chan *imap.Message, 1)
272 partDone := make(chan error, 1)
273 go func() {
274 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
275 }()
276
277 if err := <-partDone; err != nil {
278 return nil, err
279 }
280
281 partMsg := <-partMessages
282 if partMsg == nil {
283 return nil, fmt.Errorf("could not fetch inline part %s", partID)
284 }
285
286 literal := partMsg.GetBody(section)
287 if literal == nil {
288 return nil, fmt.Errorf("could not get inline part body %s", partID)
289 }
290
291 rawBytes, err := ioutil.ReadAll(literal)
292 if err != nil {
293 return nil, err
294 }
295
296 return decodeAttachmentData(rawBytes, encoding)
297 }
298
299 messages := make(chan *imap.Message, 1)
300 done := make(chan error, 1)
301 fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
302 go func() {
303 done <- c.UidFetch(seqset, fetchItems, messages)
304 }()
305
306 if err := <-done; err != nil {
307 return "", nil, err
308 }
309
310 msg := <-messages
311 if msg == nil || msg.BodyStructure == nil {
312 return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
313 }
314
315 var plainPartID string
316 var htmlPartID string
317 var attachments []Attachment
318 var checkPart func(part *imap.BodyStructure, partID string)
319 checkPart = func(part *imap.BodyStructure, partID string) {
320 // Check for text content (prefer html over plain)
321 if part.MIMEType == "text" {
322 sub := strings.ToLower(part.MIMESubType)
323 switch sub {
324 case "html":
325 if htmlPartID == "" {
326 htmlPartID = partID
327 }
328 case "plain":
329 if plainPartID == "" {
330 plainPartID = partID
331 }
332 }
333 }
334
335 // Check for attachments using multiple methods
336 filename := ""
337 // First try the Filename() method which handles various cases
338 if fn, err := part.Filename(); err == nil && fn != "" {
339 filename = fn
340 }
341 // Fallback: check DispositionParams
342 if filename == "" {
343 if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
344 filename = fn
345 }
346 }
347 // Fallback: check Params (for name parameter)
348 if filename == "" {
349 if fn, ok := part.Params["name"]; ok && fn != "" {
350 filename = fn
351 }
352 }
353 // Fallback: check Params for filename
354 if filename == "" {
355 if fn, ok := part.Params["filename"]; ok && fn != "" {
356 filename = fn
357 }
358 }
359
360 // Add as attachment if it has a disposition or a filename (and not just plain text).
361 // Allow inline parts without filenames (common for cid images).
362 contentID := strings.Trim(part.Id, "<>")
363 mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType))
364 isCID := contentID != ""
365 isInline := part.Disposition == "inline" || isCID
366
367 if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
368 filename = "inline"
369 }
370 if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
371 att := Attachment{
372 Filename: filename,
373 PartID: partID,
374 Encoding: part.Encoding, // Store encoding for proper decoding
375 MIMEType: mimeType,
376 ContentID: contentID,
377 Inline: isInline,
378 }
379 if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
380 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
381 att.Data = data
382 }
383 }
384 attachments = append(attachments, att)
385 }
386 }
387
388 var findParts func(*imap.BodyStructure, string)
389 findParts = func(bs *imap.BodyStructure, prefix string) {
390 // If this is a non-multipart message, check the body structure itself
391 if len(bs.Parts) == 0 {
392 partID := prefix
393 if partID == "" {
394 partID = "1"
395 }
396 checkPart(bs, partID)
397 return
398 }
399
400 // Iterate through parts
401 for i, part := range bs.Parts {
402 partID := fmt.Sprintf("%d", i+1)
403 if prefix != "" {
404 partID = fmt.Sprintf("%s.%d", prefix, i+1)
405 }
406
407 checkPart(part, partID)
408
409 if len(part.Parts) > 0 {
410 findParts(part, partID)
411 }
412 }
413 }
414 findParts(msg.BodyStructure, "")
415
416 var body string
417 textPartID := ""
418 if htmlPartID != "" {
419 textPartID = htmlPartID
420 } else if plainPartID != "" {
421 textPartID = plainPartID
422 }
423 if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
424 msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
425 fmt.Print(msg)
426 if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
427 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
428 _, _ = f.WriteString(msg)
429 _ = f.Close()
430 }
431 }
432 }
433 if textPartID != "" {
434 partMessages := make(chan *imap.Message, 1)
435 partDone := make(chan error, 1)
436
437 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
438 section, err := imap.ParseBodySectionName(fetchItem)
439 if err != nil {
440 return "", nil, err
441 }
442
443 go func() {
444 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
445 }()
446
447 if err := <-partDone; err != nil {
448 return "", nil, err
449 }
450
451 partMsg := <-partMessages
452 if partMsg != nil {
453 literal := partMsg.GetBody(section)
454 if literal != nil {
455 // The new decoding logic starts here
456 buf, _ := ioutil.ReadAll(literal)
457 mr, err := mail.CreateReader(bytes.NewReader(buf))
458 if err != nil {
459 body = string(buf)
460 } else {
461 p, err := mr.NextPart()
462 if err != nil {
463 body = string(buf)
464 } else {
465 encoding := p.Header.Get("Content-Transfer-Encoding")
466 bodyBytes, _ := ioutil.ReadAll(p.Body)
467
468 switch strings.ToLower(encoding) {
469 case "base64":
470 decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
471 if err == nil {
472 body = string(decoded)
473 } else {
474 body = string(bodyBytes)
475 }
476 case "quoted-printable":
477 decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes))))
478 if err == nil {
479 body = string(decoded)
480 } else {
481 body = string(bodyBytes)
482 }
483 default:
484 body = string(bodyBytes)
485 }
486 }
487 }
488 }
489 }
490 }
491
492 return body, attachments, nil
493}
494
495func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
496 c, err := connect(account)
497 if err != nil {
498 return nil, err
499 }
500 defer c.Logout()
501
502 if _, err := c.Select(mailbox, false); err != nil {
503 return nil, err
504 }
505
506 seqset := new(imap.SeqSet)
507 seqset.AddNum(uid)
508
509 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
510 section, err := imap.ParseBodySectionName(fetchItem)
511 if err != nil {
512 return nil, err
513 }
514
515 messages := make(chan *imap.Message, 1)
516 done := make(chan error, 1)
517 go func() {
518 done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
519 }()
520
521 if err := <-done; err != nil {
522 return nil, err
523 }
524
525 msg := <-messages
526 if msg == nil {
527 return nil, fmt.Errorf("could not fetch attachment")
528 }
529
530 literal := msg.GetBody(section)
531 if literal == nil {
532 return nil, fmt.Errorf("could not get attachment body")
533 }
534
535 rawBytes, err := ioutil.ReadAll(literal)
536 if err != nil {
537 return nil, err
538 }
539
540 decoded, err := decodeAttachmentData(rawBytes, encoding)
541 if err != nil {
542 return rawBytes, nil
543 }
544 return decoded, nil
545}
546
547func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
548 c, err := connect(account)
549 if err != nil {
550 return err
551 }
552 defer c.Logout()
553
554 if _, err := c.Select(sourceMailbox, false); err != nil {
555 return err
556 }
557
558 seqSet := new(imap.SeqSet)
559 seqSet.AddNum(uid)
560
561 return c.UidMove(seqSet, destMailbox)
562}
563
564func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
565 c, err := connect(account)
566 if err != nil {
567 return err
568 }
569 defer c.Logout()
570
571 if _, err := c.Select(mailbox, false); err != nil {
572 return err
573 }
574
575 seqSet := new(imap.SeqSet)
576 seqSet.AddNum(uid)
577
578 item := imap.FormatFlagsOp(imap.AddFlags, true)
579 flags := []interface{}{imap.DeletedFlag}
580
581 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
582 return err
583 }
584
585 return c.Expunge(nil)
586}
587
588func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
589 var archiveMailbox string
590 switch account.ServiceProvider {
591 case "gmail":
592 archiveMailbox = "[Gmail]/All Mail"
593 default:
594 archiveMailbox = "Archive"
595 }
596 return moveEmail(account, uid, mailbox, archiveMailbox)
597}
598
599// Convenience wrappers defaulting to INBOX for existing call sites.
600
601func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
602 return FetchMailboxEmails(account, "INBOX", limit, offset)
603}
604
605func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
606 return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
607}
608
609func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
610 return FetchEmailBodyFromMailbox(account, "INBOX", uid)
611}
612
613func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
614 return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
615}
616
617func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
618 return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
619}
620
621func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
622 return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
623}
624
625func DeleteEmail(account *config.Account, uid uint32) error {
626 return DeleteEmailFromMailbox(account, "INBOX", uid)
627}
628
629func DeleteSentEmail(account *config.Account, uid uint32) error {
630 return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
631}
632
633func ArchiveEmail(account *config.Account, uid uint32) error {
634 return ArchiveEmailFromMailbox(account, "INBOX", uid)
635}
636
637func ArchiveSentEmail(account *config.Account, uid uint32) error {
638 return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
639}