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