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