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