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