fetcher.go

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