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