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