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