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