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