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 Newest -> Oldest (since IMAP usually returns Oldest->Newest or arbitrary)
311 // Assuming seqset order or standard behavior, we want to ensure we append Newest emails first
312 // so that the final list is correct.
313 // Actually, let's just sort the batch by UID desc (Newest first)
314 // Simple bubble sort for small batch
315 for i := 0; i < len(batchEmails); i++ {
316 for j := i + 1; j < len(batchEmails); j++ {
317 if batchEmails[j].UID > batchEmails[i].UID {
318 batchEmails[i], batchEmails[j] = batchEmails[j], batchEmails[i]
319 }
320 }
321 }
322
323 // Append to allEmails
324 allEmails = append(allEmails, batchEmails...)
325
326 // Update cursor for next iteration
327 cursor = from - 1
328 }
329
330 // Trim if we have too many
331 if len(allEmails) > int(limit) {
332 allEmails = allEmails[:limit]
333 }
334
335 return allEmails, nil
336}
337
338func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
339 c, err := connect(account)
340 if err != nil {
341 return "", nil, err
342 }
343 defer c.Logout()
344
345 if _, err := c.Select(mailbox, false); err != nil {
346 return "", nil, err
347 }
348
349 seqset := new(imap.SeqSet)
350 seqset.AddNum(uid)
351
352 fetchWholeMessage := func() ([]byte, error) {
353 fetchItem := imap.FetchItem("BODY.PEEK[]")
354 section, _ := imap.ParseBodySectionName(fetchItem)
355 partMessages := make(chan *imap.Message, 1)
356 partDone := make(chan error, 1)
357 go func() {
358 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
359 }()
360 if err := <-partDone; err != nil {
361 return nil, err
362 }
363 partMsg := <-partMessages
364 if partMsg != nil {
365 literal := partMsg.GetBody(section)
366 if literal != nil {
367 return ioutil.ReadAll(literal)
368 }
369 }
370 return nil, fmt.Errorf("could not fetch whole message")
371 }
372
373 fetchInlinePart := func(partID, encoding string) ([]byte, error) {
374 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
375 section, err := imap.ParseBodySectionName(fetchItem)
376 if err != nil {
377 return nil, err
378 }
379
380 partMessages := make(chan *imap.Message, 1)
381 partDone := make(chan error, 1)
382 go func() {
383 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
384 }()
385
386 if err := <-partDone; err != nil {
387 return nil, err
388 }
389
390 partMsg := <-partMessages
391 if partMsg == nil {
392 return nil, fmt.Errorf("could not fetch inline part %s", partID)
393 }
394
395 literal := partMsg.GetBody(section)
396 if literal == nil {
397 return nil, fmt.Errorf("could not get inline part body %s", partID)
398 }
399
400 rawBytes, err := ioutil.ReadAll(literal)
401 if err != nil {
402 return nil, err
403 }
404
405 return decodeAttachmentData(rawBytes, encoding)
406 }
407
408 messages := make(chan *imap.Message, 1)
409 done := make(chan error, 1)
410 fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
411 go func() {
412 done <- c.UidFetch(seqset, fetchItems, messages)
413 }()
414
415 if err := <-done; err != nil {
416 return "", nil, err
417 }
418
419 msg := <-messages
420 if msg == nil || msg.BodyStructure == nil {
421 return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
422 }
423
424 var plainPartID, plainPartEncoding string
425 var htmlPartID, htmlPartEncoding string
426 var attachments []Attachment
427 var extractedBody string // Used if we intercept and decrypt a payload
428
429 var checkPart func(part *imap.BodyStructure, partID string)
430 checkPart = func(part *imap.BodyStructure, partID string) {
431 // Check for text content (prefer html over plain)
432 if part.MIMEType == "text" {
433 sub := strings.ToLower(part.MIMESubType)
434 switch sub {
435 case "html":
436 if htmlPartID == "" {
437 htmlPartID = partID
438 htmlPartEncoding = part.Encoding
439 }
440 case "plain":
441 if plainPartID == "" {
442 plainPartID = partID
443 plainPartEncoding = part.Encoding
444 }
445 }
446 }
447
448 // Check for attachments using multiple methods
449 filename := ""
450 // First try the Filename() method which handles various cases
451 if fn, err := part.Filename(); err == nil && fn != "" {
452 filename = fn
453 }
454 // Fallback: check DispositionParams
455 if filename == "" {
456 if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
457 filename = fn
458 }
459 }
460 // Fallback: check Params (for name parameter)
461 if filename == "" {
462 if fn, ok := part.Params["name"]; ok && fn != "" {
463 filename = fn
464 }
465 }
466 // Fallback: check Params for filename
467 if filename == "" {
468 if fn, ok := part.Params["filename"]; ok && fn != "" {
469 filename = fn
470 }
471 }
472
473 // Add as attachment if it has a disposition or a filename (and not just plain text).
474 // Allow inline parts without filenames (common for cid images).
475 contentID := strings.Trim(part.Id, "<>")
476 mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType))
477 isCID := contentID != ""
478 isInline := part.Disposition == "inline" || isCID
479
480 if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
481 filename = "inline"
482 }
483
484 // === S/MIME ENCRYPTION AND OPAQUE VERIFICATION ===
485 if filename == "smime.p7m" || mimeType == "application/pkcs7-mime" {
486 data, err := fetchInlinePart(partID, part.Encoding)
487 if err != nil && partID == "1" {
488 // Fallback for single-part messages where PEEK[1] fails
489 data, err = fetchInlinePart("TEXT", part.Encoding)
490 }
491
492 if err != nil {
493 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to fetch encrypted part from IMAP server: %v\n", err)
494 htmlPartID = "extracted"
495 } else {
496 p7, parseErr := pkcs7.Parse(data)
497 if parseErr != nil {
498 // Fallback: IMAP servers sometimes drop the transfer-encoding header.
499 // We manually strip newlines and attempt a base64 decode just in case.
500 cleanData := bytes.ReplaceAll(data, []byte("\n"), []byte(""))
501 cleanData = bytes.ReplaceAll(cleanData, []byte("\r"), []byte(""))
502 if decoded, b64err := base64.StdEncoding.DecodeString(string(cleanData)); b64err == nil {
503 p7, parseErr = pkcs7.Parse(decoded)
504 }
505 }
506
507 if parseErr != nil {
508 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to parse PKCS7 payload: %v\n", parseErr)
509 htmlPartID = "extracted"
510 } else {
511 var innerBytes []byte
512 isEncrypted, isOpaqueSigned, smimeTrusted := false, false, false
513 decryptionErr := ""
514
515 // 1. Try to Decrypt
516 if account.SMIMECert != "" && account.SMIMEKey != "" {
517 cData, err1 := os.ReadFile(account.SMIMECert)
518 kData, err2 := os.ReadFile(account.SMIMEKey)
519 if err1 != nil || err2 != nil {
520 decryptionErr = fmt.Sprintf("Failed to read cert/key files. Cert: %v, Key: %v", err1, err2)
521 } else {
522 cBlock, _ := pem.Decode(cData)
523 kBlock, _ := pem.Decode(kData)
524 if cBlock == nil || kBlock == nil {
525 decryptionErr = "Failed to decode PEM blocks from cert/key files."
526 } else {
527 cert, err3 := x509.ParseCertificate(cBlock.Bytes)
528 var privKey any
529 var err4 error
530 if key, err := x509.ParsePKCS8PrivateKey(kBlock.Bytes); err == nil {
531 privKey = key
532 } else if key, err := x509.ParsePKCS1PrivateKey(kBlock.Bytes); err == nil {
533 privKey = key
534 } else if key, err := x509.ParseECPrivateKey(kBlock.Bytes); err == nil {
535 privKey = key
536 } else {
537 err4 = errors.New("unsupported private key format")
538 }
539
540 if err3 != nil || err4 != nil {
541 decryptionErr = fmt.Sprintf("Failed to parse cert/key. Cert: %v, Key: %v", err3, err4)
542 } else {
543 dec, err := p7.Decrypt(cert, privKey)
544 if err == nil {
545 innerBytes = dec
546 isEncrypted = true
547 } else {
548 decryptionErr = fmt.Sprintf("PKCS7 Decrypt failed: %v", err)
549 }
550 }
551 }
552 }
553 } else {
554 // Only set error if it actually is enveloped data (encrypted)
555 // If it's just opaque signed, we shouldn't error out.
556 decryptionErr = "S/MIME Cert or Key path is missing in settings."
557 }
558
559 // 2. If not encrypted, check if it's an opaque signature
560 if !isEncrypted && len(p7.Signers) > 0 {
561 isOpaqueSigned = true
562 innerBytes = p7.Content
563 decryptionErr = "" // Clear encryption error because it wasn't encrypted to begin with
564 roots, _ := x509.SystemCertPool()
565 if roots == nil {
566 roots = x509.NewCertPool()
567 }
568 if err := p7.VerifyWithChain(roots); err == nil {
569 smimeTrusted = true
570 }
571 }
572
573 // 3. Parse Inner MIME payload
574 if len(innerBytes) > 0 {
575 mr, err := mail.CreateReader(bytes.NewReader(innerBytes))
576 if err == nil {
577 for {
578 p, err := mr.NextPart()
579 if err != nil {
580 break
581 }
582 cType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
583 disp, dParams, _ := mime.ParseMediaType(p.Header.Get("Content-Disposition"))
584 b, _ := ioutil.ReadAll(p.Body) // Auto-decodes quoted-printable/base64
585
586 if disp == "attachment" || disp == "inline" || (!strings.HasPrefix(cType, "multipart/") && cType != "text/plain" && cType != "text/html") {
587 fn := dParams["filename"]
588 if fn == "" {
589 _, cp, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
590 fn = cp["name"]
591 }
592 attachments = append(attachments, Attachment{
593 Filename: fn, Data: b, MIMEType: cType, Inline: disp == "inline",
594 })
595 } else {
596 if cType == "text/html" {
597 extractedBody = string(b)
598 htmlPartID = "extracted" // Skip IMAP fetch
599 } else if cType == "text/plain" && extractedBody == "" {
600 extractedBody = string(b)
601 plainPartID = "extracted"
602 }
603 }
604 }
605 } else {
606 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to read inner decrypted MIME: %v\n\n```\n%s\n```", err, string(innerBytes))
607 htmlPartID = "extracted"
608 }
609
610 attachments = append(attachments, Attachment{
611 Filename: "smime-status.internal",
612 IsSMIMESignature: isOpaqueSigned,
613 SMIMEVerified: smimeTrusted,
614 IsSMIMEEncrypted: isEncrypted,
615 })
616 return // Stop checking IMAP structure, we hijacked it
617 } else {
618 extractedBody = fmt.Sprintf("**S/MIME Decryption Failed:** %s\n", decryptionErr)
619 htmlPartID = "extracted"
620 }
621 }
622 }
623 }
624
625 // === S/MIME DETACHED SIGNATURE VERIFICATION ===
626 if filename == "smime.p7s" || mimeType == "application/pkcs7-signature" {
627 att := Attachment{
628 Filename: filename,
629 PartID: partID,
630 Encoding: part.Encoding,
631 MIMEType: mimeType,
632 ContentID: contentID,
633 Inline: isInline,
634 IsSMIMESignature: true,
635 }
636 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
637 att.Data = data
638 p7, err := pkcs7.Parse(data)
639 if err == nil {
640 boundary := msg.BodyStructure.Params["boundary"]
641 if boundary != "" {
642 rawEmail, err := fetchWholeMessage()
643 if err == nil {
644 fullBoundary := []byte("--" + boundary)
645 firstIdx := bytes.Index(rawEmail, fullBoundary)
646 if firstIdx != -1 {
647 startIdx := firstIdx + len(fullBoundary)
648 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' {
649 startIdx++
650 }
651 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' {
652 startIdx++
653 }
654 secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary)
655 if secondIdx != -1 {
656 endIdx := startIdx + secondIdx
657 if endIdx > 0 && rawEmail[endIdx-1] == '\n' {
658 endIdx--
659 }
660 if endIdx > 0 && rawEmail[endIdx-1] == '\r' {
661 endIdx--
662 }
663 signedData := rawEmail[startIdx:endIdx]
664 canonical := bytes.ReplaceAll(signedData, []byte("\r\n"), []byte("\n"))
665 canonical = bytes.ReplaceAll(canonical, []byte("\n"), []byte("\r\n"))
666
667 roots, _ := x509.SystemCertPool()
668 if roots == nil {
669 roots = x509.NewCertPool()
670 }
671
672 p7.Content = canonical
673 if err := p7.VerifyWithChain(roots); err == nil {
674 att.SMIMEVerified = true
675 } else {
676 p7.Content = append(canonical, '\r', '\n')
677 if err := p7.VerifyWithChain(roots); err == nil {
678 att.SMIMEVerified = true
679 } else {
680 p7.Content = bytes.TrimRight(canonical, "\r\n")
681 if err := p7.VerifyWithChain(roots); err == nil {
682 att.SMIMEVerified = true
683 }
684 }
685 }
686 }
687 }
688 }
689 }
690 }
691 }
692 attachments = append(attachments, att)
693 } else if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
694 att := Attachment{
695 Filename: filename,
696 PartID: partID,
697 Encoding: part.Encoding, // Store encoding for proper decoding
698 MIMEType: mimeType,
699 ContentID: contentID,
700 Inline: isInline,
701 }
702 if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
703 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
704 att.Data = data
705 }
706 }
707 attachments = append(attachments, att)
708 }
709 }
710
711 var findParts func(*imap.BodyStructure, string)
712 findParts = func(bs *imap.BodyStructure, prefix string) {
713 // If this is a non-multipart message, check the body structure itself
714 if len(bs.Parts) == 0 {
715 partID := prefix
716 if partID == "" {
717 partID = "1"
718 }
719 checkPart(bs, partID)
720 return
721 }
722
723 // Iterate through parts
724 for i, part := range bs.Parts {
725 partID := fmt.Sprintf("%d", i+1)
726 if prefix != "" {
727 partID = fmt.Sprintf("%s.%d", prefix, i+1)
728 }
729
730 checkPart(part, partID)
731
732 if len(part.Parts) > 0 {
733 findParts(part, partID)
734 }
735 }
736 }
737 findParts(msg.BodyStructure, "")
738
739 // If we hijacked and decrypted the body, return it immediately
740 if extractedBody != "" {
741 return extractedBody, attachments, nil
742 }
743
744 var body string
745 textPartID := ""
746 textPartEncoding := ""
747 if htmlPartID != "" {
748 textPartID = htmlPartID
749 textPartEncoding = htmlPartEncoding
750 } else if plainPartID != "" {
751 textPartID = plainPartID
752 textPartEncoding = plainPartEncoding
753 }
754 if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
755 msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
756 fmt.Print(msg)
757 if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
758 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
759 _, _ = f.WriteString(msg)
760 _ = f.Close()
761 }
762 }
763 }
764 if textPartID != "" {
765 partMessages := make(chan *imap.Message, 1)
766 partDone := make(chan error, 1)
767
768 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
769 section, err := imap.ParseBodySectionName(fetchItem)
770 if err != nil {
771 return "", nil, err
772 }
773
774 go func() {
775 partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
776 }()
777
778 if err := <-partDone; err != nil {
779 return "", nil, err
780 }
781
782 partMsg := <-partMessages
783 if partMsg != nil {
784 literal := partMsg.GetBody(section)
785 if literal != nil {
786 buf, _ := ioutil.ReadAll(literal)
787 // Use the encoding from BodyStructure to decode
788 if decoded, err := decodeAttachmentData(buf, textPartEncoding); err == nil {
789 body = string(decoded)
790 } else {
791 body = string(buf)
792 }
793 }
794 }
795 }
796
797 return body, attachments, nil
798}
799
800func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
801 c, err := connect(account)
802 if err != nil {
803 return nil, err
804 }
805 defer c.Logout()
806
807 if _, err := c.Select(mailbox, false); err != nil {
808 return nil, err
809 }
810
811 seqset := new(imap.SeqSet)
812 seqset.AddNum(uid)
813
814 fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
815 section, err := imap.ParseBodySectionName(fetchItem)
816 if err != nil {
817 return nil, err
818 }
819
820 messages := make(chan *imap.Message, 1)
821 done := make(chan error, 1)
822 go func() {
823 done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
824 }()
825
826 if err := <-done; err != nil {
827 return nil, err
828 }
829
830 msg := <-messages
831 if msg == nil {
832 return nil, fmt.Errorf("could not fetch attachment")
833 }
834
835 literal := msg.GetBody(section)
836 if literal == nil {
837 return nil, fmt.Errorf("could not get attachment body")
838 }
839
840 rawBytes, err := ioutil.ReadAll(literal)
841 if err != nil {
842 return nil, err
843 }
844
845 decoded, err := decodeAttachmentData(rawBytes, encoding)
846 if err != nil {
847 return rawBytes, nil
848 }
849 return decoded, nil
850}
851
852func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
853 c, err := connect(account)
854 if err != nil {
855 return err
856 }
857 defer c.Logout()
858
859 if _, err := c.Select(sourceMailbox, false); err != nil {
860 return err
861 }
862
863 seqSet := new(imap.SeqSet)
864 seqSet.AddNum(uid)
865
866 return c.UidMove(seqSet, destMailbox)
867}
868
869func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
870 c, err := connect(account)
871 if err != nil {
872 return err
873 }
874 defer c.Logout()
875
876 if _, err := c.Select(mailbox, false); err != nil {
877 return err
878 }
879
880 seqSet := new(imap.SeqSet)
881 seqSet.AddNum(uid)
882
883 item := imap.FormatFlagsOp(imap.AddFlags, true)
884 flags := []interface{}{imap.DeletedFlag}
885
886 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
887 return err
888 }
889
890 return c.Expunge(nil)
891}
892
893func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
894 c, err := connect(account)
895 if err != nil {
896 return err
897 }
898 defer c.Logout()
899
900 var archiveMailbox string
901 switch account.ServiceProvider {
902 case "gmail":
903 // For Gmail, find the mailbox with the \All attribute
904 archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr)
905 if err != nil {
906 // Fallback to hardcoded path if attribute lookup fails
907 archiveMailbox = "[Gmail]/All Mail"
908 }
909 default:
910 archiveMailbox = "Archive"
911 }
912
913 if _, err := c.Select(mailbox, false); err != nil {
914 return err
915 }
916
917 seqSet := new(imap.SeqSet)
918 seqSet.AddNum(uid)
919
920 return c.UidMove(seqSet, archiveMailbox)
921}
922
923// Convenience wrappers defaulting to INBOX for existing call sites.
924
925func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
926 return FetchMailboxEmails(account, "INBOX", limit, offset)
927}
928
929func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
930 return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
931}
932
933func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
934 return FetchEmailBodyFromMailbox(account, "INBOX", uid)
935}
936
937func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
938 return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
939}
940
941func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
942 return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
943}
944
945func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
946 return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
947}
948
949func DeleteEmail(account *config.Account, uid uint32) error {
950 return DeleteEmailFromMailbox(account, "INBOX", uid)
951}
952
953func DeleteSentEmail(account *config.Account, uid uint32) error {
954 return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
955}
956
957func ArchiveEmail(account *config.Account, uid uint32) error {
958 return ArchiveEmailFromMailbox(account, "INBOX", uid)
959}
960
961func ArchiveSentEmail(account *config.Account, uid uint32) error {
962 return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
963}
964
965// getTrashMailbox returns the trash mailbox name for the account
966func getTrashMailbox(account *config.Account) string {
967 switch account.ServiceProvider {
968 case "gmail":
969 return "[Gmail]/Trash"
970 case "icloud":
971 return "Deleted Messages"
972 default:
973 return "Trash"
974 }
975}
976
977// getArchiveMailbox returns the archive/all mail mailbox name for the account
978func getArchiveMailbox(account *config.Account) string {
979 switch account.ServiceProvider {
980 case "gmail":
981 return "[Gmail]/All Mail"
982 case "icloud":
983 return "Archive"
984 default:
985 return "Archive"
986 }
987}
988
989// FetchTrashEmails fetches emails from the trash folder
990func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
991 c, err := connect(account)
992 if err != nil {
993 return nil, err
994 }
995 defer c.Logout()
996
997 // Try to find trash by attribute first
998 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
999 if err != nil {
1000 // Fallback to hardcoded path
1001 trashMailbox = getTrashMailbox(account)
1002 }
1003
1004 return FetchMailboxEmails(account, trashMailbox, limit, offset)
1005}
1006
1007// FetchArchiveEmails fetches emails from the archive/all mail folder
1008// Archive contains all emails, so we match where user is sender OR recipient
1009func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1010 c, err := connect(account)
1011 if err != nil {
1012 return nil, err
1013 }
1014 defer c.Logout()
1015
1016 // Try to find archive by attribute first (Gmail uses \All)
1017 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1018 if err != nil {
1019 // Fallback to hardcoded path
1020 archiveMailbox = getArchiveMailbox(account)
1021 }
1022
1023 mbox, err := c.Select(archiveMailbox, false)
1024 if err != nil {
1025 return nil, err
1026 }
1027
1028 if mbox.Messages == 0 {
1029 return []Email{}, nil
1030 }
1031
1032 to := mbox.Messages - offset
1033 from := uint32(1)
1034 if to > limit {
1035 from = to - limit + 1
1036 }
1037
1038 if to < 1 {
1039 return []Email{}, nil
1040 }
1041
1042 seqset := new(imap.SeqSet)
1043 seqset.AddRange(from, to)
1044
1045 messages := make(chan *imap.Message, limit)
1046 done := make(chan error, 1)
1047 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
1048 go func() {
1049 done <- c.Fetch(seqset, fetchItems, messages)
1050 }()
1051
1052 var msgs []*imap.Message
1053 for msg := range messages {
1054 msgs = append(msgs, msg)
1055 }
1056
1057 if err := <-done; err != nil {
1058 return nil, err
1059 }
1060
1061 // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
1062 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
1063 if fetchEmail == "" {
1064 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
1065 }
1066
1067 var emails []Email
1068 for _, msg := range msgs {
1069 if msg == nil || msg.Envelope == nil {
1070 continue
1071 }
1072
1073 var fromAddr string
1074 if len(msg.Envelope.From) > 0 {
1075 fromAddr = msg.Envelope.From[0].Address()
1076 }
1077
1078 var toAddrList []string
1079 for _, addr := range msg.Envelope.To {
1080 toAddrList = append(toAddrList, addr.Address())
1081 }
1082 for _, addr := range msg.Envelope.Cc {
1083 toAddrList = append(toAddrList, addr.Address())
1084 }
1085
1086 // For archive/All Mail, match emails where user is sender OR recipient
1087 matched := false
1088 // Check if user is the sender
1089 if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
1090 matched = true
1091 }
1092 // Check if user is a recipient
1093 if !matched {
1094 for _, r := range toAddrList {
1095 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
1096 matched = true
1097 break
1098 }
1099 }
1100 }
1101
1102 if !matched {
1103 continue
1104 }
1105
1106 emails = append(emails, Email{
1107 UID: msg.Uid,
1108 From: fromAddr,
1109 To: toAddrList,
1110 Subject: decodeHeader(msg.Envelope.Subject),
1111 Date: msg.Envelope.Date,
1112 AccountID: account.ID,
1113 })
1114 }
1115
1116 // Reverse to get newest first
1117 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
1118 emails[i], emails[j] = emails[j], emails[i]
1119 }
1120
1121 return emails, nil
1122}
1123
1124// FetchTrashEmailBody fetches the body of an email from trash
1125func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1126 c, err := connect(account)
1127 if err != nil {
1128 return "", nil, err
1129 }
1130 defer c.Logout()
1131
1132 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1133 if err != nil {
1134 trashMailbox = getTrashMailbox(account)
1135 }
1136
1137 return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
1138}
1139
1140// FetchArchiveEmailBody fetches the body of an email from archive
1141func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1142 c, err := connect(account)
1143 if err != nil {
1144 return "", nil, err
1145 }
1146 defer c.Logout()
1147
1148 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1149 if err != nil {
1150 archiveMailbox = getArchiveMailbox(account)
1151 }
1152
1153 return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
1154}
1155
1156// FetchTrashAttachment fetches an attachment from trash
1157func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1158 c, err := connect(account)
1159 if err != nil {
1160 return nil, err
1161 }
1162 defer c.Logout()
1163
1164 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1165 if err != nil {
1166 trashMailbox = getTrashMailbox(account)
1167 }
1168
1169 return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
1170}
1171
1172// FetchArchiveAttachment fetches an attachment from archive
1173func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1174 c, err := connect(account)
1175 if err != nil {
1176 return nil, err
1177 }
1178 defer c.Logout()
1179
1180 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1181 if err != nil {
1182 archiveMailbox = getArchiveMailbox(account)
1183 }
1184
1185 return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
1186}
1187
1188// DeleteTrashEmail permanently deletes an email from trash
1189func DeleteTrashEmail(account *config.Account, uid uint32) error {
1190 c, err := connect(account)
1191 if err != nil {
1192 return err
1193 }
1194 defer c.Logout()
1195
1196 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1197 if err != nil {
1198 trashMailbox = getTrashMailbox(account)
1199 }
1200
1201 return DeleteEmailFromMailbox(account, trashMailbox, uid)
1202}
1203
1204// DeleteArchiveEmail deletes an email from archive (moves to trash)
1205func DeleteArchiveEmail(account *config.Account, uid uint32) error {
1206 c, err := connect(account)
1207 if err != nil {
1208 return err
1209 }
1210 defer c.Logout()
1211
1212 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1213 if err != nil {
1214 archiveMailbox = getArchiveMailbox(account)
1215 }
1216
1217 return DeleteEmailFromMailbox(account, archiveMailbox, uid)
1218}