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