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