1package fetcher
2
3import (
4 "bytes"
5 "crypto/tls"
6 "crypto/x509"
7 "encoding/base64"
8 "encoding/pem"
9 "errors"
10 "fmt"
11 "io"
12 "io/ioutil"
13 "mime"
14 "mime/quotedprintable"
15 "os"
16 "slices"
17 "strings"
18 "time"
19
20 "github.com/emersion/go-imap"
21 "github.com/emersion/go-imap/client"
22 "github.com/emersion/go-message/mail"
23 "github.com/floatpane/matcha/config"
24 "go.mozilla.org/pkcs7"
25 "golang.org/x/text/encoding/ianaindex"
26 "golang.org/x/text/transform"
27)
28
29// Attachment holds data for an email attachment.
30type Attachment struct {
31 Filename string
32 PartID string // Keep PartID to fetch on demand
33 Data []byte
34 Encoding string // Store encoding for proper decoding
35 MIMEType string // Full MIME type (e.g., image/png)
36 ContentID string // Content-ID for inline assets (e.g., cid: references)
37 Inline bool // True when the part is meant to be displayed inline
38 IsSMIMESignature bool // True if this attachment is an S/MIME signature
39 SMIMEVerified bool // True if the S/MIME signature was verified successfully
40 IsSMIMEEncrypted bool // True if the S/MIME content was successfully decrypted
41}
42
43type Email struct {
44 UID uint32
45 From string
46 To []string
47 Subject string
48 Body string
49 Date time.Time
50 IsRead bool
51 MessageID string
52 References []string
53 Attachments []Attachment
54 AccountID string // ID of the account this email belongs to
55}
56
57// Folder represents an IMAP mailbox/folder.
58type Folder struct {
59 Name string
60 Delimiter string
61 Attributes []string
62}
63
64// formatAddress returns "Name <email>" when a PersonalName is present,
65// otherwise just "email".
66func formatAddress(addr *imap.Address) string {
67 email := addr.Address()
68 if addr.PersonalName != "" {
69 return addr.PersonalName + " <" + email + ">"
70 }
71 return email
72}
73
74func hasSeenFlag(flags []string) bool {
75 return slices.Contains(flags, imap.SeenFlag)
76}
77
78func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
79 mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
80 if err != nil {
81 body, _ := ioutil.ReadAll(reader)
82 return string(body), nil
83 }
84
85 charset := "utf-8"
86 if params["charset"] != "" {
87 charset = strings.ToLower(params["charset"])
88 }
89
90 encoding, err := ianaindex.IANA.Encoding(charset)
91 if err != nil || encoding == nil {
92 encoding, _ = ianaindex.IANA.Encoding("utf-8")
93 }
94
95 transformReader := transform.NewReader(reader, encoding.NewDecoder())
96 decodedBody, err := ioutil.ReadAll(transformReader)
97 if err != nil {
98 return "", err
99 }
100
101 if strings.HasPrefix(mediaType, "multipart/") {
102 return "[This is a multipart message]", nil
103 }
104
105 return string(decodedBody), nil
106}
107
108func decodeHeader(header string) string {
109 dec := new(mime.WordDecoder)
110 dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
111 encoding, err := ianaindex.IANA.Encoding(charset)
112 if err != nil {
113 return nil, err
114 }
115 return transform.NewReader(input, encoding.NewDecoder()), nil
116 }
117 decoded, err := dec.DecodeHeader(header)
118 if err != nil {
119 return header
120 }
121 return decoded
122}
123
124func decodeAttachmentData(rawBytes []byte, encoding string) ([]byte, error) {
125 switch strings.ToLower(encoding) {
126 case "base64":
127 decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
128 return ioutil.ReadAll(decoder)
129 case "quoted-printable":
130 return ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
131 default:
132 return rawBytes, nil
133 }
134}
135
136func connect(account *config.Account) (*client.Client, error) {
137 imapServer := account.GetIMAPServer()
138 imapPort := account.GetIMAPPort()
139
140 if imapServer == "" {
141 return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
142 }
143
144 addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
145
146 tlsConfig := &tls.Config{
147 ServerName: imapServer,
148 InsecureSkipVerify: account.Insecure,
149 }
150
151 var c *client.Client
152 var err error
153
154 // If using standard non-implicit ports (1143 or 143), use Dial + STARTTLS
155 if imapPort == 1143 || imapPort == 143 {
156 c, err = client.Dial(addr)
157 if err != nil {
158 return nil, err
159 }
160 if err := c.StartTLS(tlsConfig); err != nil {
161 return nil, err
162 }
163 } else {
164 // Otherwise default to implicit TLS (port 993)
165 c, err = client.DialTLS(addr, tlsConfig)
166 if err != nil {
167 return nil, err
168 }
169 }
170
171 // Authenticate using OAuth2 (XOAUTH2) or plain password
172 if account.IsOAuth2() {
173 token, err := config.GetOAuth2Token(account.Email)
174 if err != nil {
175 return nil, fmt.Errorf("oauth2: %w", err)
176 }
177 if err := c.Authenticate(newXOAuth2Client(account.Email, token)); err != nil {
178 return nil, fmt.Errorf("XOAUTH2 authentication failed: %w", err)
179 }
180 } else {
181 if err := c.Login(account.Email, account.Password); err != nil {
182 return nil, err
183 }
184 }
185
186 return c, nil
187}
188
189func getSentMailbox(account *config.Account) string {
190 switch account.ServiceProvider {
191 case "gmail":
192 return "[Gmail]/Sent Mail"
193 case "icloud":
194 return "Sent Messages"
195 default:
196 return "Sent"
197 }
198}
199
200// getMailboxByAttr finds a mailbox with the given IMAP attribute (e.g., \All, \Sent, \Trash).
201func getMailboxByAttr(c *client.Client, attr string) (string, error) {
202 mailboxes := make(chan *imap.MailboxInfo, 10)
203 done := make(chan error, 1)
204 go func() {
205 done <- c.List("", "*", mailboxes)
206 }()
207
208 var foundMailbox string
209 for m := range mailboxes {
210 for _, a := range m.Attributes {
211 if a == attr {
212 foundMailbox = m.Name
213 break
214 }
215 }
216 }
217
218 if err := <-done; err != nil {
219 return "", err
220 }
221
222 if foundMailbox == "" {
223 return "", fmt.Errorf("no mailbox found with attribute %s", attr)
224 }
225
226 return foundMailbox, nil
227}
228
229func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) {
230 c, err := connect(account)
231 if err != nil {
232 return nil, err
233 }
234 defer c.Logout()
235
236 mbox, err := c.Select(mailbox, false)
237 if err != nil {
238 return nil, err
239 }
240
241 if mbox.Messages == 0 {
242 return []Email{}, nil
243 }
244
245 var allEmails []Email
246
247 // Start from the top minus offset
248 cursor := uint32(0)
249 if mbox.Messages > offset {
250 cursor = mbox.Messages - offset
251 } else {
252 return []Email{}, nil
253 }
254
255 // Determine if we should filter
256 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
257 if fetchEmail == "" {
258 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
259 }
260 isSentMailbox := mailbox == getSentMailbox(account)
261
262 // Loop until we have enough emails or run out of messages
263 for len(allEmails) < int(limit) && cursor > 0 {
264 // Determine chunk size
265 // Fetch at least 'limit' or 50 messages to reduce round trips
266 chunkSize := limit
267 if chunkSize < 50 {
268 chunkSize = 50
269 }
270
271 from := uint32(1)
272 if cursor > uint32(chunkSize) {
273 from = cursor - uint32(chunkSize) + 1
274 }
275
276 seqset := new(imap.SeqSet)
277 seqset.AddRange(from, cursor)
278
279 messages := make(chan *imap.Message, chunkSize)
280 done := make(chan error, 1)
281 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags}
282
283 go func() {
284 done <- c.Fetch(seqset, fetchItems, messages)
285 }()
286
287 var batchMsgs []*imap.Message
288 for msg := range messages {
289 batchMsgs = append(batchMsgs, msg)
290 }
291
292 if err := <-done; err != nil {
293 return nil, err
294 }
295
296 // Filter messages in this batch
297 var batchEmails []Email
298 for _, msg := range batchMsgs {
299 if msg == nil || msg.Envelope == nil {
300 continue
301 }
302
303 var fromAddr string
304 if len(msg.Envelope.From) > 0 {
305 fromAddr = formatAddress(msg.Envelope.From[0])
306 }
307
308 var toAddrList []string
309 for _, addr := range msg.Envelope.To {
310 toAddrList = append(toAddrList, addr.Address())
311 }
312 for _, addr := range msg.Envelope.Cc {
313 toAddrList = append(toAddrList, addr.Address())
314 }
315
316 matched := false
317 if isSentMailbox {
318 var senderEmail string
319 if len(msg.Envelope.From) > 0 {
320 senderEmail = msg.Envelope.From[0].Address()
321 }
322 if strings.EqualFold(strings.TrimSpace(senderEmail), fetchEmail) {
323 matched = true
324 }
325 } else {
326 for _, r := range toAddrList {
327 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
328 matched = true
329 break
330 }
331 }
332 }
333
334 if !matched {
335 continue
336 }
337
338 batchEmails = append(batchEmails, Email{
339 UID: msg.Uid,
340 From: fromAddr,
341 To: toAddrList,
342 Subject: decodeHeader(msg.Envelope.Subject),
343 Date: msg.Envelope.Date,
344 IsRead: hasSeenFlag(msg.Flags),
345 AccountID: account.ID,
346 })
347 }
348
349 // Sort batch Newest -> Oldest (since IMAP usually returns Oldest->Newest or arbitrary)
350 // Assuming seqset order or standard behavior, we want to ensure we append Newest emails first
351 // so that the final list is correct.
352 // Actually, let's just sort the batch by UID desc (Newest first)
353 // Simple bubble sort for small batch
354 for i := 0; i < len(batchEmails); i++ {
355 for j := i + 1; j < len(batchEmails); j++ {
356 if batchEmails[j].UID > batchEmails[i].UID {
357 batchEmails[i], batchEmails[j] = batchEmails[j], batchEmails[i]
358 }
359 }
360 }
361
362 // Append to allEmails
363 allEmails = append(allEmails, batchEmails...)
364
365 // Update cursor for next iteration
366 cursor = from - 1
367 }
368
369 // Trim if we have too many
370 if len(allEmails) > int(limit) {
371 allEmails = allEmails[:limit]
372 }
373
374 return allEmails, nil
375}
376
377func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
378 c, err := connect(account)
379 if err != nil {
380 return "", nil, err
381 }
382 defer c.Logout()
383
384 if _, err := c.Select(mailbox, false); err != nil {
385 return "", nil, err
386 }
387
388 seqset := new(imap.SeqSet)
389 seqset.AddNum(uid)
390
391 fetchWholeMessage := func() ([]byte, error) {
392 fetchItem := imap.FetchItem("BODY.PEEK[]")
393 section, _ := imap.ParseBodySectionName(fetchItem)
394 partMessages := make(chan *imap.Message, 1)
395 partDone := make(chan error, 1)
396 go func() {
397 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
398 }()
399 if err := <-partDone; err != nil {
400 return nil, err
401 }
402 partMsg := <-partMessages
403 if partMsg != nil {
404 literal := partMsg.GetBody(section)
405 if literal != nil {
406 return ioutil.ReadAll(literal)
407 }
408 }
409 return nil, fmt.Errorf("could not fetch whole message")
410 }
411
412 fetchInlinePart := func(partID, encoding string) ([]byte, error) {
413 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
414 section, err := imap.ParseBodySectionName(fetchItem)
415 if err != nil {
416 return nil, err
417 }
418
419 partMessages := make(chan *imap.Message, 1)
420 partDone := make(chan error, 1)
421 go func() {
422 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
423 }()
424
425 if err := <-partDone; err != nil {
426 return nil, err
427 }
428
429 partMsg := <-partMessages
430 if partMsg == nil {
431 return nil, fmt.Errorf("could not fetch inline part %s", partID)
432 }
433
434 literal := partMsg.GetBody(section)
435 if literal == nil {
436 return nil, fmt.Errorf("could not get inline part body %s", partID)
437 }
438
439 rawBytes, err := ioutil.ReadAll(literal)
440 if err != nil {
441 return nil, err
442 }
443
444 return decodeAttachmentData(rawBytes, encoding)
445 }
446
447 messages := make(chan *imap.Message, 1)
448 done := make(chan error, 1)
449 fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
450 go func() {
451 done <- c.UidFetch(seqset, fetchItems, messages)
452 }()
453
454 if err := <-done; err != nil {
455 return "", nil, err
456 }
457
458 msg := <-messages
459 if msg == nil || msg.BodyStructure == nil {
460 return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
461 }
462
463 var plainPartID, plainPartEncoding string
464 var htmlPartID, htmlPartEncoding string
465 var attachments []Attachment
466 var extractedBody string // Used if we intercept and decrypt a payload
467
468 var checkPart func(part *imap.BodyStructure, partID string)
469 checkPart = func(part *imap.BodyStructure, partID string) {
470 // Check for text content (prefer html over plain)
471 if part.MIMEType == "text" {
472 sub := strings.ToLower(part.MIMESubType)
473 switch sub {
474 case "html":
475 if htmlPartID == "" {
476 htmlPartID = partID
477 htmlPartEncoding = part.Encoding
478 }
479 case "plain":
480 if plainPartID == "" {
481 plainPartID = partID
482 plainPartEncoding = part.Encoding
483 }
484 }
485 }
486
487 // Check for attachments using multiple methods
488 filename := ""
489 // First try the Filename() method which handles various cases
490 if fn, err := part.Filename(); err == nil && fn != "" {
491 filename = fn
492 }
493 // Fallback: check DispositionParams
494 if filename == "" {
495 if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
496 filename = fn
497 }
498 }
499 // Fallback: check Params (for name parameter)
500 if filename == "" {
501 if fn, ok := part.Params["name"]; ok && fn != "" {
502 filename = fn
503 }
504 }
505 // Fallback: check Params for filename
506 if filename == "" {
507 if fn, ok := part.Params["filename"]; ok && fn != "" {
508 filename = fn
509 }
510 }
511
512 // Add as attachment if it has a disposition or a filename (and not just plain text).
513 // Allow inline parts without filenames (common for cid images).
514 contentID := strings.Trim(part.Id, "<>")
515 mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType))
516 isCID := contentID != ""
517 isInline := part.Disposition == "inline" || isCID
518
519 if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
520 filename = "inline"
521 }
522
523 // === S/MIME ENCRYPTION AND OPAQUE VERIFICATION ===
524 if filename == "smime.p7m" || mimeType == "application/pkcs7-mime" {
525 data, err := fetchInlinePart(partID, part.Encoding)
526 if err != nil && partID == "1" {
527 // Fallback for single-part messages where PEEK[1] fails
528 data, err = fetchInlinePart("TEXT", part.Encoding)
529 }
530
531 if err != nil {
532 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to fetch encrypted part from IMAP server: %v\n", err)
533 htmlPartID = "extracted"
534 } else {
535 p7, parseErr := pkcs7.Parse(data)
536 if parseErr != nil {
537 // Fallback: IMAP servers sometimes drop the transfer-encoding header.
538 // We manually strip newlines and attempt a base64 decode just in case.
539 cleanData := bytes.ReplaceAll(data, []byte("\n"), []byte(""))
540 cleanData = bytes.ReplaceAll(cleanData, []byte("\r"), []byte(""))
541 if decoded, b64err := base64.StdEncoding.DecodeString(string(cleanData)); b64err == nil {
542 p7, parseErr = pkcs7.Parse(decoded)
543 }
544 }
545
546 if parseErr != nil {
547 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to parse PKCS7 payload: %v\n", parseErr)
548 htmlPartID = "extracted"
549 } else {
550 var innerBytes []byte
551 isEncrypted, isOpaqueSigned, smimeTrusted := false, false, false
552 decryptionErr := ""
553
554 // 1. Try to Decrypt
555 if account.SMIMECert != "" && account.SMIMEKey != "" {
556 cData, err1 := os.ReadFile(account.SMIMECert)
557 kData, err2 := os.ReadFile(account.SMIMEKey)
558 if err1 != nil || err2 != nil {
559 decryptionErr = fmt.Sprintf("Failed to read cert/key files. Cert: %v, Key: %v", err1, err2)
560 } else {
561 cBlock, _ := pem.Decode(cData)
562 kBlock, _ := pem.Decode(kData)
563 if cBlock == nil || kBlock == nil {
564 decryptionErr = "Failed to decode PEM blocks from cert/key files."
565 } else {
566 cert, err3 := x509.ParseCertificate(cBlock.Bytes)
567 var privKey any
568 var err4 error
569 if key, err := x509.ParsePKCS8PrivateKey(kBlock.Bytes); err == nil {
570 privKey = key
571 } else if key, err := x509.ParsePKCS1PrivateKey(kBlock.Bytes); err == nil {
572 privKey = key
573 } else if key, err := x509.ParseECPrivateKey(kBlock.Bytes); err == nil {
574 privKey = key
575 } else {
576 err4 = errors.New("unsupported private key format")
577 }
578
579 if err3 != nil || err4 != nil {
580 decryptionErr = fmt.Sprintf("Failed to parse cert/key. Cert: %v, Key: %v", err3, err4)
581 } else {
582 dec, err := p7.Decrypt(cert, privKey)
583 if err == nil {
584 innerBytes = dec
585 isEncrypted = true
586 } else {
587 decryptionErr = fmt.Sprintf("PKCS7 Decrypt failed: %v", err)
588 }
589 }
590 }
591 }
592 } else {
593 // Only set error if it actually is enveloped data (encrypted)
594 // If it's just opaque signed, we shouldn't error out.
595 decryptionErr = "S/MIME Cert or Key path is missing in settings."
596 }
597
598 // 2. If not encrypted, check if it's an opaque signature
599 if !isEncrypted && len(p7.Signers) > 0 {
600 isOpaqueSigned = true
601 innerBytes = p7.Content
602 decryptionErr = "" // Clear encryption error because it wasn't encrypted to begin with
603 roots, _ := x509.SystemCertPool()
604 if roots == nil {
605 roots = x509.NewCertPool()
606 }
607 if err := p7.VerifyWithChain(roots); err == nil {
608 smimeTrusted = true
609 }
610 }
611
612 // 3. Parse Inner MIME payload
613 if len(innerBytes) > 0 {
614 mr, err := mail.CreateReader(bytes.NewReader(innerBytes))
615 if err == nil {
616 for {
617 p, err := mr.NextPart()
618 if err != nil {
619 break
620 }
621 cType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
622 disp, dParams, _ := mime.ParseMediaType(p.Header.Get("Content-Disposition"))
623 b, _ := ioutil.ReadAll(p.Body) // Auto-decodes quoted-printable/base64
624
625 if disp == "attachment" || disp == "inline" || (!strings.HasPrefix(cType, "multipart/") && cType != "text/plain" && cType != "text/html") {
626 fn := dParams["filename"]
627 if fn == "" {
628 _, cp, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
629 fn = cp["name"]
630 }
631 attachments = append(attachments, Attachment{
632 Filename: fn, Data: b, MIMEType: cType, Inline: disp == "inline",
633 })
634 } else {
635 if cType == "text/html" {
636 extractedBody = string(b)
637 htmlPartID = "extracted" // Skip IMAP fetch
638 } else if cType == "text/plain" && extractedBody == "" {
639 extractedBody = string(b)
640 plainPartID = "extracted"
641 }
642 }
643 }
644 } else {
645 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to read inner decrypted MIME: %v\n\n```\n%s\n```", err, string(innerBytes))
646 htmlPartID = "extracted"
647 }
648
649 attachments = append(attachments, Attachment{
650 Filename: "smime-status.internal",
651 IsSMIMESignature: isOpaqueSigned,
652 SMIMEVerified: smimeTrusted,
653 IsSMIMEEncrypted: isEncrypted,
654 })
655 return // Stop checking IMAP structure, we hijacked it
656 } else {
657 extractedBody = fmt.Sprintf("**S/MIME Decryption Failed:** %s\n", decryptionErr)
658 htmlPartID = "extracted"
659 }
660 }
661 }
662 }
663
664 // === S/MIME DETACHED SIGNATURE VERIFICATION ===
665 if filename == "smime.p7s" || mimeType == "application/pkcs7-signature" {
666 att := Attachment{
667 Filename: filename,
668 PartID: partID,
669 Encoding: part.Encoding,
670 MIMEType: mimeType,
671 ContentID: contentID,
672 Inline: isInline,
673 IsSMIMESignature: true,
674 }
675 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
676 att.Data = data
677 p7, err := pkcs7.Parse(data)
678 if err == nil {
679 boundary := msg.BodyStructure.Params["boundary"]
680 if boundary != "" {
681 rawEmail, err := fetchWholeMessage()
682 if err == nil {
683 fullBoundary := []byte("--" + boundary)
684 firstIdx := bytes.Index(rawEmail, fullBoundary)
685 if firstIdx != -1 {
686 startIdx := firstIdx + len(fullBoundary)
687 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' {
688 startIdx++
689 }
690 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' {
691 startIdx++
692 }
693 secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary)
694 if secondIdx != -1 {
695 endIdx := startIdx + secondIdx
696 if endIdx > 0 && rawEmail[endIdx-1] == '\n' {
697 endIdx--
698 }
699 if endIdx > 0 && rawEmail[endIdx-1] == '\r' {
700 endIdx--
701 }
702 signedData := rawEmail[startIdx:endIdx]
703 canonical := bytes.ReplaceAll(signedData, []byte("\r\n"), []byte("\n"))
704 canonical = bytes.ReplaceAll(canonical, []byte("\n"), []byte("\r\n"))
705
706 roots, _ := x509.SystemCertPool()
707 if roots == nil {
708 roots = x509.NewCertPool()
709 }
710
711 p7.Content = canonical
712 if err := p7.VerifyWithChain(roots); err == nil {
713 att.SMIMEVerified = true
714 } else {
715 p7.Content = append(canonical, '\r', '\n')
716 if err := p7.VerifyWithChain(roots); err == nil {
717 att.SMIMEVerified = true
718 } else {
719 p7.Content = bytes.TrimRight(canonical, "\r\n")
720 if err := p7.VerifyWithChain(roots); err == nil {
721 att.SMIMEVerified = true
722 }
723 }
724 }
725 }
726 }
727 }
728 }
729 }
730 }
731 attachments = append(attachments, att)
732 } else if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
733 att := Attachment{
734 Filename: filename,
735 PartID: partID,
736 Encoding: part.Encoding, // Store encoding for proper decoding
737 MIMEType: mimeType,
738 ContentID: contentID,
739 Inline: isInline,
740 }
741 if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
742 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
743 att.Data = data
744 }
745 }
746 attachments = append(attachments, att)
747 }
748 }
749
750 var findParts func(*imap.BodyStructure, string)
751 findParts = func(bs *imap.BodyStructure, prefix string) {
752 // If this is a non-multipart message, check the body structure itself
753 if len(bs.Parts) == 0 {
754 partID := prefix
755 if partID == "" {
756 partID = "1"
757 }
758 checkPart(bs, partID)
759 return
760 }
761
762 // Iterate through parts
763 for i, part := range bs.Parts {
764 partID := fmt.Sprintf("%d", i+1)
765 if prefix != "" {
766 partID = fmt.Sprintf("%s.%d", prefix, i+1)
767 }
768
769 checkPart(part, partID)
770
771 if len(part.Parts) > 0 {
772 findParts(part, partID)
773 }
774 }
775 }
776 findParts(msg.BodyStructure, "")
777
778 // If we hijacked and decrypted the body, return it immediately
779 if extractedBody != "" {
780 return extractedBody, attachments, nil
781 }
782
783 var body string
784 textPartID := ""
785 textPartEncoding := ""
786 if htmlPartID != "" {
787 textPartID = htmlPartID
788 textPartEncoding = htmlPartEncoding
789 } else if plainPartID != "" {
790 textPartID = plainPartID
791 textPartEncoding = plainPartEncoding
792 }
793 if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
794 msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
795 fmt.Print(msg)
796 if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
797 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
798 _, _ = f.WriteString(msg)
799 _ = f.Close()
800 }
801 }
802 }
803 if textPartID != "" {
804 partMessages := make(chan *imap.Message, 1)
805 partDone := make(chan error, 1)
806
807 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
808 section, err := imap.ParseBodySectionName(fetchItem)
809 if err != nil {
810 return "", nil, err
811 }
812
813 go func() {
814 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
815 }()
816
817 if err := <-partDone; err != nil {
818 return "", nil, err
819 }
820
821 partMsg := <-partMessages
822 if partMsg != nil {
823 literal := partMsg.GetBody(section)
824 if literal != nil {
825 buf, _ := ioutil.ReadAll(literal)
826 // Use the encoding from BodyStructure to decode
827 if decoded, err := decodeAttachmentData(buf, textPartEncoding); err == nil {
828 body = string(decoded)
829 } else {
830 body = string(buf)
831 }
832 }
833 }
834 }
835
836 return body, attachments, nil
837}
838
839func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
840 c, err := connect(account)
841 if err != nil {
842 return nil, err
843 }
844 defer c.Logout()
845
846 if _, err := c.Select(mailbox, false); err != nil {
847 return nil, err
848 }
849
850 seqset := new(imap.SeqSet)
851 seqset.AddNum(uid)
852
853 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
854 section, err := imap.ParseBodySectionName(fetchItem)
855 if err != nil {
856 return nil, err
857 }
858
859 messages := make(chan *imap.Message, 1)
860 done := make(chan error, 1)
861 go func() {
862 done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
863 }()
864
865 if err := <-done; err != nil {
866 return nil, err
867 }
868
869 msg := <-messages
870 if msg == nil {
871 return nil, fmt.Errorf("could not fetch attachment")
872 }
873
874 literal := msg.GetBody(section)
875 if literal == nil {
876 return nil, fmt.Errorf("could not get attachment body")
877 }
878
879 rawBytes, err := ioutil.ReadAll(literal)
880 if err != nil {
881 return nil, err
882 }
883
884 decoded, err := decodeAttachmentData(rawBytes, encoding)
885 if err != nil {
886 return rawBytes, nil
887 }
888 return decoded, nil
889}
890
891func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
892 c, err := connect(account)
893 if err != nil {
894 return err
895 }
896 defer c.Logout()
897
898 if _, err := c.Select(sourceMailbox, false); err != nil {
899 return err
900 }
901
902 seqSet := new(imap.SeqSet)
903 seqSet.AddNum(uid)
904
905 return c.UidMove(seqSet, destMailbox)
906}
907
908func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint32) error {
909 c, err := connect(account)
910 if err != nil {
911 return err
912 }
913 defer c.Logout()
914
915 if _, err := c.Select(mailbox, false); err != nil {
916 return err
917 }
918
919 seqSet := new(imap.SeqSet)
920 seqSet.AddNum(uid)
921
922 item := imap.FormatFlagsOp(imap.AddFlags, true)
923 flags := []interface{}{imap.SeenFlag}
924
925 return c.UidStore(seqSet, item, flags, nil)
926}
927
928func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
929 c, err := connect(account)
930 if err != nil {
931 return err
932 }
933 defer c.Logout()
934
935 if _, err := c.Select(mailbox, false); err != nil {
936 return err
937 }
938
939 seqSet := new(imap.SeqSet)
940 seqSet.AddNum(uid)
941
942 item := imap.FormatFlagsOp(imap.AddFlags, true)
943 flags := []interface{}{imap.DeletedFlag}
944
945 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
946 return err
947 }
948
949 return c.Expunge(nil)
950}
951
952func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
953 c, err := connect(account)
954 if err != nil {
955 return err
956 }
957 defer c.Logout()
958
959 var archiveMailbox string
960 switch account.ServiceProvider {
961 case "gmail":
962 // For Gmail, find the mailbox with the \All attribute
963 archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr)
964 if err != nil {
965 // Fallback to hardcoded path if attribute lookup fails
966 archiveMailbox = "[Gmail]/All Mail"
967 }
968 default:
969 archiveMailbox = "Archive"
970 }
971
972 if _, err := c.Select(mailbox, false); err != nil {
973 return err
974 }
975
976 seqSet := new(imap.SeqSet)
977 seqSet.AddNum(uid)
978
979 return c.UidMove(seqSet, archiveMailbox)
980}
981
982// Convenience wrappers defaulting to INBOX for existing call sites.
983
984func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
985 return FetchMailboxEmails(account, "INBOX", limit, offset)
986}
987
988func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
989 return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
990}
991
992func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
993 return FetchEmailBodyFromMailbox(account, "INBOX", uid)
994}
995
996func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
997 return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
998}
999
1000func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1001 return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
1002}
1003
1004func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1005 return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
1006}
1007
1008func DeleteEmail(account *config.Account, uid uint32) error {
1009 return DeleteEmailFromMailbox(account, "INBOX", uid)
1010}
1011
1012func DeleteSentEmail(account *config.Account, uid uint32) error {
1013 return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
1014}
1015
1016func ArchiveEmail(account *config.Account, uid uint32) error {
1017 return ArchiveEmailFromMailbox(account, "INBOX", uid)
1018}
1019
1020func ArchiveSentEmail(account *config.Account, uid uint32) error {
1021 return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
1022}
1023
1024// getTrashMailbox returns the trash mailbox name for the account
1025func getTrashMailbox(account *config.Account) string {
1026 switch account.ServiceProvider {
1027 case "gmail":
1028 return "[Gmail]/Trash"
1029 case "icloud":
1030 return "Deleted Messages"
1031 default:
1032 return "Trash"
1033 }
1034}
1035
1036// getArchiveMailbox returns the archive/all mail mailbox name for the account
1037func getArchiveMailbox(account *config.Account) string {
1038 switch account.ServiceProvider {
1039 case "gmail":
1040 return "[Gmail]/All Mail"
1041 case "icloud":
1042 return "Archive"
1043 default:
1044 return "Archive"
1045 }
1046}
1047
1048// FetchTrashEmails fetches emails from the trash folder
1049func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1050 c, err := connect(account)
1051 if err != nil {
1052 return nil, err
1053 }
1054 defer c.Logout()
1055
1056 // Try to find trash by attribute first
1057 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1058 if err != nil {
1059 // Fallback to hardcoded path
1060 trashMailbox = getTrashMailbox(account)
1061 }
1062
1063 return FetchMailboxEmails(account, trashMailbox, limit, offset)
1064}
1065
1066// FetchArchiveEmails fetches emails from the archive/all mail folder
1067// Archive contains all emails, so we match where user is sender OR recipient
1068func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1069 c, err := connect(account)
1070 if err != nil {
1071 return nil, err
1072 }
1073 defer c.Logout()
1074
1075 // Try to find archive by attribute first (Gmail uses \All)
1076 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1077 if err != nil {
1078 // Fallback to hardcoded path
1079 archiveMailbox = getArchiveMailbox(account)
1080 }
1081
1082 mbox, err := c.Select(archiveMailbox, false)
1083 if err != nil {
1084 return nil, err
1085 }
1086
1087 if mbox.Messages == 0 {
1088 return []Email{}, nil
1089 }
1090
1091 to := mbox.Messages - offset
1092 from := uint32(1)
1093 if to > limit {
1094 from = to - limit + 1
1095 }
1096
1097 if to < 1 {
1098 return []Email{}, nil
1099 }
1100
1101 seqset := new(imap.SeqSet)
1102 seqset.AddRange(from, to)
1103
1104 messages := make(chan *imap.Message, limit)
1105 done := make(chan error, 1)
1106 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags}
1107 go func() {
1108 done <- c.Fetch(seqset, fetchItems, messages)
1109 }()
1110
1111 var msgs []*imap.Message
1112 for msg := range messages {
1113 msgs = append(msgs, msg)
1114 }
1115
1116 if err := <-done; err != nil {
1117 return nil, err
1118 }
1119
1120 // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
1121 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
1122 if fetchEmail == "" {
1123 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
1124 }
1125
1126 var emails []Email
1127 for _, msg := range msgs {
1128 if msg == nil || msg.Envelope == nil {
1129 continue
1130 }
1131
1132 var fromAddr string
1133 if len(msg.Envelope.From) > 0 {
1134 fromAddr = formatAddress(msg.Envelope.From[0])
1135 }
1136
1137 var toAddrList []string
1138 for _, addr := range msg.Envelope.To {
1139 toAddrList = append(toAddrList, addr.Address())
1140 }
1141 for _, addr := range msg.Envelope.Cc {
1142 toAddrList = append(toAddrList, addr.Address())
1143 }
1144
1145 // For archive/All Mail, match emails where user is sender OR recipient
1146 matched := false
1147 // Check if user is the sender
1148 if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
1149 matched = true
1150 }
1151 // Check if user is a recipient
1152 if !matched {
1153 for _, r := range toAddrList {
1154 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
1155 matched = true
1156 break
1157 }
1158 }
1159 }
1160
1161 if !matched {
1162 continue
1163 }
1164
1165 emails = append(emails, Email{
1166 UID: msg.Uid,
1167 From: fromAddr,
1168 To: toAddrList,
1169 Subject: decodeHeader(msg.Envelope.Subject),
1170 Date: msg.Envelope.Date,
1171 IsRead: hasSeenFlag(msg.Flags),
1172 AccountID: account.ID,
1173 })
1174 }
1175
1176 // Reverse to get newest first
1177 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
1178 emails[i], emails[j] = emails[j], emails[i]
1179 }
1180
1181 return emails, nil
1182}
1183
1184// FetchTrashEmailBody fetches the body of an email from trash
1185func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1186 c, err := connect(account)
1187 if err != nil {
1188 return "", nil, err
1189 }
1190 defer c.Logout()
1191
1192 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1193 if err != nil {
1194 trashMailbox = getTrashMailbox(account)
1195 }
1196
1197 return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
1198}
1199
1200// FetchArchiveEmailBody fetches the body of an email from archive
1201func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1202 c, err := connect(account)
1203 if err != nil {
1204 return "", nil, err
1205 }
1206 defer c.Logout()
1207
1208 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1209 if err != nil {
1210 archiveMailbox = getArchiveMailbox(account)
1211 }
1212
1213 return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
1214}
1215
1216// FetchTrashAttachment fetches an attachment from trash
1217func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1218 c, err := connect(account)
1219 if err != nil {
1220 return nil, err
1221 }
1222 defer c.Logout()
1223
1224 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1225 if err != nil {
1226 trashMailbox = getTrashMailbox(account)
1227 }
1228
1229 return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
1230}
1231
1232// FetchArchiveAttachment fetches an attachment from archive
1233func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1234 c, err := connect(account)
1235 if err != nil {
1236 return nil, err
1237 }
1238 defer c.Logout()
1239
1240 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1241 if err != nil {
1242 archiveMailbox = getArchiveMailbox(account)
1243 }
1244
1245 return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
1246}
1247
1248// DeleteTrashEmail permanently deletes an email from trash
1249func DeleteTrashEmail(account *config.Account, uid uint32) error {
1250 c, err := connect(account)
1251 if err != nil {
1252 return err
1253 }
1254 defer c.Logout()
1255
1256 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1257 if err != nil {
1258 trashMailbox = getTrashMailbox(account)
1259 }
1260
1261 return DeleteEmailFromMailbox(account, trashMailbox, uid)
1262}
1263
1264// DeleteArchiveEmail deletes an email from archive (moves to trash)
1265func DeleteArchiveEmail(account *config.Account, uid uint32) error {
1266 c, err := connect(account)
1267 if err != nil {
1268 return err
1269 }
1270 defer c.Logout()
1271
1272 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1273 if err != nil {
1274 archiveMailbox = getArchiveMailbox(account)
1275 }
1276
1277 return DeleteEmailFromMailbox(account, archiveMailbox, uid)
1278}
1279
1280// FetchFolders lists all IMAP folders/mailboxes for an account.
1281func FetchFolders(account *config.Account) ([]Folder, error) {
1282 c, err := connect(account)
1283 if err != nil {
1284 return nil, err
1285 }
1286 defer c.Logout()
1287
1288 mailboxes := make(chan *imap.MailboxInfo, 50)
1289 done := make(chan error, 1)
1290 go func() {
1291 done <- c.List("", "*", mailboxes)
1292 }()
1293
1294 var folders []Folder
1295 for m := range mailboxes {
1296 folders = append(folders, Folder{
1297 Name: m.Name,
1298 Delimiter: m.Delimiter,
1299 Attributes: m.Attributes,
1300 })
1301 }
1302
1303 if err := <-done; err != nil {
1304 return nil, err
1305 }
1306
1307 return folders, nil
1308}
1309
1310// MoveEmailToFolder moves an email from one folder to another via IMAP.
1311func MoveEmailToFolder(account *config.Account, uid uint32, sourceFolder, destFolder string) error {
1312 return moveEmail(account, uid, sourceFolder, destFolder)
1313}
1314
1315// FetchFolderEmails fetches emails from an arbitrary folder.
1316func FetchFolderEmails(account *config.Account, folder string, limit, offset uint32) ([]Email, error) {
1317 return FetchMailboxEmails(account, folder, limit, offset)
1318}
1319
1320// FetchFolderEmailBody fetches the body of an email from an arbitrary folder.
1321func FetchFolderEmailBody(account *config.Account, folder string, uid uint32) (string, []Attachment, error) {
1322 return FetchEmailBodyFromMailbox(account, folder, uid)
1323}
1324
1325// FetchFolderAttachment fetches an attachment from an arbitrary folder.
1326func FetchFolderAttachment(account *config.Account, folder string, uid uint32, partID string, encoding string) ([]byte, error) {
1327 return FetchAttachmentFromMailbox(account, folder, uid, partID, encoding)
1328}
1329
1330// DeleteFolderEmail deletes an email from an arbitrary folder.
1331func DeleteFolderEmail(account *config.Account, folder string, uid uint32) error {
1332 return DeleteEmailFromMailbox(account, folder, uid)
1333}
1334
1335// ArchiveFolderEmail archives an email from an arbitrary folder.
1336func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) error {
1337 return ArchiveEmailFromMailbox(account, folder, uid)
1338}