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