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