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