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