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