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