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