fetcher.go

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