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