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