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