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