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