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