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