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