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