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// Batch operations for multiple emails
1144
1145// DeleteEmailsFromMailbox deletes multiple emails from a mailbox (batch operation)
1146func DeleteEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
1147 if len(uids) == 0 {
1148 return nil
1149 }
1150
1151 c, err := connect(account)
1152 if err != nil {
1153 return err
1154 }
1155 defer c.Logout()
1156
1157 if _, err := c.Select(mailbox, false); err != nil {
1158 return err
1159 }
1160
1161 seqSet := new(imap.SeqSet)
1162 for _, uid := range uids {
1163 seqSet.AddNum(uid)
1164 }
1165
1166 item := imap.FormatFlagsOp(imap.AddFlags, true)
1167 flags := []interface{}{imap.DeletedFlag}
1168
1169 if err := c.UidStore(seqSet, item, flags, nil); err != nil {
1170 return err
1171 }
1172
1173 return c.Expunge(nil)
1174}
1175
1176// ArchiveEmailsFromMailbox archives multiple emails from a mailbox (batch operation)
1177func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
1178 if len(uids) == 0 {
1179 return nil
1180 }
1181
1182 c, err := connect(account)
1183 if err != nil {
1184 return err
1185 }
1186 defer c.Logout()
1187
1188 var archiveMailbox string
1189 switch account.ServiceProvider {
1190 case "gmail":
1191 archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr)
1192 if err != nil {
1193 archiveMailbox = "[Gmail]/All Mail"
1194 }
1195 default:
1196 archiveMailbox = "Archive"
1197 }
1198
1199 if _, err := c.Select(mailbox, false); err != nil {
1200 return err
1201 }
1202
1203 seqSet := new(imap.SeqSet)
1204 for _, uid := range uids {
1205 seqSet.AddNum(uid)
1206 }
1207
1208 return c.UidMove(seqSet, archiveMailbox)
1209}
1210
1211// MoveEmailsToFolder moves multiple emails to a different folder (batch operation)
1212func MoveEmailsToFolder(account *config.Account, uids []uint32, sourceFolder, destFolder string) error {
1213 if len(uids) == 0 {
1214 return nil
1215 }
1216
1217 c, err := connect(account)
1218 if err != nil {
1219 return err
1220 }
1221 defer c.Logout()
1222
1223 if _, err := c.Select(sourceFolder, false); err != nil {
1224 return err
1225 }
1226
1227 seqSet := new(imap.SeqSet)
1228 for _, uid := range uids {
1229 seqSet.AddNum(uid)
1230 }
1231
1232 return c.UidMove(seqSet, destFolder)
1233}
1234
1235// Convenience wrappers defaulting to INBOX for existing call sites.
1236
1237func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1238 return FetchMailboxEmails(account, "INBOX", limit, offset)
1239}
1240
1241func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1242 return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
1243}
1244
1245func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1246 return FetchEmailBodyFromMailbox(account, "INBOX", uid)
1247}
1248
1249func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1250 return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
1251}
1252
1253func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1254 return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
1255}
1256
1257func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1258 return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
1259}
1260
1261func DeleteEmail(account *config.Account, uid uint32) error {
1262 return DeleteEmailFromMailbox(account, "INBOX", uid)
1263}
1264
1265func DeleteSentEmail(account *config.Account, uid uint32) error {
1266 return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
1267}
1268
1269func ArchiveEmail(account *config.Account, uid uint32) error {
1270 return ArchiveEmailFromMailbox(account, "INBOX", uid)
1271}
1272
1273func ArchiveSentEmail(account *config.Account, uid uint32) error {
1274 return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
1275}
1276
1277// getTrashMailbox returns the trash mailbox name for the account
1278func getTrashMailbox(account *config.Account) string {
1279 switch account.ServiceProvider {
1280 case "gmail":
1281 return "[Gmail]/Trash"
1282 case "icloud":
1283 return "Deleted Messages"
1284 default:
1285 return "Trash"
1286 }
1287}
1288
1289// getArchiveMailbox returns the archive/all mail mailbox name for the account
1290func getArchiveMailbox(account *config.Account) string {
1291 switch account.ServiceProvider {
1292 case "gmail":
1293 return "[Gmail]/All Mail"
1294 case "icloud":
1295 return "Archive"
1296 default:
1297 return "Archive"
1298 }
1299}
1300
1301// FetchTrashEmails fetches emails from the trash folder
1302func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1303 c, err := connect(account)
1304 if err != nil {
1305 return nil, err
1306 }
1307 defer c.Logout()
1308
1309 // Try to find trash by attribute first
1310 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1311 if err != nil {
1312 // Fallback to hardcoded path
1313 trashMailbox = getTrashMailbox(account)
1314 }
1315
1316 return FetchMailboxEmails(account, trashMailbox, limit, offset)
1317}
1318
1319// FetchArchiveEmails fetches emails from the archive/all mail folder
1320// Archive contains all emails, so we match where user is sender OR recipient
1321func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1322 c, err := connect(account)
1323 if err != nil {
1324 return nil, err
1325 }
1326 defer c.Logout()
1327
1328 // Try to find archive by attribute first (Gmail uses \All)
1329 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1330 if err != nil {
1331 // Fallback to hardcoded path
1332 archiveMailbox = getArchiveMailbox(account)
1333 }
1334
1335 mbox, err := c.Select(archiveMailbox, false)
1336 if err != nil {
1337 return nil, err
1338 }
1339
1340 if mbox.Messages == 0 {
1341 return []Email{}, nil
1342 }
1343
1344 to := mbox.Messages - offset
1345 from := uint32(1)
1346 if to > limit {
1347 from = to - limit + 1
1348 }
1349
1350 if to < 1 {
1351 return []Email{}, nil
1352 }
1353
1354 seqset := new(imap.SeqSet)
1355 seqset.AddRange(from, to)
1356
1357 messages := make(chan *imap.Message, limit)
1358 done := make(chan error, 1)
1359 // Fetch delivery headers to match auto-forwarded emails
1360 deliveryHeaderItem := imap.FetchItem("BODY.PEEK[HEADER.FIELDS (Delivered-To X-Forwarded-To X-Original-To)]")
1361 deliveryHeaderSection, _ := imap.ParseBodySectionName(deliveryHeaderItem)
1362 fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags, deliveryHeaderItem}
1363 go func() {
1364 done <- c.Fetch(seqset, fetchItems, messages)
1365 }()
1366
1367 var msgs []*imap.Message
1368 for msg := range messages {
1369 msgs = append(msgs, msg)
1370 }
1371
1372 if err := <-done; err != nil {
1373 return nil, err
1374 }
1375
1376 // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
1377 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
1378 if fetchEmail == "" {
1379 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
1380 }
1381
1382 var emails []Email
1383 for _, msg := range msgs {
1384 if msg == nil || msg.Envelope == nil {
1385 continue
1386 }
1387
1388 var fromAddr string
1389 if len(msg.Envelope.From) > 0 {
1390 fromAddr = formatAddress(msg.Envelope.From[0])
1391 }
1392
1393 var toAddrList []string
1394 for _, addr := range msg.Envelope.To {
1395 toAddrList = append(toAddrList, addr.Address())
1396 }
1397 for _, addr := range msg.Envelope.Cc {
1398 toAddrList = append(toAddrList, addr.Address())
1399 }
1400
1401 // For archive/All Mail, match emails where user is sender OR recipient
1402 matched := false
1403 // Check if user is the sender
1404 if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
1405 matched = true
1406 }
1407 // Check if user is a recipient
1408 if !matched {
1409 for _, r := range toAddrList {
1410 if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
1411 matched = true
1412 break
1413 }
1414 }
1415 }
1416 // Check delivery headers for auto-forwarded emails
1417 if !matched {
1418 matched = deliveryHeadersMatch(msg, deliveryHeaderSection, fetchEmail)
1419 }
1420
1421 if !matched {
1422 continue
1423 }
1424
1425 emails = append(emails, Email{
1426 UID: msg.Uid,
1427 From: fromAddr,
1428 To: toAddrList,
1429 Subject: decodeHeader(msg.Envelope.Subject),
1430 Date: msg.Envelope.Date,
1431 IsRead: hasSeenFlag(msg.Flags),
1432 AccountID: account.ID,
1433 })
1434 }
1435
1436 // Reverse to get newest first
1437 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
1438 emails[i], emails[j] = emails[j], emails[i]
1439 }
1440
1441 return emails, nil
1442}
1443
1444// FetchTrashEmailBody fetches the body of an email from trash
1445func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1446 c, err := connect(account)
1447 if err != nil {
1448 return "", nil, err
1449 }
1450 defer c.Logout()
1451
1452 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1453 if err != nil {
1454 trashMailbox = getTrashMailbox(account)
1455 }
1456
1457 return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
1458}
1459
1460// FetchArchiveEmailBody fetches the body of an email from archive
1461func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1462 c, err := connect(account)
1463 if err != nil {
1464 return "", nil, err
1465 }
1466 defer c.Logout()
1467
1468 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1469 if err != nil {
1470 archiveMailbox = getArchiveMailbox(account)
1471 }
1472
1473 return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
1474}
1475
1476// FetchTrashAttachment fetches an attachment from trash
1477func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1478 c, err := connect(account)
1479 if err != nil {
1480 return nil, err
1481 }
1482 defer c.Logout()
1483
1484 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1485 if err != nil {
1486 trashMailbox = getTrashMailbox(account)
1487 }
1488
1489 return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
1490}
1491
1492// FetchArchiveAttachment fetches an attachment from archive
1493func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1494 c, err := connect(account)
1495 if err != nil {
1496 return nil, err
1497 }
1498 defer c.Logout()
1499
1500 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1501 if err != nil {
1502 archiveMailbox = getArchiveMailbox(account)
1503 }
1504
1505 return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
1506}
1507
1508// DeleteTrashEmail permanently deletes an email from trash
1509func DeleteTrashEmail(account *config.Account, uid uint32) error {
1510 c, err := connect(account)
1511 if err != nil {
1512 return err
1513 }
1514 defer c.Logout()
1515
1516 trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1517 if err != nil {
1518 trashMailbox = getTrashMailbox(account)
1519 }
1520
1521 return DeleteEmailFromMailbox(account, trashMailbox, uid)
1522}
1523
1524// DeleteArchiveEmail deletes an email from archive (moves to trash)
1525func DeleteArchiveEmail(account *config.Account, uid uint32) error {
1526 c, err := connect(account)
1527 if err != nil {
1528 return err
1529 }
1530 defer c.Logout()
1531
1532 archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1533 if err != nil {
1534 archiveMailbox = getArchiveMailbox(account)
1535 }
1536
1537 return DeleteEmailFromMailbox(account, archiveMailbox, uid)
1538}
1539
1540// FetchFolders lists all IMAP folders/mailboxes for an account.
1541func FetchFolders(account *config.Account) ([]Folder, error) {
1542 c, err := connect(account)
1543 if err != nil {
1544 return nil, err
1545 }
1546 defer c.Logout()
1547
1548 mailboxes := make(chan *imap.MailboxInfo, 50)
1549 done := make(chan error, 1)
1550 go func() {
1551 done <- c.List("", "*", mailboxes)
1552 }()
1553
1554 var folders []Folder
1555 for m := range mailboxes {
1556 folders = append(folders, Folder{
1557 Name: m.Name,
1558 Delimiter: m.Delimiter,
1559 Attributes: m.Attributes,
1560 })
1561 }
1562
1563 if err := <-done; err != nil {
1564 return nil, err
1565 }
1566
1567 return folders, nil
1568}
1569
1570// MoveEmailToFolder moves an email from one folder to another via IMAP.
1571func MoveEmailToFolder(account *config.Account, uid uint32, sourceFolder, destFolder string) error {
1572 return moveEmail(account, uid, sourceFolder, destFolder)
1573}
1574
1575// FetchFolderEmails fetches emails from an arbitrary folder.
1576func FetchFolderEmails(account *config.Account, folder string, limit, offset uint32) ([]Email, error) {
1577 return FetchMailboxEmails(account, folder, limit, offset)
1578}
1579
1580// FetchFolderEmailBody fetches the body of an email from an arbitrary folder.
1581func FetchFolderEmailBody(account *config.Account, folder string, uid uint32) (string, []Attachment, error) {
1582 return FetchEmailBodyFromMailbox(account, folder, uid)
1583}
1584
1585// FetchFolderAttachment fetches an attachment from an arbitrary folder.
1586func FetchFolderAttachment(account *config.Account, folder string, uid uint32, partID string, encoding string) ([]byte, error) {
1587 return FetchAttachmentFromMailbox(account, folder, uid, partID, encoding)
1588}
1589
1590// DeleteFolderEmail deletes an email from an arbitrary folder.
1591func DeleteFolderEmail(account *config.Account, folder string, uid uint32) error {
1592 return DeleteEmailFromMailbox(account, folder, uid)
1593}
1594
1595// ArchiveFolderEmail archives an email from an arbitrary folder.
1596func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) error {
1597 return ArchiveEmailFromMailbox(account, folder, uid)
1598}
1599
1600// decryptPGPMessage decrypts a PGP-encrypted message using the account's private key.
1601func decryptPGPMessage(encryptedData []byte, account *config.Account) ([]byte, error) {
1602 if account.PGPPrivateKey == "" {
1603 return nil, errors.New("PGP private key not configured")
1604 }
1605
1606 // Load private key
1607 keyFile, err := os.ReadFile(account.PGPPrivateKey)
1608 if err != nil {
1609 return nil, fmt.Errorf("failed to read PGP private key: %w", err)
1610 }
1611
1612 // Try armored format first
1613 entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
1614 if err != nil {
1615 // Try binary format
1616 entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
1617 if err != nil {
1618 return nil, fmt.Errorf("failed to parse PGP private key: %w", err)
1619 }
1620 }
1621
1622 if len(entityList) == 0 {
1623 return nil, errors.New("no PGP keys found in private keyring")
1624 }
1625
1626 // Decrypt using go-pgpmail
1627 mr, err := pgpmail.Read(bytes.NewReader(encryptedData), openpgp.EntityList{entityList[0]}, nil, nil)
1628 if err != nil {
1629 return nil, fmt.Errorf("failed to decrypt PGP message: %w", err)
1630 }
1631
1632 // Read decrypted content from UnverifiedBody
1633 if mr.MessageDetails == nil || mr.MessageDetails.UnverifiedBody == nil {
1634 return nil, errors.New("no decrypted content available")
1635 }
1636
1637 var decrypted bytes.Buffer
1638 if _, err := io.Copy(&decrypted, mr.MessageDetails.UnverifiedBody); err != nil {
1639 return nil, fmt.Errorf("failed to read decrypted content: %w", err)
1640 }
1641
1642 return decrypted.Bytes(), nil
1643}
1644
1645// loadPGPKeyring builds an openpgp.EntityList from the account's public key
1646// and any keys stored in the pgp/ config directory.
1647func loadPGPKeyring(account *config.Account) openpgp.EntityList {
1648 var keyring openpgp.EntityList
1649
1650 readKeys := func(path string) {
1651 data, err := os.ReadFile(path)
1652 if err != nil {
1653 return
1654 }
1655 entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
1656 if err != nil {
1657 entities, err = openpgp.ReadKeyRing(bytes.NewReader(data))
1658 if err != nil {
1659 return
1660 }
1661 }
1662 keyring = append(keyring, entities...)
1663 }
1664
1665 // Load account's own public key
1666 if account.PGPPublicKey != "" {
1667 readKeys(account.PGPPublicKey)
1668 }
1669
1670 // Load all keys from the pgp/ config directory
1671 cfgDir, err := config.GetConfigDir()
1672 if err == nil {
1673 pgpDir := cfgDir + "/pgp"
1674 entries, err := os.ReadDir(pgpDir)
1675 if err == nil {
1676 for _, entry := range entries {
1677 if entry.IsDir() {
1678 continue
1679 }
1680 name := entry.Name()
1681 if strings.HasSuffix(name, ".asc") || strings.HasSuffix(name, ".gpg") {
1682 readKeys(pgpDir + "/" + name)
1683 }
1684 }
1685 }
1686 }
1687
1688 return keyring
1689}
1690
1691// verifyPGPSignature verifies a PGP detached signature against signed content.
1692func verifyPGPSignature(signedContent, signatureData []byte, account *config.Account) bool {
1693 keyring := loadPGPKeyring(account)
1694 if len(keyring) == 0 {
1695 return false
1696 }
1697
1698 // Build a complete multipart/signed message for go-pgpmail
1699 boundary := "pgp-verify-boundary"
1700 var msg bytes.Buffer
1701 msg.WriteString("Content-Type: multipart/signed; boundary=\"" + boundary + "\"; micalg=pgp-sha256; protocol=\"application/pgp-signature\"\r\n\r\n")
1702 msg.WriteString("--" + boundary + "\r\n")
1703 msg.Write(signedContent)
1704 msg.WriteString("\r\n--" + boundary + "\r\n")
1705 msg.WriteString("Content-Type: application/pgp-signature\r\n\r\n")
1706 msg.Write(signatureData)
1707 msg.WriteString("\r\n--" + boundary + "--\r\n")
1708
1709 mr, err := pgpmail.Read(&msg, keyring, nil, nil)
1710 if err != nil {
1711 return false
1712 }
1713
1714 if mr.MessageDetails == nil {
1715 return false
1716 }
1717
1718 // Must read UnverifiedBody to EOF to trigger signature verification
1719 _, _ = io.ReadAll(mr.MessageDetails.UnverifiedBody)
1720
1721 return mr.MessageDetails.SignatureError == nil
1722}