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