1package fetcher
2
3import (
4 "bufio"
5 "bytes"
6 "crypto/tls"
7 "crypto/x509"
8 "encoding/base64"
9 "encoding/pem"
10 "errors"
11 "fmt"
12 "io"
13 "io/ioutil"
14 "mime"
15 "mime/quotedprintable"
16 "net/textproto"
17 "os"
18 "slices"
19 "strings"
20 "sync"
21 "time"
22
23 "github.com/ProtonMail/go-crypto/openpgp"
24 "github.com/emersion/go-imap/v2"
25 "github.com/emersion/go-imap/v2/imapclient"
26 "github.com/emersion/go-message/mail"
27 "github.com/emersion/go-pgpmail"
28 "github.com/floatpane/matcha/config"
29 "go.mozilla.org/pkcs7"
30 "golang.org/x/text/encoding/ianaindex"
31 "golang.org/x/text/transform"
32)
33
34// debugIMAPFile holds a single shared file handle for IMAP debug logging,
35// opened once via debugIMAPOnce to avoid leaking a descriptor per connection.
36var (
37 debugIMAPFile *os.File
38 debugIMAPOnce sync.Once
39)
40
41func getDebugIMAPWriter() io.Writer {
42 debugIMAPOnce.Do(func() {
43 if path := os.Getenv("DEBUG_IMAP"); path != "" {
44 f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
45 if err == nil {
46 debugIMAPFile = f
47 }
48 }
49 })
50 if debugIMAPFile != nil {
51 return debugIMAPFile
52 }
53 return nil
54}
55
56// Attachment holds data for an email attachment.
57type Attachment struct {
58 Filename string
59 PartID string // Keep PartID to fetch on demand
60 Data []byte
61 Encoding string // Store encoding for proper decoding
62 MIMEType string // Full MIME type (e.g., image/png)
63 ContentID string // Content-ID for inline assets (e.g., cid: references)
64 Inline bool // True when the part is meant to be displayed inline
65 IsSMIMESignature bool // True if this attachment is an S/MIME signature
66 SMIMEVerified bool // True if the S/MIME signature was verified successfully
67 IsSMIMEEncrypted bool // True if the S/MIME content was successfully decrypted
68 IsPGPSignature bool // True if this attachment is a PGP signature
69 PGPVerified bool // True if the PGP signature was verified successfully
70 IsPGPEncrypted bool // True if the PGP content was successfully decrypted
71}
72
73type Email struct {
74 UID uint32
75 From string
76 To []string
77 ReplyTo []string
78 Subject string
79 Body string
80 Date time.Time
81 IsRead bool
82 MessageID string
83 References []string
84 Attachments []Attachment
85 AccountID string // ID of the account this email belongs to
86}
87
88// Folder represents an IMAP mailbox/folder.
89type Folder struct {
90 Name string
91 Delimiter string
92 Attributes []string
93}
94
95// formatAddress returns "Name <email>" when a Name is present,
96// otherwise just "email".
97func formatAddress(addr imap.Address) string {
98 email := addr.Addr()
99 if addr.Name != "" {
100 return addr.Name + " <" + email + ">"
101 }
102 return email
103}
104
105func hasSeenFlag(flags []imap.Flag) bool {
106 return slices.Contains(flags, imap.FlagSeen)
107}
108
109// deliveryHeadersMatch checks if any of the Delivered-To, X-Forwarded-To, or
110// X-Original-To headers contain the given email address. This catches
111// auto-forwarded emails where the envelope To/Cc don't match the local account.
112func deliveryHeadersMatch(data []byte, fetchEmail string) bool {
113 if len(data) == 0 {
114 return false
115 }
116 // Parse as MIME headers
117 reader := textproto.NewReader(bufio.NewReader(bytes.NewReader(data)))
118 headers, err := reader.ReadMIMEHeader()
119 if err != nil && len(headers) == 0 {
120 return false
121 }
122 for _, key := range []string{"Delivered-To", "X-Forwarded-To", "X-Original-To"} {
123 for _, val := range headers.Values(key) {
124 if strings.EqualFold(strings.TrimSpace(val), fetchEmail) {
125 return true
126 }
127 }
128 }
129 return false
130}
131
132func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
133 mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
134 if err != nil {
135 body, _ := ioutil.ReadAll(reader)
136 return string(body), nil
137 }
138
139 charset := "utf-8"
140 if params["charset"] != "" {
141 charset = strings.ToLower(params["charset"])
142 }
143
144 encoding, err := ianaindex.IANA.Encoding(charset)
145 if err != nil || encoding == nil {
146 encoding, _ = ianaindex.IANA.Encoding("utf-8")
147 }
148
149 transformReader := transform.NewReader(reader, encoding.NewDecoder())
150 decodedBody, err := ioutil.ReadAll(transformReader)
151 if err != nil {
152 return "", err
153 }
154
155 if strings.HasPrefix(mediaType, "multipart/") {
156 return "[This is a multipart message]", nil
157 }
158
159 return string(decodedBody), nil
160}
161
162func decodeHeader(header string) string {
163 dec := new(mime.WordDecoder)
164 dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
165 encoding, err := ianaindex.IANA.Encoding(charset)
166 if err != nil {
167 return nil, err
168 }
169 return transform.NewReader(input, encoding.NewDecoder()), nil
170 }
171 decoded, err := dec.DecodeHeader(header)
172 if err != nil {
173 return header
174 }
175 return decoded
176}
177
178func decodeAttachmentData(rawBytes []byte, encoding string) ([]byte, error) {
179 switch strings.ToLower(encoding) {
180 case "base64":
181 decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
182 return ioutil.ReadAll(decoder)
183 case "quoted-printable":
184 return ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
185 default:
186 return rawBytes, nil
187 }
188}
189
190// parsePartID converts a string part ID like "1.2.3" to []int{1, 2, 3}.
191// Special cases: "TEXT" maps to empty with PartSpecifierText (handled by caller).
192func parsePartID(partID string) []int {
193 if partID == "" || partID == "TEXT" {
194 return nil
195 }
196 var parts []int
197 for _, s := range strings.Split(partID, ".") {
198 n := 0
199 for _, c := range s {
200 if c >= '0' && c <= '9' {
201 n = n*10 + int(c-'0')
202 }
203 }
204 parts = append(parts, n)
205 }
206 return parts
207}
208
209// formatPartPath converts a Walk path like []int{1, 2, 3} to "1.2.3".
210func formatPartPath(path []int) string {
211 if len(path) == 0 {
212 return "1"
213 }
214 parts := make([]string, len(path))
215 for i, p := range path {
216 parts[i] = fmt.Sprintf("%d", p)
217 }
218 return strings.Join(parts, ".")
219}
220
221// getBodyStructureBoundary extracts the boundary parameter from a multipart body structure.
222func getBodyStructureBoundary(bs imap.BodyStructure) string {
223 if mp, ok := bs.(*imap.BodyStructureMultiPart); ok {
224 if mp.Extended != nil && mp.Extended.Params != nil {
225 return mp.Extended.Params["boundary"]
226 }
227 }
228 return ""
229}
230
231// uidsToUIDSet converts a slice of uint32 UIDs to an imap.UIDSet.
232func uidsToUIDSet(uids []uint32) imap.UIDSet {
233 var uidSet imap.UIDSet
234 for _, uid := range uids {
235 uidSet.AddNum(imap.UID(uid))
236 }
237 return uidSet
238}
239
240func connectWithHandler(account *config.Account, handler *imapclient.UnilateralDataHandler) (*imapclient.Client, error) {
241 return connectWithOptions(account, &imapclient.Options{
242 UnilateralDataHandler: handler,
243 })
244}
245
246func connect(account *config.Account) (*imapclient.Client, error) {
247 return connectWithOptions(account, nil)
248}
249
250func connectWithOptions(account *config.Account, extraOpts *imapclient.Options) (*imapclient.Client, error) {
251 imapServer := account.GetIMAPServer()
252 imapPort := account.GetIMAPPort()
253
254 if imapServer == "" {
255 return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
256 }
257
258 addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
259
260 options := &imapclient.Options{
261 TLSConfig: &tls.Config{
262 ServerName: imapServer,
263 InsecureSkipVerify: account.Insecure,
264 },
265 }
266 if extraOpts != nil {
267 options.UnilateralDataHandler = extraOpts.UnilateralDataHandler
268 options.DebugWriter = extraOpts.DebugWriter
269 }
270 if w := getDebugIMAPWriter(); w != nil {
271 options.DebugWriter = w
272 }
273
274 var c *imapclient.Client
275 var err error
276
277 // If using standard non-implicit ports (1143 or 143), use DialStartTLS
278 if imapPort == 1143 || imapPort == 143 {
279 c, err = imapclient.DialStartTLS(addr, options)
280 if err != nil {
281 return nil, err
282 }
283 } else {
284 // Otherwise default to implicit TLS (port 993)
285 c, err = imapclient.DialTLS(addr, options)
286 if err != nil {
287 return nil, err
288 }
289 }
290
291 if err := c.WaitGreeting(); err != nil {
292 c.Close()
293 return nil, err
294 }
295
296 // Authenticate using OAuth2 (XOAUTH2) or plain password
297 if account.IsOAuth2() {
298 token, err := config.GetOAuth2Token(account.Email)
299 if err != nil {
300 return nil, fmt.Errorf("oauth2: %w", err)
301 }
302 if err := c.Authenticate(newXOAuth2Client(account.Email, token)); err != nil {
303 return nil, fmt.Errorf("XOAUTH2 authentication failed: %w", err)
304 }
305 } else {
306 if err := c.Login(account.Email, account.Password).Wait(); err != nil {
307 return nil, fmt.Errorf("authentication error: %w", err)
308 }
309 }
310
311 return c, nil
312}
313
314func getSentMailbox(account *config.Account) string {
315 switch account.ServiceProvider {
316 case "gmail":
317 return "[Gmail]/Sent Mail"
318 case "outlook":
319 return "Sent Items"
320 case "icloud":
321 return "Sent Messages"
322 default:
323 return "Sent"
324 }
325}
326
327// getMailboxByAttr finds a mailbox with the given IMAP attribute (e.g., \All, \Sent, \Trash).
328func getMailboxByAttr(c *imapclient.Client, attr imap.MailboxAttr) (string, error) {
329 listCmd := c.List("", "*", nil)
330 defer listCmd.Close()
331
332 var foundMailbox string
333 for {
334 data := listCmd.Next()
335 if data == nil {
336 break
337 }
338 for _, a := range data.Attrs {
339 if a == attr {
340 foundMailbox = data.Mailbox
341 break
342 }
343 }
344 }
345
346 if err := listCmd.Close(); err != nil {
347 return "", err
348 }
349
350 if foundMailbox == "" {
351 return "", fmt.Errorf("no mailbox found with attribute %s", attr)
352 }
353
354 return foundMailbox, nil
355}
356
357func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) {
358 c, err := connect(account)
359 if err != nil {
360 return nil, err
361 }
362 defer c.Close()
363
364 selectData, err := c.Select(mailbox, nil).Wait()
365 if err != nil {
366 return nil, err
367 }
368
369 if selectData.NumMessages == 0 {
370 return []Email{}, nil
371 }
372
373 var allEmails []Email
374
375 // Start from the top minus offset
376 cursor := uint32(0)
377 if selectData.NumMessages > offset {
378 cursor = selectData.NumMessages - offset
379 } else {
380 return []Email{}, nil
381 }
382
383 // Determine if we should filter
384 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
385 if fetchEmail == "" {
386 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
387 }
388 isSentMailbox := mailbox == getSentMailbox(account)
389
390 // Delivery header section for matching auto-forwarded emails
391 deliveryHeaderSection := &imap.FetchItemBodySection{
392 Specifier: imap.PartSpecifierHeader,
393 HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To"},
394 Peek: true,
395 }
396
397 // Loop until we have enough emails or run out of messages
398 for len(allEmails) < int(limit) && cursor > 0 {
399 // Determine chunk size
400 chunkSize := limit
401 if chunkSize < 50 {
402 chunkSize = 50
403 }
404
405 from := uint32(1)
406 if cursor > uint32(chunkSize) {
407 from = cursor - uint32(chunkSize) + 1
408 }
409
410 var seqset imap.SeqSet
411 seqset.AddRange(from, cursor)
412
413 fetchCmd := c.Fetch(seqset, &imap.FetchOptions{
414 Envelope: true,
415 UID: true,
416 Flags: true,
417 BodySection: []*imap.FetchItemBodySection{deliveryHeaderSection},
418 })
419
420 batchMsgs, err := fetchCmd.Collect()
421 if err != nil {
422 return nil, err
423 }
424
425 // Filter messages in this batch
426 var batchEmails []Email
427 for _, msg := range batchMsgs {
428 if msg.Envelope == nil {
429 continue
430 }
431
432 var fromAddr string
433 if len(msg.Envelope.From) > 0 {
434 fromAddr = formatAddress(msg.Envelope.From[0])
435 }
436
437 var toAddrList []string
438 for _, addr := range msg.Envelope.To {
439 toAddrList = append(toAddrList, addr.Addr())
440 }
441 for _, addr := range msg.Envelope.Cc {
442 toAddrList = append(toAddrList, addr.Addr())
443 }
444
445 var replyToAddrList []string
446 for _, addr := range msg.Envelope.ReplyTo {
447 replyToAddrList = append(replyToAddrList, addr.Addr())
448 }
449
450 matched := false
451 if isSentMailbox {
452 var senderEmail string
453 if len(msg.Envelope.From) > 0 {
454 senderEmail = msg.Envelope.From[0].Addr()
455 }
456 if strings.EqualFold(strings.TrimSpace(senderEmail), fetchEmail) {
457 matched = true
458 }
459 } else {
460 for _, r := range toAddrList {
461 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
462 matched = true
463 break
464 }
465 }
466 // Check delivery headers for auto-forwarded emails
467 if !matched {
468 headerData := msg.FindBodySection(deliveryHeaderSection)
469 matched = deliveryHeadersMatch(headerData, fetchEmail)
470 }
471 }
472
473 if !matched {
474 continue
475 }
476
477 batchEmails = append(batchEmails, Email{
478 UID: uint32(msg.UID),
479 From: fromAddr,
480 To: toAddrList,
481 ReplyTo: replyToAddrList,
482 Subject: decodeHeader(msg.Envelope.Subject),
483 Date: msg.Envelope.Date,
484 IsRead: hasSeenFlag(msg.Flags),
485 AccountID: account.ID,
486 })
487 }
488
489 // Sort batch Newest -> Oldest by UID desc
490 for i := 0; i < len(batchEmails); i++ {
491 for j := i + 1; j < len(batchEmails); j++ {
492 if batchEmails[j].UID > batchEmails[i].UID {
493 batchEmails[i], batchEmails[j] = batchEmails[j], batchEmails[i]
494 }
495 }
496 }
497
498 allEmails = append(allEmails, batchEmails...)
499 cursor = from - 1
500 }
501
502 // Trim if we have too many
503 if len(allEmails) > int(limit) {
504 allEmails = allEmails[:limit]
505 }
506
507 return allEmails, nil
508}
509
510func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
511 c, err := connect(account)
512 if err != nil {
513 return "", nil, err
514 }
515 defer c.Close()
516
517 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
518 return "", nil, err
519 }
520
521 uidSet := imap.UIDSetNum(imap.UID(uid))
522
523 fetchWholeMessage := func() ([]byte, error) {
524 wholeSection := &imap.FetchItemBodySection{Peek: true}
525 fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
526 BodySection: []*imap.FetchItemBodySection{wholeSection},
527 })
528 msgs, err := fetchCmd.Collect()
529 if err != nil {
530 return nil, err
531 }
532 if len(msgs) > 0 {
533 if data := msgs[0].FindBodySection(wholeSection); data != nil {
534 return data, nil
535 }
536 }
537 return nil, fmt.Errorf("could not fetch whole message")
538 }
539
540 fetchInlinePart := func(partID, encoding string) ([]byte, error) {
541 part := parsePartID(partID)
542 section := &imap.FetchItemBodySection{
543 Part: part,
544 Peek: true,
545 }
546
547 fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
548 BodySection: []*imap.FetchItemBodySection{section},
549 })
550 msgs, err := fetchCmd.Collect()
551 if err != nil {
552 return nil, err
553 }
554
555 if len(msgs) == 0 {
556 return nil, fmt.Errorf("could not fetch inline part %s", partID)
557 }
558
559 rawBytes := msgs[0].FindBodySection(section)
560 if rawBytes == nil {
561 return nil, fmt.Errorf("could not get inline part body %s", partID)
562 }
563
564 return decodeAttachmentData(rawBytes, encoding)
565 }
566
567 fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
568 BodyStructure: &imap.FetchItemBodyStructure{Extended: true},
569 })
570 bsMsgs, err := fetchCmd.Collect()
571 if err != nil {
572 return "", nil, err
573 }
574
575 if len(bsMsgs) == 0 || bsMsgs[0].BodyStructure == nil {
576 return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
577 }
578
579 msg := bsMsgs[0]
580
581 var plainPartID, plainPartEncoding string
582 var htmlPartID, htmlPartEncoding string
583 var attachments []Attachment
584 var extractedBody string // Used if we intercept and decrypt a payload
585
586 var checkPart func(part *imap.BodyStructureSinglePart, partID string)
587 checkPart = func(part *imap.BodyStructureSinglePart, partID string) {
588 // Check for text content (prefer html over plain)
589 if strings.EqualFold(part.Type, "text") {
590 sub := strings.ToLower(part.Subtype)
591 switch sub {
592 case "html":
593 if htmlPartID == "" {
594 htmlPartID = partID
595 htmlPartEncoding = part.Encoding
596 }
597 case "plain":
598 if plainPartID == "" {
599 plainPartID = partID
600 plainPartEncoding = part.Encoding
601 }
602 }
603 }
604
605 // Check for attachments using multiple methods
606 filename := part.Filename()
607 // Fallback: check Params (for name parameter)
608 if filename == "" {
609 if fn, ok := part.Params["name"]; ok && fn != "" {
610 filename = fn
611 }
612 }
613 // Fallback: check Params for filename
614 if filename == "" {
615 if fn, ok := part.Params["filename"]; ok && fn != "" {
616 filename = fn
617 }
618 }
619
620 // Add as attachment if it has a disposition or a filename (and not just plain text).
621 // Allow inline parts without filenames (common for cid images).
622 contentID := strings.Trim(part.ID, "<>")
623 mimeType := part.MediaType()
624 dispValue := ""
625 dispParams := map[string]string{}
626 if part.Disposition() != nil {
627 dispValue = part.Disposition().Value
628 dispParams = part.Disposition().Params
629 }
630 _ = dispParams // used below in attachment fallback checks
631 isCID := contentID != ""
632 isInline := strings.EqualFold(dispValue, "inline") || isCID
633
634 if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
635 filename = "inline"
636 }
637
638 // === S/MIME ENCRYPTION AND OPAQUE VERIFICATION ===
639 if filename == "smime.p7m" || mimeType == "application/pkcs7-mime" {
640 data, err := fetchInlinePart(partID, part.Encoding)
641 if err != nil && partID == "1" {
642 // Fallback for single-part messages where PEEK[1] fails
643 data, err = fetchInlinePart("TEXT", part.Encoding)
644 }
645
646 if err != nil {
647 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to fetch encrypted part from IMAP server: %v\n", err)
648 htmlPartID = "extracted"
649 } else {
650 p7, parseErr := pkcs7.Parse(data)
651 if parseErr != nil {
652 // Fallback: IMAP servers sometimes drop the transfer-encoding header.
653 // We manually strip newlines and attempt a base64 decode just in case.
654 cleanData := bytes.ReplaceAll(data, []byte("\n"), []byte(""))
655 cleanData = bytes.ReplaceAll(cleanData, []byte("\r"), []byte(""))
656 if decoded, b64err := base64.StdEncoding.DecodeString(string(cleanData)); b64err == nil {
657 p7, parseErr = pkcs7.Parse(decoded)
658 }
659 }
660
661 if parseErr != nil {
662 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to parse PKCS7 payload: %v\n", parseErr)
663 htmlPartID = "extracted"
664 } else {
665 var innerBytes []byte
666 isEncrypted, isOpaqueSigned, smimeTrusted := false, false, false
667 decryptionErr := ""
668
669 // 1. Try to Decrypt
670 if account.SMIMECert != "" && account.SMIMEKey != "" {
671 cData, err1 := os.ReadFile(account.SMIMECert)
672 kData, err2 := os.ReadFile(account.SMIMEKey)
673 if err1 != nil || err2 != nil {
674 decryptionErr = fmt.Sprintf("Failed to read cert/key files. Cert: %v, Key: %v", err1, err2)
675 } else {
676 cBlock, _ := pem.Decode(cData)
677 kBlock, _ := pem.Decode(kData)
678 if cBlock == nil || kBlock == nil {
679 decryptionErr = "Failed to decode PEM blocks from cert/key files."
680 } else {
681 cert, err3 := x509.ParseCertificate(cBlock.Bytes)
682 var privKey any
683 var err4 error
684 if key, err := x509.ParsePKCS8PrivateKey(kBlock.Bytes); err == nil {
685 privKey = key
686 } else if key, err := x509.ParsePKCS1PrivateKey(kBlock.Bytes); err == nil {
687 privKey = key
688 } else if key, err := x509.ParseECPrivateKey(kBlock.Bytes); err == nil {
689 privKey = key
690 } else {
691 err4 = errors.New("unsupported private key format")
692 }
693
694 if err3 != nil || err4 != nil {
695 decryptionErr = fmt.Sprintf("Failed to parse cert/key. Cert: %v, Key: %v", err3, err4)
696 } else {
697 dec, err := p7.Decrypt(cert, privKey)
698 if err == nil {
699 innerBytes = dec
700 isEncrypted = true
701 } else {
702 decryptionErr = fmt.Sprintf("PKCS7 Decrypt failed: %v", err)
703 }
704 }
705 }
706 }
707 } else {
708 // Only set error if it actually is enveloped data (encrypted)
709 // If it's just opaque signed, we shouldn't error out.
710 decryptionErr = "S/MIME Cert or Key path is missing in settings."
711 }
712
713 // 2. If not encrypted, check if it's an opaque signature
714 if !isEncrypted && len(p7.Signers) > 0 {
715 isOpaqueSigned = true
716 innerBytes = p7.Content
717 decryptionErr = "" // Clear encryption error because it wasn't encrypted to begin with
718 roots, _ := x509.SystemCertPool()
719 if roots == nil {
720 roots = x509.NewCertPool()
721 }
722 if err := p7.VerifyWithChain(roots); err == nil {
723 smimeTrusted = true
724 }
725 }
726
727 // 3. Parse Inner MIME payload
728 if len(innerBytes) > 0 {
729 mr, err := mail.CreateReader(bytes.NewReader(innerBytes))
730 if err == nil {
731 for {
732 p, err := mr.NextPart()
733 if err != nil {
734 break
735 }
736 cType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
737 disp, dParams, _ := mime.ParseMediaType(p.Header.Get("Content-Disposition"))
738 b, _ := ioutil.ReadAll(p.Body) // Auto-decodes quoted-printable/base64
739
740 if disp == "attachment" || disp == "inline" || (!strings.HasPrefix(cType, "multipart/") && cType != "text/plain" && cType != "text/html") {
741 fn := dParams["filename"]
742 if fn == "" {
743 _, cp, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
744 fn = cp["name"]
745 }
746 attachments = append(attachments, Attachment{
747 Filename: fn, Data: b, MIMEType: cType, Inline: disp == "inline",
748 })
749 } else {
750 if cType == "text/html" {
751 extractedBody = string(b)
752 htmlPartID = "extracted" // Skip IMAP fetch
753 } else if cType == "text/plain" && extractedBody == "" {
754 extractedBody = string(b)
755 plainPartID = "extracted"
756 }
757 }
758 }
759 } else {
760 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to read inner decrypted MIME: %v\n\n```\n%s\n```", err, string(innerBytes))
761 htmlPartID = "extracted"
762 }
763
764 attachments = append(attachments, Attachment{
765 Filename: "smime-status.internal",
766 IsSMIMESignature: isOpaqueSigned,
767 SMIMEVerified: smimeTrusted,
768 IsSMIMEEncrypted: isEncrypted,
769 })
770 return // Stop checking IMAP structure, we hijacked it
771 } else {
772 extractedBody = fmt.Sprintf("**S/MIME Decryption Failed:** %s\n", decryptionErr)
773 htmlPartID = "extracted"
774 }
775 }
776 }
777 }
778
779 // === S/MIME DETACHED SIGNATURE VERIFICATION ===
780 if filename == "smime.p7s" || mimeType == "application/pkcs7-signature" {
781 att := Attachment{
782 Filename: filename,
783 PartID: partID,
784 Encoding: part.Encoding,
785 MIMEType: mimeType,
786 ContentID: contentID,
787 Inline: isInline,
788 IsSMIMESignature: true,
789 }
790 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
791 att.Data = data
792 p7, err := pkcs7.Parse(data)
793 if err == nil {
794 boundary := getBodyStructureBoundary(msg.BodyStructure)
795 if boundary != "" {
796 rawEmail, err := fetchWholeMessage()
797 if err == nil {
798 fullBoundary := []byte("--" + boundary)
799 firstIdx := bytes.Index(rawEmail, fullBoundary)
800 if firstIdx != -1 {
801 startIdx := firstIdx + len(fullBoundary)
802 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' {
803 startIdx++
804 }
805 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' {
806 startIdx++
807 }
808 secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary)
809 if secondIdx != -1 {
810 endIdx := startIdx + secondIdx
811 if endIdx > 0 && rawEmail[endIdx-1] == '\n' {
812 endIdx--
813 }
814 if endIdx > 0 && rawEmail[endIdx-1] == '\r' {
815 endIdx--
816 }
817 signedData := rawEmail[startIdx:endIdx]
818 canonical := bytes.ReplaceAll(signedData, []byte("\r\n"), []byte("\n"))
819 canonical = bytes.ReplaceAll(canonical, []byte("\n"), []byte("\r\n"))
820
821 roots, _ := x509.SystemCertPool()
822 if roots == nil {
823 roots = x509.NewCertPool()
824 }
825
826 p7.Content = canonical
827 if err := p7.VerifyWithChain(roots); err == nil {
828 att.SMIMEVerified = true
829 } else {
830 p7.Content = append(canonical, '\r', '\n')
831 if err := p7.VerifyWithChain(roots); err == nil {
832 att.SMIMEVerified = true
833 } else {
834 p7.Content = bytes.TrimRight(canonical, "\r\n")
835 if err := p7.VerifyWithChain(roots); err == nil {
836 att.SMIMEVerified = true
837 }
838 }
839 }
840 }
841 }
842 }
843 }
844 }
845 }
846 attachments = append(attachments, att)
847 }
848
849 // === PGP ENCRYPTED MESSAGE DETECTION ===
850 if mimeType == "application/pgp-encrypted" || (mimeType == "multipart/encrypted" && strings.Contains(part.Subtype, "pgp")) {
851 // PGP encrypted messages typically have two parts:
852 // 1. Version info (application/pgp-encrypted)
853 // 2. Encrypted data (application/octet-stream)
854 // We'll handle decryption when we find the encrypted data part
855 // Skip this part and continue processing
856 }
857
858 // Detect encrypted data part of PGP message
859 if strings.Contains(filename, ".asc") || (mimeType == "application/octet-stream" && part.Encoding == "7bit") {
860 // This might be PGP encrypted data
861 data, err := fetchInlinePart(partID, part.Encoding)
862 if err == nil && bytes.Contains(data, []byte("-----BEGIN PGP MESSAGE-----")) {
863 // This is PGP encrypted content
864 if account.PGPPrivateKey != "" {
865 decrypted, err := decryptPGPMessage(data, account)
866 if err == nil {
867 // Parse the decrypted MIME content
868 mr, err := mail.CreateReader(bytes.NewReader(decrypted))
869 if err == nil {
870 for {
871 p, err := mr.NextPart()
872 if err == io.EOF {
873 break
874 }
875 if err != nil {
876 break
877 }
878
879 switch h := p.Header.(type) {
880 case *mail.InlineHeader:
881 ct, _, _ := h.ContentType()
882 if strings.HasPrefix(ct, "text/html") {
883 body, _ := io.ReadAll(p.Body)
884 extractedBody = string(body)
885 htmlPartID = "decrypted"
886 } else if strings.HasPrefix(ct, "text/plain") && extractedBody == "" {
887 body, _ := io.ReadAll(p.Body)
888 extractedBody = string(body)
889 htmlPartID = "decrypted"
890 }
891 }
892 }
893
894 // Add status marker
895 attachments = append(attachments, Attachment{
896 Filename: "pgp-status.internal",
897 IsPGPEncrypted: true,
898 PGPVerified: true, // Decryption succeeded
899 })
900 }
901 } else {
902 extractedBody = fmt.Sprintf("**PGP Decryption Failed:** %s\n", err)
903 htmlPartID = "extracted"
904 }
905 } else {
906 extractedBody = "**PGP Encrypted:** Private key not configured\n"
907 htmlPartID = "extracted"
908 }
909 }
910 }
911
912 // === PGP DETACHED SIGNATURE VERIFICATION ===
913 if filename == "signature.asc" || mimeType == "application/pgp-signature" {
914 att := Attachment{
915 Filename: filename,
916 PartID: partID,
917 Encoding: part.Encoding,
918 MIMEType: mimeType,
919 ContentID: contentID,
920 Inline: isInline,
921 IsPGPSignature: true,
922 }
923
924 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
925 att.Data = data
926
927 // Try to verify the signature
928 boundary := getBodyStructureBoundary(msg.BodyStructure)
929 if boundary != "" {
930 rawEmail, err := fetchWholeMessage()
931 if err == nil {
932 // Extract signed content (similar to S/MIME)
933 fullBoundary := []byte("--" + boundary)
934 firstIdx := bytes.Index(rawEmail, fullBoundary)
935 if firstIdx != -1 {
936 startIdx := firstIdx + len(fullBoundary)
937 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' {
938 startIdx++
939 }
940 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' {
941 startIdx++
942 }
943 secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary)
944 if secondIdx != -1 {
945 endIdx := startIdx + secondIdx
946 if endIdx > 0 && rawEmail[endIdx-1] == '\n' {
947 endIdx--
948 }
949 if endIdx > 0 && rawEmail[endIdx-1] == '\r' {
950 endIdx--
951 }
952 signedData := rawEmail[startIdx:endIdx]
953
954 // Verify PGP signature
955 verified := verifyPGPSignature(signedData, data, account)
956 att.PGPVerified = verified
957 }
958 }
959 }
960 }
961 }
962 attachments = append(attachments, att)
963 } else if (filename != "" || isCID) && (strings.EqualFold(dispValue, "attachment") || isInline || !strings.EqualFold(part.Type, "text")) {
964 att := Attachment{
965 Filename: filename,
966 PartID: partID,
967 Encoding: part.Encoding, // Store encoding for proper decoding
968 MIMEType: mimeType,
969 ContentID: contentID,
970 Inline: isInline,
971 }
972 if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
973 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
974 att.Data = data
975 }
976 }
977 attachments = append(attachments, att)
978 }
979 }
980
981 // Walk the body structure tree
982 msg.BodyStructure.Walk(func(path []int, part imap.BodyStructure) bool {
983 if sp, ok := part.(*imap.BodyStructureSinglePart); ok {
984 partID := formatPartPath(path)
985 checkPart(sp, partID)
986 }
987 return true
988 })
989
990 // If we hijacked and decrypted the body, return it immediately
991 if extractedBody != "" {
992 return extractedBody, attachments, nil
993 }
994
995 var body string
996 textPartID := ""
997 textPartEncoding := ""
998 if htmlPartID != "" {
999 textPartID = htmlPartID
1000 textPartEncoding = htmlPartEncoding
1001 } else if plainPartID != "" {
1002 textPartID = plainPartID
1003 textPartEncoding = plainPartEncoding
1004 }
1005 if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
1006 msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
1007 fmt.Print(msg)
1008 if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
1009 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
1010 _, _ = f.WriteString(msg)
1011 _ = f.Close()
1012 }
1013 }
1014 }
1015 if textPartID != "" {
1016 part := parsePartID(textPartID)
1017 section := &imap.FetchItemBodySection{
1018 Part: part,
1019 Peek: true,
1020 }
1021
1022 fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
1023 BodySection: []*imap.FetchItemBodySection{section},
1024 })
1025 msgs, err := fetchCmd.Collect()
1026 if err != nil {
1027 return "", nil, err
1028 }
1029
1030 if len(msgs) > 0 {
1031 if buf := msgs[0].FindBodySection(section); buf != nil {
1032 // Use the encoding from BodyStructure to decode
1033 if decoded, err := decodeAttachmentData(buf, textPartEncoding); err == nil {
1034 body = string(decoded)
1035 } else {
1036 body = string(buf)
1037 }
1038 }
1039 }
1040 }
1041
1042 return body, attachments, nil
1043}
1044
1045func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
1046 c, err := connect(account)
1047 if err != nil {
1048 return nil, err
1049 }
1050 defer c.Close()
1051
1052 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1053 return nil, err
1054 }
1055
1056 uidSet := imap.UIDSetNum(imap.UID(uid))
1057 part := parsePartID(partID)
1058 section := &imap.FetchItemBodySection{
1059 Part: part,
1060 Peek: true,
1061 }
1062
1063 fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
1064 BodySection: []*imap.FetchItemBodySection{section},
1065 })
1066 msgs, err := fetchCmd.Collect()
1067 if err != nil {
1068 return nil, err
1069 }
1070
1071 if len(msgs) == 0 {
1072 return nil, fmt.Errorf("could not fetch attachment")
1073 }
1074
1075 rawBytes := msgs[0].FindBodySection(section)
1076 if rawBytes == nil {
1077 return nil, fmt.Errorf("could not get attachment body")
1078 }
1079
1080 decoded, err := decodeAttachmentData(rawBytes, encoding)
1081 if err != nil {
1082 return rawBytes, nil
1083 }
1084 return decoded, nil
1085}
1086
1087func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
1088 c, err := connect(account)
1089 if err != nil {
1090 return err
1091 }
1092 defer c.Close()
1093
1094 if _, err := c.Select(sourceMailbox, nil).Wait(); err != nil {
1095 return err
1096 }
1097
1098 uidSet := imap.UIDSetNum(imap.UID(uid))
1099 _, err = c.Move(uidSet, destMailbox).Wait()
1100 return err
1101}
1102
1103func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint32) error {
1104 c, err := connect(account)
1105 if err != nil {
1106 return err
1107 }
1108 defer c.Close()
1109
1110 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1111 return err
1112 }
1113
1114 uidSet := imap.UIDSetNum(imap.UID(uid))
1115 return c.Store(uidSet, &imap.StoreFlags{
1116 Op: imap.StoreFlagsAdd,
1117 Silent: true,
1118 Flags: []imap.Flag{imap.FlagSeen},
1119 }, nil).Close()
1120}
1121
1122func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
1123 c, err := connect(account)
1124 if err != nil {
1125 return err
1126 }
1127 defer c.Close()
1128
1129 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1130 return err
1131 }
1132
1133 uidSet := imap.UIDSetNum(imap.UID(uid))
1134 if err := c.Store(uidSet, &imap.StoreFlags{
1135 Op: imap.StoreFlagsAdd,
1136 Silent: true,
1137 Flags: []imap.Flag{imap.FlagDeleted},
1138 }, nil).Close(); err != nil {
1139 return err
1140 }
1141
1142 return c.Expunge().Close()
1143}
1144
1145func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
1146 c, err := connect(account)
1147 if err != nil {
1148 return err
1149 }
1150 defer c.Close()
1151
1152 var archiveMailbox string
1153 switch account.ServiceProvider {
1154 case "gmail":
1155 // For Gmail, find the mailbox with the \All attribute
1156 archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll)
1157 if err != nil {
1158 // Fallback to hardcoded path if attribute lookup fails
1159 archiveMailbox = "[Gmail]/All Mail"
1160 }
1161 default:
1162 archiveMailbox = "Archive"
1163 }
1164
1165 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1166 return err
1167 }
1168
1169 uidSet := imap.UIDSetNum(imap.UID(uid))
1170 _, err = c.Move(uidSet, archiveMailbox).Wait()
1171 return err
1172}
1173
1174// Batch operations for multiple emails
1175
1176// DeleteEmailsFromMailbox deletes multiple emails from a mailbox (batch operation)
1177func DeleteEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
1178 if len(uids) == 0 {
1179 return nil
1180 }
1181
1182 c, err := connect(account)
1183 if err != nil {
1184 return err
1185 }
1186 defer c.Close()
1187
1188 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1189 return err
1190 }
1191
1192 uidSet := uidsToUIDSet(uids)
1193 if err := c.Store(uidSet, &imap.StoreFlags{
1194 Op: imap.StoreFlagsAdd,
1195 Silent: true,
1196 Flags: []imap.Flag{imap.FlagDeleted},
1197 }, nil).Close(); err != nil {
1198 return err
1199 }
1200
1201 return c.Expunge().Close()
1202}
1203
1204// ArchiveEmailsFromMailbox archives multiple emails from a mailbox (batch operation)
1205func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
1206 if len(uids) == 0 {
1207 return nil
1208 }
1209
1210 c, err := connect(account)
1211 if err != nil {
1212 return err
1213 }
1214 defer c.Close()
1215
1216 var archiveMailbox string
1217 switch account.ServiceProvider {
1218 case "gmail":
1219 archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll)
1220 if err != nil {
1221 archiveMailbox = "[Gmail]/All Mail"
1222 }
1223 default:
1224 archiveMailbox = "Archive"
1225 }
1226
1227 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1228 return err
1229 }
1230
1231 uidSet := uidsToUIDSet(uids)
1232 _, err = c.Move(uidSet, archiveMailbox).Wait()
1233 return err
1234}
1235
1236// MoveEmailsToFolder moves multiple emails to a different folder (batch operation)
1237func MoveEmailsToFolder(account *config.Account, uids []uint32, sourceFolder, destFolder string) error {
1238 if len(uids) == 0 {
1239 return nil
1240 }
1241
1242 c, err := connect(account)
1243 if err != nil {
1244 return err
1245 }
1246 defer c.Close()
1247
1248 if _, err := c.Select(sourceFolder, nil).Wait(); err != nil {
1249 return err
1250 }
1251
1252 uidSet := uidsToUIDSet(uids)
1253 _, err = c.Move(uidSet, destFolder).Wait()
1254 return err
1255}
1256
1257// Convenience wrappers defaulting to INBOX for existing call sites.
1258
1259func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1260 return FetchMailboxEmails(account, "INBOX", limit, offset)
1261}
1262
1263func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1264 return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
1265}
1266
1267func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1268 return FetchEmailBodyFromMailbox(account, "INBOX", uid)
1269}
1270
1271func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1272 return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
1273}
1274
1275func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1276 return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
1277}
1278
1279func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1280 return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
1281}
1282
1283func DeleteEmail(account *config.Account, uid uint32) error {
1284 return DeleteEmailFromMailbox(account, "INBOX", uid)
1285}
1286
1287func DeleteSentEmail(account *config.Account, uid uint32) error {
1288 return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
1289}
1290
1291func ArchiveEmail(account *config.Account, uid uint32) error {
1292 return ArchiveEmailFromMailbox(account, "INBOX", uid)
1293}
1294
1295func ArchiveSentEmail(account *config.Account, uid uint32) error {
1296 return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
1297}
1298
1299// AppendToSentMailbox appends a raw RFC822 message to the Sent mailbox via IMAP APPEND.
1300func AppendToSentMailbox(account *config.Account, rawMsg []byte) error {
1301 c, err := connect(account)
1302 if err != nil {
1303 return err
1304 }
1305 defer c.Close()
1306
1307 sentMailbox := getSentMailbox(account)
1308 appendCmd := c.Append(sentMailbox, int64(len(rawMsg)), &imap.AppendOptions{
1309 Flags: []imap.Flag{imap.FlagSeen},
1310 Time: time.Now(),
1311 })
1312 if _, err := appendCmd.Write(rawMsg); err != nil {
1313 return err
1314 }
1315 if err := appendCmd.Close(); err != nil {
1316 return err
1317 }
1318 _, err = appendCmd.Wait()
1319 return err
1320}
1321
1322// getTrashMailbox returns the trash mailbox name for the account
1323func getTrashMailbox(account *config.Account) string {
1324 switch account.ServiceProvider {
1325 case "gmail":
1326 return "[Gmail]/Trash"
1327 case "outlook":
1328 return "Deleted Items"
1329 case "icloud":
1330 return "Deleted Messages"
1331 default:
1332 return "Trash"
1333 }
1334}
1335
1336// getArchiveMailbox returns the archive/all mail mailbox name for the account
1337func getArchiveMailbox(account *config.Account) string {
1338 switch account.ServiceProvider {
1339 case "gmail":
1340 return "[Gmail]/All Mail"
1341 case "outlook", "icloud":
1342 return "Archive"
1343 default:
1344 return "Archive"
1345 }
1346}
1347
1348// FetchTrashEmails fetches emails from the trash folder
1349func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1350 c, err := connect(account)
1351 if err != nil {
1352 return nil, err
1353 }
1354 defer c.Close()
1355
1356 // Try to find trash by attribute first
1357 trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1358 if err != nil {
1359 // Fallback to hardcoded path
1360 trashMailbox = getTrashMailbox(account)
1361 }
1362
1363 return FetchMailboxEmails(account, trashMailbox, limit, offset)
1364}
1365
1366// FetchArchiveEmails fetches emails from the archive/all mail folder
1367// Archive contains all emails, so we match where user is sender OR recipient
1368func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1369 c, err := connect(account)
1370 if err != nil {
1371 return nil, err
1372 }
1373 defer c.Close()
1374
1375 // Try to find archive by attribute first (Gmail uses \All)
1376 archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1377 if err != nil {
1378 // Fallback to hardcoded path
1379 archiveMailbox = getArchiveMailbox(account)
1380 }
1381
1382 selectData, err := c.Select(archiveMailbox, nil).Wait()
1383 if err != nil {
1384 return nil, err
1385 }
1386
1387 if selectData.NumMessages == 0 {
1388 return []Email{}, nil
1389 }
1390
1391 to := selectData.NumMessages - offset
1392 from := uint32(1)
1393 if to > limit {
1394 from = to - limit + 1
1395 }
1396
1397 if to < 1 {
1398 return []Email{}, nil
1399 }
1400
1401 var seqset imap.SeqSet
1402 seqset.AddRange(from, to)
1403
1404 // Delivery header section for matching auto-forwarded emails
1405 deliveryHeaderSection := &imap.FetchItemBodySection{
1406 Specifier: imap.PartSpecifierHeader,
1407 HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To"},
1408 Peek: true,
1409 }
1410
1411 fetchCmd := c.Fetch(seqset, &imap.FetchOptions{
1412 Envelope: true,
1413 UID: true,
1414 Flags: true,
1415 BodySection: []*imap.FetchItemBodySection{deliveryHeaderSection},
1416 })
1417 msgs, err := fetchCmd.Collect()
1418 if err != nil {
1419 return nil, err
1420 }
1421
1422 // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
1423 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
1424 if fetchEmail == "" {
1425 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
1426 }
1427
1428 var emails []Email
1429 for _, msg := range msgs {
1430 if msg.Envelope == nil {
1431 continue
1432 }
1433
1434 var fromAddr string
1435 if len(msg.Envelope.From) > 0 {
1436 fromAddr = formatAddress(msg.Envelope.From[0])
1437 }
1438
1439 var toAddrList []string
1440 for _, addr := range msg.Envelope.To {
1441 toAddrList = append(toAddrList, addr.Addr())
1442 }
1443 for _, addr := range msg.Envelope.Cc {
1444 toAddrList = append(toAddrList, addr.Addr())
1445 }
1446
1447 // For archive/All Mail, match emails where user is sender OR recipient
1448 matched := false
1449 // Check if user is the sender
1450 if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
1451 matched = true
1452 }
1453 // Check if user is a recipient
1454 if !matched {
1455 for _, r := range toAddrList {
1456 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
1457 matched = true
1458 break
1459 }
1460 }
1461 }
1462 // Check delivery headers for auto-forwarded emails
1463 if !matched {
1464 headerData := msg.FindBodySection(deliveryHeaderSection)
1465 matched = deliveryHeadersMatch(headerData, fetchEmail)
1466 }
1467
1468 if !matched {
1469 continue
1470 }
1471
1472 emails = append(emails, Email{
1473 UID: uint32(msg.UID),
1474 From: fromAddr,
1475 To: toAddrList,
1476 Subject: decodeHeader(msg.Envelope.Subject),
1477 Date: msg.Envelope.Date,
1478 IsRead: hasSeenFlag(msg.Flags),
1479 AccountID: account.ID,
1480 })
1481 }
1482
1483 // Reverse to get newest first
1484 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
1485 emails[i], emails[j] = emails[j], emails[i]
1486 }
1487
1488 return emails, nil
1489}
1490
1491// FetchTrashEmailBody fetches the body of an email from trash
1492func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1493 c, err := connect(account)
1494 if err != nil {
1495 return "", nil, err
1496 }
1497 defer c.Close()
1498
1499 trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1500 if err != nil {
1501 trashMailbox = getTrashMailbox(account)
1502 }
1503
1504 return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
1505}
1506
1507// FetchArchiveEmailBody fetches the body of an email from archive
1508func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1509 c, err := connect(account)
1510 if err != nil {
1511 return "", nil, err
1512 }
1513 defer c.Close()
1514
1515 archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1516 if err != nil {
1517 archiveMailbox = getArchiveMailbox(account)
1518 }
1519
1520 return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
1521}
1522
1523// FetchTrashAttachment fetches an attachment from trash
1524func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1525 c, err := connect(account)
1526 if err != nil {
1527 return nil, err
1528 }
1529 defer c.Close()
1530
1531 trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1532 if err != nil {
1533 trashMailbox = getTrashMailbox(account)
1534 }
1535
1536 return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
1537}
1538
1539// FetchArchiveAttachment fetches an attachment from archive
1540func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1541 c, err := connect(account)
1542 if err != nil {
1543 return nil, err
1544 }
1545 defer c.Close()
1546
1547 archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1548 if err != nil {
1549 archiveMailbox = getArchiveMailbox(account)
1550 }
1551
1552 return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
1553}
1554
1555// DeleteTrashEmail permanently deletes an email from trash
1556func DeleteTrashEmail(account *config.Account, uid uint32) error {
1557 c, err := connect(account)
1558 if err != nil {
1559 return err
1560 }
1561 defer c.Close()
1562
1563 trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1564 if err != nil {
1565 trashMailbox = getTrashMailbox(account)
1566 }
1567
1568 return DeleteEmailFromMailbox(account, trashMailbox, uid)
1569}
1570
1571// DeleteArchiveEmail deletes an email from archive (moves to trash)
1572func DeleteArchiveEmail(account *config.Account, uid uint32) error {
1573 c, err := connect(account)
1574 if err != nil {
1575 return err
1576 }
1577 defer c.Close()
1578
1579 archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1580 if err != nil {
1581 archiveMailbox = getArchiveMailbox(account)
1582 }
1583
1584 return DeleteEmailFromMailbox(account, archiveMailbox, uid)
1585}
1586
1587// FetchFolders lists all IMAP folders/mailboxes for an account.
1588func FetchFolders(account *config.Account) ([]Folder, error) {
1589 c, err := connect(account)
1590 if err != nil {
1591 return nil, err
1592 }
1593 defer c.Close()
1594
1595 listCmd := c.List("", "*", nil)
1596 defer listCmd.Close()
1597
1598 var folders []Folder
1599 for {
1600 data := listCmd.Next()
1601 if data == nil {
1602 break
1603 }
1604 delim := ""
1605 if data.Delim != 0 {
1606 delim = string(data.Delim)
1607 }
1608 var attrs []string
1609 for _, a := range data.Attrs {
1610 attrs = append(attrs, string(a))
1611 }
1612 folders = append(folders, Folder{
1613 Name: data.Mailbox,
1614 Delimiter: delim,
1615 Attributes: attrs,
1616 })
1617 }
1618
1619 if err := listCmd.Close(); err != nil {
1620 return nil, err
1621 }
1622
1623 return folders, nil
1624}
1625
1626// MoveEmailToFolder moves an email from one folder to another via IMAP.
1627func MoveEmailToFolder(account *config.Account, uid uint32, sourceFolder, destFolder string) error {
1628 return moveEmail(account, uid, sourceFolder, destFolder)
1629}
1630
1631// FetchFolderEmails fetches emails from an arbitrary folder.
1632func FetchFolderEmails(account *config.Account, folder string, limit, offset uint32) ([]Email, error) {
1633 return FetchMailboxEmails(account, folder, limit, offset)
1634}
1635
1636// FetchFolderEmailBody fetches the body of an email from an arbitrary folder.
1637func FetchFolderEmailBody(account *config.Account, folder string, uid uint32) (string, []Attachment, error) {
1638 return FetchEmailBodyFromMailbox(account, folder, uid)
1639}
1640
1641// FetchFolderAttachment fetches an attachment from an arbitrary folder.
1642func FetchFolderAttachment(account *config.Account, folder string, uid uint32, partID string, encoding string) ([]byte, error) {
1643 return FetchAttachmentFromMailbox(account, folder, uid, partID, encoding)
1644}
1645
1646// DeleteFolderEmail deletes an email from an arbitrary folder.
1647func DeleteFolderEmail(account *config.Account, folder string, uid uint32) error {
1648 return DeleteEmailFromMailbox(account, folder, uid)
1649}
1650
1651// ArchiveFolderEmail archives an email from an arbitrary folder.
1652func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) error {
1653 return ArchiveEmailFromMailbox(account, folder, uid)
1654}
1655
1656// decryptPGPMessage decrypts a PGP-encrypted message using the account's private key.
1657func decryptPGPMessage(encryptedData []byte, account *config.Account) ([]byte, error) {
1658 if account.PGPPrivateKey == "" {
1659 return nil, errors.New("PGP private key not configured")
1660 }
1661
1662 // Load private key
1663 keyFile, err := os.ReadFile(account.PGPPrivateKey)
1664 if err != nil {
1665 return nil, fmt.Errorf("failed to read PGP private key: %w", err)
1666 }
1667
1668 // Try armored format first
1669 entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
1670 if err != nil {
1671 // Try binary format
1672 entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
1673 if err != nil {
1674 return nil, fmt.Errorf("failed to parse PGP private key: %w", err)
1675 }
1676 }
1677
1678 if len(entityList) == 0 {
1679 return nil, errors.New("no PGP keys found in private keyring")
1680 }
1681
1682 // Decrypt using go-pgpmail
1683 mr, err := pgpmail.Read(bytes.NewReader(encryptedData), openpgp.EntityList{entityList[0]}, nil, nil)
1684 if err != nil {
1685 return nil, fmt.Errorf("failed to decrypt PGP message: %w", err)
1686 }
1687
1688 // Read decrypted content from UnverifiedBody
1689 if mr.MessageDetails == nil || mr.MessageDetails.UnverifiedBody == nil {
1690 return nil, errors.New("no decrypted content available")
1691 }
1692
1693 var decrypted bytes.Buffer
1694 if _, err := io.Copy(&decrypted, mr.MessageDetails.UnverifiedBody); err != nil {
1695 return nil, fmt.Errorf("failed to read decrypted content: %w", err)
1696 }
1697
1698 return decrypted.Bytes(), nil
1699}
1700
1701// loadPGPKeyring builds an openpgp.EntityList from the account's public key
1702// and any keys stored in the pgp/ config directory.
1703func loadPGPKeyring(account *config.Account) openpgp.EntityList {
1704 var keyring openpgp.EntityList
1705
1706 readKeys := func(path string) {
1707 data, err := os.ReadFile(path)
1708 if err != nil {
1709 return
1710 }
1711 entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
1712 if err != nil {
1713 entities, err = openpgp.ReadKeyRing(bytes.NewReader(data))
1714 if err != nil {
1715 return
1716 }
1717 }
1718 keyring = append(keyring, entities...)
1719 }
1720
1721 // Load account's own public key
1722 if account.PGPPublicKey != "" {
1723 readKeys(account.PGPPublicKey)
1724 }
1725
1726 // Load all keys from the pgp/ config directory
1727 cfgDir, err := config.GetConfigDir()
1728 if err == nil {
1729 pgpDir := cfgDir + "/pgp"
1730 entries, err := os.ReadDir(pgpDir)
1731 if err == nil {
1732 for _, entry := range entries {
1733 if entry.IsDir() {
1734 continue
1735 }
1736 name := entry.Name()
1737 if strings.HasSuffix(name, ".asc") || strings.HasSuffix(name, ".gpg") {
1738 readKeys(pgpDir + "/" + name)
1739 }
1740 }
1741 }
1742 }
1743
1744 return keyring
1745}
1746
1747// verifyPGPSignature verifies a PGP detached signature against signed content.
1748func verifyPGPSignature(signedContent, signatureData []byte, account *config.Account) bool {
1749 keyring := loadPGPKeyring(account)
1750 if len(keyring) == 0 {
1751 return false
1752 }
1753
1754 // Build a complete multipart/signed message for go-pgpmail
1755 boundary := "pgp-verify-boundary"
1756 var msg bytes.Buffer
1757 msg.WriteString("Content-Type: multipart/signed; boundary=\"" + boundary + "\"; micalg=pgp-sha256; protocol=\"application/pgp-signature\"\r\n\r\n")
1758 msg.WriteString("--" + boundary + "\r\n")
1759 msg.Write(signedContent)
1760 msg.WriteString("\r\n--" + boundary + "\r\n")
1761 msg.WriteString("Content-Type: application/pgp-signature\r\n\r\n")
1762 msg.Write(signatureData)
1763 msg.WriteString("\r\n--" + boundary + "--\r\n")
1764
1765 mr, err := pgpmail.Read(&msg, keyring, nil, nil)
1766 if err != nil {
1767 return false
1768 }
1769
1770 if mr.MessageDetails == nil {
1771 return false
1772 }
1773
1774 // Must read UnverifiedBody to EOF to trigger signature verification
1775 _, _ = io.ReadAll(mr.MessageDetails.UnverifiedBody)
1776
1777 return mr.MessageDetails.SignatureError == nil
1778}