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// Convenience wrappers defaulting to INBOX for existing call sites.
1144
1145func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1146	return FetchMailboxEmails(account, "INBOX", limit, offset)
1147}
1148
1149func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1150	return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
1151}
1152
1153func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1154	return FetchEmailBodyFromMailbox(account, "INBOX", uid)
1155}
1156
1157func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1158	return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
1159}
1160
1161func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1162	return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
1163}
1164
1165func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1166	return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
1167}
1168
1169func DeleteEmail(account *config.Account, uid uint32) error {
1170	return DeleteEmailFromMailbox(account, "INBOX", uid)
1171}
1172
1173func DeleteSentEmail(account *config.Account, uid uint32) error {
1174	return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
1175}
1176
1177func ArchiveEmail(account *config.Account, uid uint32) error {
1178	return ArchiveEmailFromMailbox(account, "INBOX", uid)
1179}
1180
1181func ArchiveSentEmail(account *config.Account, uid uint32) error {
1182	return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
1183}
1184
1185// getTrashMailbox returns the trash mailbox name for the account
1186func getTrashMailbox(account *config.Account) string {
1187	switch account.ServiceProvider {
1188	case "gmail":
1189		return "[Gmail]/Trash"
1190	case "icloud":
1191		return "Deleted Messages"
1192	default:
1193		return "Trash"
1194	}
1195}
1196
1197// getArchiveMailbox returns the archive/all mail mailbox name for the account
1198func getArchiveMailbox(account *config.Account) string {
1199	switch account.ServiceProvider {
1200	case "gmail":
1201		return "[Gmail]/All Mail"
1202	case "icloud":
1203		return "Archive"
1204	default:
1205		return "Archive"
1206	}
1207}
1208
1209// FetchTrashEmails fetches emails from the trash folder
1210func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1211	c, err := connect(account)
1212	if err != nil {
1213		return nil, err
1214	}
1215	defer c.Logout()
1216
1217	// Try to find trash by attribute first
1218	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1219	if err != nil {
1220		// Fallback to hardcoded path
1221		trashMailbox = getTrashMailbox(account)
1222	}
1223
1224	return FetchMailboxEmails(account, trashMailbox, limit, offset)
1225}
1226
1227// FetchArchiveEmails fetches emails from the archive/all mail folder
1228// Archive contains all emails, so we match where user is sender OR recipient
1229func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1230	c, err := connect(account)
1231	if err != nil {
1232		return nil, err
1233	}
1234	defer c.Logout()
1235
1236	// Try to find archive by attribute first (Gmail uses \All)
1237	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1238	if err != nil {
1239		// Fallback to hardcoded path
1240		archiveMailbox = getArchiveMailbox(account)
1241	}
1242
1243	mbox, err := c.Select(archiveMailbox, false)
1244	if err != nil {
1245		return nil, err
1246	}
1247
1248	if mbox.Messages == 0 {
1249		return []Email{}, nil
1250	}
1251
1252	to := mbox.Messages - offset
1253	from := uint32(1)
1254	if to > limit {
1255		from = to - limit + 1
1256	}
1257
1258	if to < 1 {
1259		return []Email{}, nil
1260	}
1261
1262	seqset := new(imap.SeqSet)
1263	seqset.AddRange(from, to)
1264
1265	messages := make(chan *imap.Message, limit)
1266	done := make(chan error, 1)
1267	// Fetch delivery headers to match auto-forwarded emails
1268	deliveryHeaderItem := imap.FetchItem("BODY.PEEK[HEADER.FIELDS (Delivered-To X-Forwarded-To X-Original-To)]")
1269	deliveryHeaderSection, _ := imap.ParseBodySectionName(deliveryHeaderItem)
1270	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags, deliveryHeaderItem}
1271	go func() {
1272		done <- c.Fetch(seqset, fetchItems, messages)
1273	}()
1274
1275	var msgs []*imap.Message
1276	for msg := range messages {
1277		msgs = append(msgs, msg)
1278	}
1279
1280	if err := <-done; err != nil {
1281		return nil, err
1282	}
1283
1284	// Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
1285	fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
1286	if fetchEmail == "" {
1287		fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
1288	}
1289
1290	var emails []Email
1291	for _, msg := range msgs {
1292		if msg == nil || msg.Envelope == nil {
1293			continue
1294		}
1295
1296		var fromAddr string
1297		if len(msg.Envelope.From) > 0 {
1298			fromAddr = formatAddress(msg.Envelope.From[0])
1299		}
1300
1301		var toAddrList []string
1302		for _, addr := range msg.Envelope.To {
1303			toAddrList = append(toAddrList, addr.Address())
1304		}
1305		for _, addr := range msg.Envelope.Cc {
1306			toAddrList = append(toAddrList, addr.Address())
1307		}
1308
1309		// For archive/All Mail, match emails where user is sender OR recipient
1310		matched := false
1311		// Check if user is the sender
1312		if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
1313			matched = true
1314		}
1315		// Check if user is a recipient
1316		if !matched {
1317			for _, r := range toAddrList {
1318				if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
1319					matched = true
1320					break
1321				}
1322			}
1323		}
1324		// Check delivery headers for auto-forwarded emails
1325		if !matched {
1326			matched = deliveryHeadersMatch(msg, deliveryHeaderSection, fetchEmail)
1327		}
1328
1329		if !matched {
1330			continue
1331		}
1332
1333		emails = append(emails, Email{
1334			UID:       msg.Uid,
1335			From:      fromAddr,
1336			To:        toAddrList,
1337			Subject:   decodeHeader(msg.Envelope.Subject),
1338			Date:      msg.Envelope.Date,
1339			IsRead:    hasSeenFlag(msg.Flags),
1340			AccountID: account.ID,
1341		})
1342	}
1343
1344	// Reverse to get newest first
1345	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
1346		emails[i], emails[j] = emails[j], emails[i]
1347	}
1348
1349	return emails, nil
1350}
1351
1352// FetchTrashEmailBody fetches the body of an email from trash
1353func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1354	c, err := connect(account)
1355	if err != nil {
1356		return "", nil, err
1357	}
1358	defer c.Logout()
1359
1360	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1361	if err != nil {
1362		trashMailbox = getTrashMailbox(account)
1363	}
1364
1365	return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
1366}
1367
1368// FetchArchiveEmailBody fetches the body of an email from archive
1369func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1370	c, err := connect(account)
1371	if err != nil {
1372		return "", nil, err
1373	}
1374	defer c.Logout()
1375
1376	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1377	if err != nil {
1378		archiveMailbox = getArchiveMailbox(account)
1379	}
1380
1381	return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
1382}
1383
1384// FetchTrashAttachment fetches an attachment from trash
1385func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1386	c, err := connect(account)
1387	if err != nil {
1388		return nil, err
1389	}
1390	defer c.Logout()
1391
1392	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1393	if err != nil {
1394		trashMailbox = getTrashMailbox(account)
1395	}
1396
1397	return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
1398}
1399
1400// FetchArchiveAttachment fetches an attachment from archive
1401func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1402	c, err := connect(account)
1403	if err != nil {
1404		return nil, err
1405	}
1406	defer c.Logout()
1407
1408	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1409	if err != nil {
1410		archiveMailbox = getArchiveMailbox(account)
1411	}
1412
1413	return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
1414}
1415
1416// DeleteTrashEmail permanently deletes an email from trash
1417func DeleteTrashEmail(account *config.Account, uid uint32) error {
1418	c, err := connect(account)
1419	if err != nil {
1420		return err
1421	}
1422	defer c.Logout()
1423
1424	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1425	if err != nil {
1426		trashMailbox = getTrashMailbox(account)
1427	}
1428
1429	return DeleteEmailFromMailbox(account, trashMailbox, uid)
1430}
1431
1432// DeleteArchiveEmail deletes an email from archive (moves to trash)
1433func DeleteArchiveEmail(account *config.Account, uid uint32) error {
1434	c, err := connect(account)
1435	if err != nil {
1436		return err
1437	}
1438	defer c.Logout()
1439
1440	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1441	if err != nil {
1442		archiveMailbox = getArchiveMailbox(account)
1443	}
1444
1445	return DeleteEmailFromMailbox(account, archiveMailbox, uid)
1446}
1447
1448// FetchFolders lists all IMAP folders/mailboxes for an account.
1449func FetchFolders(account *config.Account) ([]Folder, error) {
1450	c, err := connect(account)
1451	if err != nil {
1452		return nil, err
1453	}
1454	defer c.Logout()
1455
1456	mailboxes := make(chan *imap.MailboxInfo, 50)
1457	done := make(chan error, 1)
1458	go func() {
1459		done <- c.List("", "*", mailboxes)
1460	}()
1461
1462	var folders []Folder
1463	for m := range mailboxes {
1464		folders = append(folders, Folder{
1465			Name:       m.Name,
1466			Delimiter:  m.Delimiter,
1467			Attributes: m.Attributes,
1468		})
1469	}
1470
1471	if err := <-done; err != nil {
1472		return nil, err
1473	}
1474
1475	return folders, nil
1476}
1477
1478// MoveEmailToFolder moves an email from one folder to another via IMAP.
1479func MoveEmailToFolder(account *config.Account, uid uint32, sourceFolder, destFolder string) error {
1480	return moveEmail(account, uid, sourceFolder, destFolder)
1481}
1482
1483// FetchFolderEmails fetches emails from an arbitrary folder.
1484func FetchFolderEmails(account *config.Account, folder string, limit, offset uint32) ([]Email, error) {
1485	return FetchMailboxEmails(account, folder, limit, offset)
1486}
1487
1488// FetchFolderEmailBody fetches the body of an email from an arbitrary folder.
1489func FetchFolderEmailBody(account *config.Account, folder string, uid uint32) (string, []Attachment, error) {
1490	return FetchEmailBodyFromMailbox(account, folder, uid)
1491}
1492
1493// FetchFolderAttachment fetches an attachment from an arbitrary folder.
1494func FetchFolderAttachment(account *config.Account, folder string, uid uint32, partID string, encoding string) ([]byte, error) {
1495	return FetchAttachmentFromMailbox(account, folder, uid, partID, encoding)
1496}
1497
1498// DeleteFolderEmail deletes an email from an arbitrary folder.
1499func DeleteFolderEmail(account *config.Account, folder string, uid uint32) error {
1500	return DeleteEmailFromMailbox(account, folder, uid)
1501}
1502
1503// ArchiveFolderEmail archives an email from an arbitrary folder.
1504func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) error {
1505	return ArchiveEmailFromMailbox(account, folder, uid)
1506}
1507
1508// decryptPGPMessage decrypts a PGP-encrypted message using the account's private key.
1509func decryptPGPMessage(encryptedData []byte, account *config.Account) ([]byte, error) {
1510	if account.PGPPrivateKey == "" {
1511		return nil, errors.New("PGP private key not configured")
1512	}
1513
1514	// Load private key
1515	keyFile, err := os.ReadFile(account.PGPPrivateKey)
1516	if err != nil {
1517		return nil, fmt.Errorf("failed to read PGP private key: %w", err)
1518	}
1519
1520	// Try armored format first
1521	entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
1522	if err != nil {
1523		// Try binary format
1524		entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
1525		if err != nil {
1526			return nil, fmt.Errorf("failed to parse PGP private key: %w", err)
1527		}
1528	}
1529
1530	if len(entityList) == 0 {
1531		return nil, errors.New("no PGP keys found in private keyring")
1532	}
1533
1534	// Decrypt using go-pgpmail
1535	mr, err := pgpmail.Read(bytes.NewReader(encryptedData), openpgp.EntityList{entityList[0]}, nil, nil)
1536	if err != nil {
1537		return nil, fmt.Errorf("failed to decrypt PGP message: %w", err)
1538	}
1539
1540	// Read decrypted content from UnverifiedBody
1541	if mr.MessageDetails == nil || mr.MessageDetails.UnverifiedBody == nil {
1542		return nil, errors.New("no decrypted content available")
1543	}
1544
1545	var decrypted bytes.Buffer
1546	if _, err := io.Copy(&decrypted, mr.MessageDetails.UnverifiedBody); err != nil {
1547		return nil, fmt.Errorf("failed to read decrypted content: %w", err)
1548	}
1549
1550	return decrypted.Bytes(), nil
1551}
1552
1553// loadPGPKeyring builds an openpgp.EntityList from the account's public key
1554// and any keys stored in the pgp/ config directory.
1555func loadPGPKeyring(account *config.Account) openpgp.EntityList {
1556	var keyring openpgp.EntityList
1557
1558	readKeys := func(path string) {
1559		data, err := os.ReadFile(path)
1560		if err != nil {
1561			return
1562		}
1563		entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
1564		if err != nil {
1565			entities, err = openpgp.ReadKeyRing(bytes.NewReader(data))
1566			if err != nil {
1567				return
1568			}
1569		}
1570		keyring = append(keyring, entities...)
1571	}
1572
1573	// Load account's own public key
1574	if account.PGPPublicKey != "" {
1575		readKeys(account.PGPPublicKey)
1576	}
1577
1578	// Load all keys from the pgp/ config directory
1579	cfgDir, err := config.GetConfigDir()
1580	if err == nil {
1581		pgpDir := cfgDir + "/pgp"
1582		entries, err := os.ReadDir(pgpDir)
1583		if err == nil {
1584			for _, entry := range entries {
1585				if entry.IsDir() {
1586					continue
1587				}
1588				name := entry.Name()
1589				if strings.HasSuffix(name, ".asc") || strings.HasSuffix(name, ".gpg") {
1590					readKeys(pgpDir + "/" + name)
1591				}
1592			}
1593		}
1594	}
1595
1596	return keyring
1597}
1598
1599// verifyPGPSignature verifies a PGP detached signature against signed content.
1600func verifyPGPSignature(signedContent, signatureData []byte, account *config.Account) bool {
1601	keyring := loadPGPKeyring(account)
1602	if len(keyring) == 0 {
1603		return false
1604	}
1605
1606	// Build a complete multipart/signed message for go-pgpmail
1607	boundary := "pgp-verify-boundary"
1608	var msg bytes.Buffer
1609	msg.WriteString("Content-Type: multipart/signed; boundary=\"" + boundary + "\"; micalg=pgp-sha256; protocol=\"application/pgp-signature\"\r\n\r\n")
1610	msg.WriteString("--" + boundary + "\r\n")
1611	msg.Write(signedContent)
1612	msg.WriteString("\r\n--" + boundary + "\r\n")
1613	msg.WriteString("Content-Type: application/pgp-signature\r\n\r\n")
1614	msg.Write(signatureData)
1615	msg.WriteString("\r\n--" + boundary + "--\r\n")
1616
1617	mr, err := pgpmail.Read(&msg, keyring, nil, nil)
1618	if err != nil {
1619		return false
1620	}
1621
1622	if mr.MessageDetails == nil {
1623		return false
1624	}
1625
1626	// Must read UnverifiedBody to EOF to trigger signature verification
1627	_, _ = io.ReadAll(mr.MessageDetails.UnverifiedBody)
1628
1629	return mr.MessageDetails.SignatureError == nil
1630}