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