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