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