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