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