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			if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
1045				_, _ = f.WriteString(msg)
1046				_ = f.Close()
1047			}
1048		}
1049	}
1050	if textPartID != "" {
1051		part := parsePartID(textPartID)
1052		section := &imap.FetchItemBodySection{
1053			Part: part,
1054			Peek: true,
1055		}
1056
1057		fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
1058			BodySection: []*imap.FetchItemBodySection{section},
1059		})
1060		msgs, err := fetchCmd.Collect()
1061		if err != nil {
1062			return "", nil, err
1063		}
1064
1065		if len(msgs) > 0 {
1066			if buf := msgs[0].FindBodySection(section); buf != nil {
1067				// Use the encoding from BodyStructure to decode
1068				if decoded, err := decodeAttachmentData(buf, textPartEncoding); err == nil {
1069					body = string(decoded)
1070				} else {
1071					body = string(buf)
1072				}
1073			}
1074		}
1075	}
1076
1077	return body, attachments, nil
1078}
1079
1080func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
1081	c, err := connect(account)
1082	if err != nil {
1083		return nil, err
1084	}
1085	defer c.Close()
1086
1087	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1088		return nil, err
1089	}
1090
1091	uidSet := imap.UIDSetNum(imap.UID(uid))
1092	part := parsePartID(partID)
1093	section := &imap.FetchItemBodySection{
1094		Part: part,
1095		Peek: true,
1096	}
1097
1098	fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
1099		BodySection: []*imap.FetchItemBodySection{section},
1100	})
1101	msgs, err := fetchCmd.Collect()
1102	if err != nil {
1103		return nil, err
1104	}
1105
1106	if len(msgs) == 0 {
1107		return nil, fmt.Errorf("could not fetch attachment")
1108	}
1109
1110	rawBytes := msgs[0].FindBodySection(section)
1111	if rawBytes == nil {
1112		return nil, fmt.Errorf("could not get attachment body")
1113	}
1114
1115	decoded, err := decodeAttachmentData(rawBytes, encoding)
1116	if err != nil {
1117		return rawBytes, nil
1118	}
1119	return decoded, nil
1120}
1121
1122func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
1123	c, err := connect(account)
1124	if err != nil {
1125		return err
1126	}
1127	defer c.Close()
1128
1129	if _, err := c.Select(sourceMailbox, nil).Wait(); err != nil {
1130		return err
1131	}
1132
1133	uidSet := imap.UIDSetNum(imap.UID(uid))
1134	_, err = c.Move(uidSet, destMailbox).Wait()
1135	return err
1136}
1137
1138func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint32) error {
1139	c, err := connect(account)
1140	if err != nil {
1141		return err
1142	}
1143	defer c.Close()
1144
1145	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1146		return err
1147	}
1148
1149	uidSet := imap.UIDSetNum(imap.UID(uid))
1150	return c.Store(uidSet, &imap.StoreFlags{
1151		Op:     imap.StoreFlagsAdd,
1152		Silent: true,
1153		Flags:  []imap.Flag{imap.FlagSeen},
1154	}, nil).Close()
1155}
1156
1157func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
1158	c, err := connect(account)
1159	if err != nil {
1160		return err
1161	}
1162	defer c.Close()
1163
1164	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1165		return err
1166	}
1167
1168	uidSet := imap.UIDSetNum(imap.UID(uid))
1169	if err := c.Store(uidSet, &imap.StoreFlags{
1170		Op:     imap.StoreFlagsAdd,
1171		Silent: true,
1172		Flags:  []imap.Flag{imap.FlagDeleted},
1173	}, nil).Close(); err != nil {
1174		return err
1175	}
1176
1177	return c.Expunge().Close()
1178}
1179
1180func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
1181	c, err := connect(account)
1182	if err != nil {
1183		return err
1184	}
1185	defer c.Close()
1186
1187	var archiveMailbox string
1188	switch account.ServiceProvider {
1189	case "gmail":
1190		// For Gmail, find the mailbox with the \All attribute
1191		archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll)
1192		if err != nil {
1193			// Fallback to hardcoded path if attribute lookup fails
1194			archiveMailbox = "[Gmail]/All Mail"
1195		}
1196	default:
1197		archiveMailbox = "Archive"
1198	}
1199
1200	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1201		return err
1202	}
1203
1204	uidSet := imap.UIDSetNum(imap.UID(uid))
1205	_, err = c.Move(uidSet, archiveMailbox).Wait()
1206	return err
1207}
1208
1209// Batch operations for multiple emails
1210
1211// DeleteEmailsFromMailbox deletes multiple emails from a mailbox (batch operation)
1212func DeleteEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
1213	if len(uids) == 0 {
1214		return nil
1215	}
1216
1217	c, err := connect(account)
1218	if err != nil {
1219		return err
1220	}
1221	defer c.Close()
1222
1223	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1224		return err
1225	}
1226
1227	uidSet := uidsToUIDSet(uids)
1228	if err := c.Store(uidSet, &imap.StoreFlags{
1229		Op:     imap.StoreFlagsAdd,
1230		Silent: true,
1231		Flags:  []imap.Flag{imap.FlagDeleted},
1232	}, nil).Close(); err != nil {
1233		return err
1234	}
1235
1236	return c.Expunge().Close()
1237}
1238
1239// ArchiveEmailsFromMailbox archives multiple emails from a mailbox (batch operation)
1240func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
1241	if len(uids) == 0 {
1242		return nil
1243	}
1244
1245	c, err := connect(account)
1246	if err != nil {
1247		return err
1248	}
1249	defer c.Close()
1250
1251	var archiveMailbox string
1252	switch account.ServiceProvider {
1253	case "gmail":
1254		archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll)
1255		if err != nil {
1256			archiveMailbox = "[Gmail]/All Mail"
1257		}
1258	default:
1259		archiveMailbox = "Archive"
1260	}
1261
1262	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1263		return err
1264	}
1265
1266	uidSet := uidsToUIDSet(uids)
1267	_, err = c.Move(uidSet, archiveMailbox).Wait()
1268	return err
1269}
1270
1271// MoveEmailsToFolder moves multiple emails to a different folder (batch operation)
1272func MoveEmailsToFolder(account *config.Account, uids []uint32, sourceFolder, destFolder string) error {
1273	if len(uids) == 0 {
1274		return nil
1275	}
1276
1277	c, err := connect(account)
1278	if err != nil {
1279		return err
1280	}
1281	defer c.Close()
1282
1283	if _, err := c.Select(sourceFolder, nil).Wait(); err != nil {
1284		return err
1285	}
1286
1287	uidSet := uidsToUIDSet(uids)
1288	_, err = c.Move(uidSet, destFolder).Wait()
1289	return err
1290}
1291
1292// Convenience wrappers defaulting to INBOX for existing call sites.
1293
1294func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1295	return FetchMailboxEmails(account, "INBOX", limit, offset)
1296}
1297
1298func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1299	return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
1300}
1301
1302func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1303	return FetchEmailBodyFromMailbox(account, "INBOX", uid)
1304}
1305
1306func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1307	return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
1308}
1309
1310func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1311	return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
1312}
1313
1314func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1315	return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
1316}
1317
1318func DeleteEmail(account *config.Account, uid uint32) error {
1319	return DeleteEmailFromMailbox(account, "INBOX", uid)
1320}
1321
1322func DeleteSentEmail(account *config.Account, uid uint32) error {
1323	return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
1324}
1325
1326func ArchiveEmail(account *config.Account, uid uint32) error {
1327	return ArchiveEmailFromMailbox(account, "INBOX", uid)
1328}
1329
1330func ArchiveSentEmail(account *config.Account, uid uint32) error {
1331	return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
1332}
1333
1334// AppendToSentMailbox appends a raw RFC822 message to the Sent mailbox via IMAP APPEND.
1335func AppendToSentMailbox(account *config.Account, rawMsg []byte) error {
1336	c, err := connect(account)
1337	if err != nil {
1338		return err
1339	}
1340	defer c.Close()
1341
1342	sentMailbox := getSentMailbox(account)
1343	appendCmd := c.Append(sentMailbox, int64(len(rawMsg)), &imap.AppendOptions{
1344		Flags: []imap.Flag{imap.FlagSeen},
1345		Time:  time.Now(),
1346	})
1347	if _, err := appendCmd.Write(rawMsg); err != nil {
1348		return err
1349	}
1350	if err := appendCmd.Close(); err != nil {
1351		return err
1352	}
1353	_, err = appendCmd.Wait()
1354	return err
1355}
1356
1357// getTrashMailbox returns the trash mailbox name for the account
1358func getTrashMailbox(account *config.Account) string {
1359	switch account.ServiceProvider {
1360	case "gmail":
1361		return "[Gmail]/Trash"
1362	case "outlook":
1363		return "Deleted Items"
1364	case "icloud":
1365		return "Deleted Messages"
1366	default:
1367		return "Trash"
1368	}
1369}
1370
1371// getArchiveMailbox returns the archive/all mail mailbox name for the account
1372func getArchiveMailbox(account *config.Account) string {
1373	switch account.ServiceProvider {
1374	case "gmail":
1375		return "[Gmail]/All Mail"
1376	case "outlook", "icloud":
1377		return "Archive"
1378	default:
1379		return "Archive"
1380	}
1381}
1382
1383// FetchTrashEmails fetches emails from the trash folder
1384func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1385	c, err := connect(account)
1386	if err != nil {
1387		return nil, err
1388	}
1389	defer c.Close()
1390
1391	// Try to find trash by attribute first
1392	trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1393	if err != nil {
1394		// Fallback to hardcoded path
1395		trashMailbox = getTrashMailbox(account)
1396	}
1397
1398	return FetchMailboxEmails(account, trashMailbox, limit, offset)
1399}
1400
1401// FetchArchiveEmails fetches emails from the archive/all mail folder
1402// Archive contains all emails, so we match where user is sender OR recipient
1403func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1404	c, err := connect(account)
1405	if err != nil {
1406		return nil, err
1407	}
1408	defer c.Close()
1409
1410	// Try to find archive by attribute first (Gmail uses \All)
1411	archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1412	if err != nil {
1413		// Fallback to hardcoded path
1414		archiveMailbox = getArchiveMailbox(account)
1415	}
1416
1417	selectData, err := c.Select(archiveMailbox, nil).Wait()
1418	if err != nil {
1419		return nil, err
1420	}
1421
1422	if selectData.NumMessages == 0 {
1423		return []Email{}, nil
1424	}
1425
1426	to := selectData.NumMessages - offset
1427	from := uint32(1)
1428	if to > limit {
1429		from = to - limit + 1
1430	}
1431
1432	if to < 1 {
1433		return []Email{}, nil
1434	}
1435
1436	var seqset imap.SeqSet
1437	seqset.AddRange(from, to)
1438
1439	// Delivery header section for matching auto-forwarded emails
1440	deliveryHeaderSection := &imap.FetchItemBodySection{
1441		Specifier:    imap.PartSpecifierHeader,
1442		HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To"},
1443		Peek:         true,
1444	}
1445
1446	fetchCmd := c.Fetch(seqset, &imap.FetchOptions{
1447		Envelope:    true,
1448		UID:         true,
1449		Flags:       true,
1450		BodySection: []*imap.FetchItemBodySection{deliveryHeaderSection},
1451	})
1452	msgs, err := fetchCmd.Collect()
1453	if err != nil {
1454		return nil, err
1455	}
1456
1457	// Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
1458	fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
1459	if fetchEmail == "" {
1460		fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
1461	}
1462
1463	var emails []Email
1464	for _, msg := range msgs {
1465		if msg.Envelope == nil {
1466			continue
1467		}
1468
1469		var fromAddr string
1470		if len(msg.Envelope.From) > 0 {
1471			fromAddr = formatAddress(msg.Envelope.From[0])
1472		}
1473
1474		var toAddrList []string
1475		for _, addr := range msg.Envelope.To {
1476			toAddrList = append(toAddrList, addr.Addr())
1477		}
1478		for _, addr := range msg.Envelope.Cc {
1479			toAddrList = append(toAddrList, addr.Addr())
1480		}
1481
1482		// For archive/All Mail, match emails where user is sender OR recipient
1483		matched := false
1484		// Check if user is the sender
1485		if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
1486			matched = true
1487		}
1488		// Check if user is a recipient
1489		if !matched {
1490			for _, r := range toAddrList {
1491				if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
1492					matched = true
1493					break
1494				}
1495			}
1496		}
1497		// Check delivery headers for auto-forwarded emails
1498		if !matched {
1499			headerData := msg.FindBodySection(deliveryHeaderSection)
1500			matched = deliveryHeadersMatch(headerData, fetchEmail)
1501		}
1502
1503		if !matched {
1504			continue
1505		}
1506
1507		emails = append(emails, Email{
1508			UID:       uint32(msg.UID),
1509			From:      fromAddr,
1510			To:        toAddrList,
1511			Subject:   decodeHeader(msg.Envelope.Subject),
1512			Date:      msg.Envelope.Date,
1513			IsRead:    hasSeenFlag(msg.Flags),
1514			AccountID: account.ID,
1515		})
1516	}
1517
1518	// Reverse to get newest first
1519	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
1520		emails[i], emails[j] = emails[j], emails[i]
1521	}
1522
1523	return emails, nil
1524}
1525
1526// FetchTrashEmailBody fetches the body of an email from trash
1527func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1528	c, err := connect(account)
1529	if err != nil {
1530		return "", nil, err
1531	}
1532	defer c.Close()
1533
1534	trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1535	if err != nil {
1536		trashMailbox = getTrashMailbox(account)
1537	}
1538
1539	return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
1540}
1541
1542// FetchArchiveEmailBody fetches the body of an email from archive
1543func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1544	c, err := connect(account)
1545	if err != nil {
1546		return "", nil, err
1547	}
1548	defer c.Close()
1549
1550	archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1551	if err != nil {
1552		archiveMailbox = getArchiveMailbox(account)
1553	}
1554
1555	return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
1556}
1557
1558// FetchTrashAttachment fetches an attachment from trash
1559func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1560	c, err := connect(account)
1561	if err != nil {
1562		return nil, err
1563	}
1564	defer c.Close()
1565
1566	trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1567	if err != nil {
1568		trashMailbox = getTrashMailbox(account)
1569	}
1570
1571	return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
1572}
1573
1574// FetchArchiveAttachment fetches an attachment from archive
1575func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1576	c, err := connect(account)
1577	if err != nil {
1578		return nil, err
1579	}
1580	defer c.Close()
1581
1582	archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1583	if err != nil {
1584		archiveMailbox = getArchiveMailbox(account)
1585	}
1586
1587	return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
1588}
1589
1590// DeleteTrashEmail permanently deletes an email from trash
1591func DeleteTrashEmail(account *config.Account, uid uint32) error {
1592	c, err := connect(account)
1593	if err != nil {
1594		return err
1595	}
1596	defer c.Close()
1597
1598	trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1599	if err != nil {
1600		trashMailbox = getTrashMailbox(account)
1601	}
1602
1603	return DeleteEmailFromMailbox(account, trashMailbox, uid)
1604}
1605
1606// DeleteArchiveEmail deletes an email from archive (moves to trash)
1607func DeleteArchiveEmail(account *config.Account, uid uint32) error {
1608	c, err := connect(account)
1609	if err != nil {
1610		return err
1611	}
1612	defer c.Close()
1613
1614	archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1615	if err != nil {
1616		archiveMailbox = getArchiveMailbox(account)
1617	}
1618
1619	return DeleteEmailFromMailbox(account, archiveMailbox, uid)
1620}
1621
1622// FetchFolders lists all IMAP folders/mailboxes for an account.
1623func FetchFolders(account *config.Account) ([]Folder, error) {
1624	c, err := connect(account)
1625	if err != nil {
1626		return nil, err
1627	}
1628	defer c.Close()
1629
1630	listCmd := c.List("", "*", nil)
1631	defer listCmd.Close()
1632
1633	var folders []Folder
1634	for {
1635		data := listCmd.Next()
1636		if data == nil {
1637			break
1638		}
1639		delim := ""
1640		if data.Delim != 0 {
1641			delim = string(data.Delim)
1642		}
1643		var attrs []string
1644		for _, a := range data.Attrs {
1645			attrs = append(attrs, string(a))
1646		}
1647		folders = append(folders, Folder{
1648			Name:       data.Mailbox,
1649			Delimiter:  delim,
1650			Attributes: attrs,
1651		})
1652	}
1653
1654	if err := listCmd.Close(); err != nil {
1655		return nil, err
1656	}
1657
1658	return folders, nil
1659}
1660
1661// MoveEmailToFolder moves an email from one folder to another via IMAP.
1662func MoveEmailToFolder(account *config.Account, uid uint32, sourceFolder, destFolder string) error {
1663	return moveEmail(account, uid, sourceFolder, destFolder)
1664}
1665
1666// FetchFolderEmails fetches emails from an arbitrary folder.
1667func FetchFolderEmails(account *config.Account, folder string, limit, offset uint32) ([]Email, error) {
1668	return FetchMailboxEmails(account, folder, limit, offset)
1669}
1670
1671// FetchFolderEmailBody fetches the body of an email from an arbitrary folder.
1672func FetchFolderEmailBody(account *config.Account, folder string, uid uint32) (string, []Attachment, error) {
1673	return FetchEmailBodyFromMailbox(account, folder, uid)
1674}
1675
1676// FetchFolderAttachment fetches an attachment from an arbitrary folder.
1677func FetchFolderAttachment(account *config.Account, folder string, uid uint32, partID string, encoding string) ([]byte, error) {
1678	return FetchAttachmentFromMailbox(account, folder, uid, partID, encoding)
1679}
1680
1681// DeleteFolderEmail deletes an email from an arbitrary folder.
1682func DeleteFolderEmail(account *config.Account, folder string, uid uint32) error {
1683	return DeleteEmailFromMailbox(account, folder, uid)
1684}
1685
1686// ArchiveFolderEmail archives an email from an arbitrary folder.
1687func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) error {
1688	return ArchiveEmailFromMailbox(account, folder, uid)
1689}
1690
1691// decryptPGPMessage decrypts a PGP-encrypted message using the account's private key.
1692func decryptPGPMessage(encryptedData []byte, account *config.Account) ([]byte, error) {
1693	if account.PGPPrivateKey == "" {
1694		return nil, errors.New("PGP private key not configured")
1695	}
1696
1697	// Load private key
1698	keyFile, err := os.ReadFile(account.PGPPrivateKey)
1699	if err != nil {
1700		return nil, fmt.Errorf("failed to read PGP private key: %w", err)
1701	}
1702
1703	// Try armored format first
1704	entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
1705	if err != nil {
1706		// Try binary format
1707		entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
1708		if err != nil {
1709			return nil, fmt.Errorf("failed to parse PGP private key: %w", err)
1710		}
1711	}
1712
1713	if len(entityList) == 0 {
1714		return nil, errors.New("no PGP keys found in private keyring")
1715	}
1716
1717	// Decrypt using go-pgpmail
1718	mr, err := pgpmail.Read(bytes.NewReader(encryptedData), openpgp.EntityList{entityList[0]}, nil, nil)
1719	if err != nil {
1720		return nil, fmt.Errorf("failed to decrypt PGP message: %w", err)
1721	}
1722
1723	// Read decrypted content from UnverifiedBody
1724	if mr.MessageDetails == nil || mr.MessageDetails.UnverifiedBody == nil {
1725		return nil, errors.New("no decrypted content available")
1726	}
1727
1728	var decrypted bytes.Buffer
1729	if _, err := io.Copy(&decrypted, mr.MessageDetails.UnverifiedBody); err != nil {
1730		return nil, fmt.Errorf("failed to read decrypted content: %w", err)
1731	}
1732
1733	return decrypted.Bytes(), nil
1734}
1735
1736// loadPGPKeyring builds an openpgp.EntityList from the account's public key
1737// and any keys stored in the pgp/ config directory.
1738func loadPGPKeyring(account *config.Account) openpgp.EntityList {
1739	var keyring openpgp.EntityList
1740
1741	readKeys := func(path string) {
1742		data, err := os.ReadFile(path)
1743		if err != nil {
1744			return
1745		}
1746		entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
1747		if err != nil {
1748			entities, err = openpgp.ReadKeyRing(bytes.NewReader(data))
1749			if err != nil {
1750				return
1751			}
1752		}
1753		keyring = append(keyring, entities...)
1754	}
1755
1756	// Load account's own public key
1757	if account.PGPPublicKey != "" {
1758		readKeys(account.PGPPublicKey)
1759	}
1760
1761	// Load all keys from the pgp/ config directory
1762	cfgDir, err := config.GetConfigDir()
1763	if err == nil {
1764		pgpDir := cfgDir + "/pgp"
1765		entries, err := os.ReadDir(pgpDir)
1766		if err == nil {
1767			for _, entry := range entries {
1768				if entry.IsDir() {
1769					continue
1770				}
1771				name := entry.Name()
1772				if strings.HasSuffix(name, ".asc") || strings.HasSuffix(name, ".gpg") {
1773					readKeys(pgpDir + "/" + name)
1774				}
1775			}
1776		}
1777	}
1778
1779	return keyring
1780}
1781
1782// verifyPGPSignature verifies a PGP detached signature against signed content.
1783func verifyPGPSignature(signedContent, signatureData []byte, account *config.Account) bool {
1784	keyring := loadPGPKeyring(account)
1785	if len(keyring) == 0 {
1786		return false
1787	}
1788
1789	// Build a complete multipart/signed message for go-pgpmail
1790	boundary := "pgp-verify-boundary"
1791	var msg bytes.Buffer
1792	msg.WriteString("Content-Type: multipart/signed; boundary=\"" + boundary + "\"; micalg=pgp-sha256; protocol=\"application/pgp-signature\"\r\n\r\n")
1793	msg.WriteString("--" + boundary + "\r\n")
1794	msg.Write(signedContent)
1795	msg.WriteString("\r\n--" + boundary + "\r\n")
1796	msg.WriteString("Content-Type: application/pgp-signature\r\n\r\n")
1797	msg.Write(signatureData)
1798	msg.WriteString("\r\n--" + boundary + "--\r\n")
1799
1800	mr, err := pgpmail.Read(&msg, keyring, nil, nil)
1801	if err != nil {
1802		return false
1803	}
1804
1805	if mr.MessageDetails == nil {
1806		return false
1807	}
1808
1809	// Must read UnverifiedBody to EOF to trigger signature verification
1810	_, _ = io.ReadAll(mr.MessageDetails.UnverifiedBody)
1811
1812	return mr.MessageDetails.SignatureError == nil
1813}