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