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