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