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