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