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		chunkSize := limit
 525
 526		from := uint32(1)
 527		if cursor > chunkSize {
 528			from = cursor - chunkSize + 1
 529		}
 530
 531		var seqset imap.SeqSet
 532		seqset.AddRange(from, cursor)
 533
 534		fetchCmd := c.Fetch(seqset, &imap.FetchOptions{
 535			Envelope:    true,
 536			UID:         true,
 537			Flags:       true,
 538			BodySection: []*imap.FetchItemBodySection{deliveryHeaderSection},
 539		})
 540
 541		batchMsgs, err := fetchCmd.Collect()
 542		if err != nil {
 543			return nil, err
 544		}
 545
 546		// Filter messages in this batch
 547		var batchEmails []Email
 548		for _, msg := range batchMsgs {
 549			if msg.Envelope == nil {
 550				continue
 551			}
 552
 553			var fromAddr string
 554			if len(msg.Envelope.From) > 0 {
 555				fromAddr = formatAddress(msg.Envelope.From[0])
 556			}
 557
 558			var toAddrList []string
 559			for _, addr := range msg.Envelope.To {
 560				toAddrList = append(toAddrList, addr.Addr())
 561			}
 562			for _, addr := range msg.Envelope.Cc {
 563				toAddrList = append(toAddrList, addr.Addr())
 564			}
 565
 566			var replyToAddrList []string
 567			for _, addr := range msg.Envelope.ReplyTo {
 568				replyToAddrList = append(replyToAddrList, addr.Addr())
 569			}
 570
 571			matched := false
 572			switch {
 573			case account.CatchAll:
 574				matched = true
 575			case isSentMailbox:
 576				var senderEmail string
 577				if len(msg.Envelope.From) > 0 {
 578					senderEmail = msg.Envelope.From[0].Addr()
 579				}
 580				if addressMatches(senderEmail, fetchEmail, account) {
 581					matched = true
 582				}
 583			default:
 584				for _, r := range toAddrList {
 585					if addressMatches(r, fetchEmail, account) {
 586						matched = true
 587						break
 588					}
 589				}
 590				// Check delivery headers for auto-forwarded emails
 591				if !matched {
 592					headerData := msg.FindBodySection(deliveryHeaderSection)
 593					matched = deliveryHeadersMatch(headerData, fetchEmail, account)
 594				}
 595			}
 596
 597			if !matched {
 598				continue
 599			}
 600
 601			headerData := msg.FindBodySection(deliveryHeaderSection)
 602			batchEmails = append(batchEmails, Email{
 603				UID:        uint32(msg.UID),
 604				From:       fromAddr,
 605				To:         toAddrList,
 606				ReplyTo:    replyToAddrList,
 607				Subject:    decodeHeader(msg.Envelope.Subject),
 608				Date:       msg.Envelope.Date,
 609				IsRead:     hasSeenFlag(msg.Flags),
 610				MessageID:  msg.Envelope.MessageID,
 611				InReplyTo:  firstEnvelopeInReplyTo(msg.Envelope.InReplyTo),
 612				References: headerMessageIDs(headerData, "References"),
 613				AccountID:  account.ID,
 614			})
 615		}
 616
 617		// Sort batch Newest -> Oldest by UID desc
 618		sort.Slice(batchEmails, func(i, j int) bool {
 619			return batchEmails[i].UID > batchEmails[j].UID
 620		})
 621
 622		allEmails = append(allEmails, batchEmails...)
 623		cursor = from - 1
 624	}
 625
 626	// Trim if we have too many
 627	if len(allEmails) > int(limit) {
 628		allEmails = allEmails[:limit]
 629	}
 630
 631	return allEmails, nil
 632}
 633
 634// FetchEmailBodyFromMailbox returns the chosen body, its MIME type
 635// (mimeTextHTML or mimeTextPlain; empty if it could not be resolved), the
 636// parsed attachments, and any error. The MIME type lets the renderer
 637// skip the markdown→HTML pre-pass for already-HTML bodies.
 638func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, string, []Attachment, error) { //nolint:gocyclo
 639	c, err := connect(account)
 640	if err != nil {
 641		return "", "", nil, err
 642	}
 643	defer c.Close() //nolint:errcheck
 644
 645	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
 646		return "", "", nil, err
 647	}
 648
 649	uidSet := imap.UIDSetNum(imap.UID(uid))
 650
 651	fetchWholeMessage := func() ([]byte, error) {
 652		wholeSection := &imap.FetchItemBodySection{Peek: true}
 653		fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
 654			BodySection: []*imap.FetchItemBodySection{wholeSection},
 655		})
 656		msgs, err := fetchCmd.Collect()
 657		if err != nil {
 658			return nil, err
 659		}
 660		if len(msgs) > 0 {
 661			if data := msgs[0].FindBodySection(wholeSection); data != nil {
 662				return data, nil
 663			}
 664		}
 665		return nil, fmt.Errorf("could not fetch whole message")
 666	}
 667
 668	fetchInlinePart := func(partID, encoding string) ([]byte, error) {
 669		part := parsePartID(partID)
 670		section := &imap.FetchItemBodySection{
 671			Part: part,
 672			Peek: true,
 673		}
 674
 675		fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
 676			BodySection: []*imap.FetchItemBodySection{section},
 677		})
 678		msgs, err := fetchCmd.Collect()
 679		if err != nil {
 680			return nil, err
 681		}
 682
 683		if len(msgs) == 0 {
 684			return nil, fmt.Errorf("could not fetch inline part %s", partID)
 685		}
 686
 687		rawBytes := msgs[0].FindBodySection(section)
 688		if rawBytes == nil {
 689			return nil, fmt.Errorf("could not get inline part body %s", partID)
 690		}
 691
 692		return decodeAttachmentData(rawBytes, encoding)
 693	}
 694
 695	fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
 696		BodyStructure: &imap.FetchItemBodyStructure{Extended: true},
 697	})
 698	bsMsgs, err := fetchCmd.Collect()
 699	if err != nil {
 700		return "", "", nil, err
 701	}
 702
 703	if len(bsMsgs) == 0 || bsMsgs[0].BodyStructure == nil {
 704		return "", "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
 705	}
 706
 707	msg := bsMsgs[0]
 708
 709	var plainPartID, plainPartEncoding string
 710	var htmlPartID, htmlPartEncoding string
 711	var attachments []Attachment
 712	var extractedBody string // Used if we intercept and decrypt a payload
 713	// MIME type of extractedBody. Set alongside every assignment to extractedBody
 714	// so the renderer can skip the markdown→HTML pre-pass for HTML payloads while
 715	// still letting markdown error messages render formatted.
 716	var extractedBodyMIMEType string
 717
 718	var checkPart func(part *imap.BodyStructureSinglePart, partID string) //nolint:staticcheck
 719	checkPart = func(part *imap.BodyStructureSinglePart, partID string) {
 720		// Check for text content (prefer html over plain)
 721		if strings.EqualFold(part.Type, "text") {
 722			sub := strings.ToLower(part.Subtype)
 723			switch sub {
 724			case "html":
 725				if htmlPartID == "" {
 726					htmlPartID = partID
 727					htmlPartEncoding = part.Encoding
 728				}
 729			case "plain":
 730				if plainPartID == "" {
 731					plainPartID = partID
 732					plainPartEncoding = part.Encoding
 733				}
 734			}
 735		}
 736
 737		// Check for attachments using multiple methods
 738		filename := part.Filename()
 739		// Fallback: check Params (for name parameter)
 740		if filename == "" {
 741			if fn, ok := part.Params["name"]; ok && fn != "" {
 742				filename = fn
 743			}
 744		}
 745		// Fallback: check Params for filename
 746		if filename == "" {
 747			if fn, ok := part.Params["filename"]; ok && fn != "" {
 748				filename = fn
 749			}
 750		}
 751
 752		// Add as attachment if it has a disposition or a filename (and not just plain text).
 753		// Allow inline parts without filenames (common for cid images).
 754		contentID := strings.Trim(part.ID, "<>")
 755		mimeType := part.MediaType()
 756		dispValue := ""
 757		dispParams := map[string]string{}
 758		if part.Disposition() != nil {
 759			dispValue = part.Disposition().Value
 760			dispParams = part.Disposition().Params
 761		}
 762		_ = dispParams // used below in attachment fallback checks
 763		isCID := contentID != ""
 764		isInline := strings.EqualFold(dispValue, "inline") || isCID
 765
 766		if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
 767			filename = "inline"
 768		}
 769
 770		// === S/MIME ENCRYPTION AND OPAQUE VERIFICATION ===
 771		if filename == "smime.p7m" || mimeType == "application/pkcs7-mime" {
 772			data, err := fetchInlinePart(partID, part.Encoding)
 773			if err != nil && partID == "1" {
 774				// Fallback for single-part messages where PEEK[1] fails
 775				data, err = fetchInlinePart("TEXT", part.Encoding)
 776			}
 777
 778			if err != nil {
 779				extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to fetch encrypted part from IMAP server: %v\n", err)
 780				extractedBodyMIMEType = mimeTextPlain
 781				htmlPartID = partExtracted
 782			} else {
 783				p7, parseErr := pkcs7.Parse(data)
 784				if parseErr != nil {
 785					// Fallback: IMAP servers sometimes drop the transfer-encoding header.
 786					// We manually strip newlines and attempt a base64 decode just in case.
 787					cleanData := bytes.ReplaceAll(data, []byte("\n"), []byte(""))
 788					cleanData = bytes.ReplaceAll(cleanData, []byte("\r"), []byte(""))
 789					if decoded, b64err := base64.StdEncoding.DecodeString(string(cleanData)); b64err == nil {
 790						p7, parseErr = pkcs7.Parse(decoded)
 791					}
 792				}
 793
 794				if parseErr != nil {
 795					extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to parse PKCS7 payload: %v\n", parseErr)
 796					extractedBodyMIMEType = mimeTextPlain
 797					htmlPartID = partExtracted
 798				} else {
 799					var innerBytes []byte
 800					isEncrypted, isOpaqueSigned, smimeTrusted := false, false, false
 801					decryptionErr := ""
 802
 803					// 1. Try to Decrypt
 804					if account.SMIMECert != "" && account.SMIMEKey != "" {
 805						cData, err1 := os.ReadFile(account.SMIMECert)
 806						kData, err2 := os.ReadFile(account.SMIMEKey)
 807						if err1 != nil || err2 != nil {
 808							decryptionErr = fmt.Sprintf("Failed to read cert/key files. Cert: %v, Key: %v", err1, err2)
 809						} else {
 810							cBlock, _ := pem.Decode(cData)
 811							kBlock, _ := pem.Decode(kData)
 812							if cBlock == nil || kBlock == nil {
 813								decryptionErr = "Failed to decode PEM blocks from cert/key files."
 814							} else {
 815								cert, err3 := x509.ParseCertificate(cBlock.Bytes)
 816								var privKey any
 817								var err4 error
 818								if key, err := x509.ParsePKCS8PrivateKey(kBlock.Bytes); err == nil {
 819									privKey = key
 820								} else if key, err := x509.ParsePKCS1PrivateKey(kBlock.Bytes); err == nil {
 821									privKey = key
 822								} else if key, err := x509.ParseECPrivateKey(kBlock.Bytes); err == nil {
 823									privKey = key
 824								} else {
 825									err4 = errors.New("unsupported private key format")
 826								}
 827
 828								if err3 != nil || err4 != nil {
 829									decryptionErr = fmt.Sprintf("Failed to parse cert/key. Cert: %v, Key: %v", err3, err4)
 830								} else {
 831									dec, err := p7.Decrypt(cert, privKey)
 832									if err == nil {
 833										innerBytes = dec
 834										isEncrypted = true
 835									} else {
 836										decryptionErr = fmt.Sprintf("PKCS7 Decrypt failed: %v", err)
 837									}
 838								}
 839							}
 840						}
 841					} else {
 842						// Only set error if it actually is enveloped data (encrypted)
 843						// If it's just opaque signed, we shouldn't error out.
 844						decryptionErr = "S/MIME Cert or Key path is missing in settings."
 845					}
 846
 847					// 2. If not encrypted, check if it's an opaque signature
 848					if !isEncrypted && len(p7.Signers) > 0 {
 849						isOpaqueSigned = true
 850						innerBytes = p7.Content
 851						decryptionErr = "" // Clear encryption error because it wasn't encrypted to begin with
 852						roots, _ := x509.SystemCertPool()
 853						if roots == nil {
 854							roots = x509.NewCertPool()
 855						}
 856						if err := p7.VerifyWithChain(roots); err == nil {
 857							smimeTrusted = true
 858						}
 859					}
 860
 861					// 3. Parse Inner MIME payload
 862					if len(innerBytes) > 0 {
 863						mr, err := mail.CreateReader(bytes.NewReader(innerBytes))
 864						if err == nil {
 865							for {
 866								p, err := mr.NextPart()
 867								if err != nil {
 868									break
 869								}
 870								cType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
 871								disp, dParams, _ := mime.ParseMediaType(p.Header.Get("Content-Disposition"))
 872								b, readErr := io.ReadAll(p.Body) // Auto-decodes quoted-printable/base64
 873								if readErr != nil {
 874									log.Printf("fetcher: reading inner MIME part body: %v", readErr)
 875									continue
 876								}
 877
 878								if disp == "attachment" || disp == "inline" || (!strings.HasPrefix(cType, "multipart/") && cType != mimeTextPlain && cType != mimeTextHTML) {
 879									fn := dParams["filename"]
 880									if fn == "" {
 881										_, cp, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
 882										fn = cp["name"]
 883									}
 884									attachments = append(attachments, Attachment{
 885										Filename: fn, Data: b, MIMEType: cType, Inline: disp == "inline",
 886									})
 887								} else {
 888									if cType == mimeTextHTML {
 889										extractedBody = string(b)
 890										extractedBodyMIMEType = mimeTextHTML
 891										htmlPartID = partExtracted // Skip IMAP fetch
 892									} else if cType == mimeTextPlain && extractedBody == "" {
 893										extractedBody = string(b)
 894										extractedBodyMIMEType = mimeTextPlain
 895										plainPartID = partExtracted
 896									}
 897								}
 898							}
 899						} else {
 900							extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to read inner decrypted MIME: %v\n\n```\n%s\n```", err, string(innerBytes))
 901							extractedBodyMIMEType = mimeTextPlain
 902							htmlPartID = partExtracted
 903						}
 904
 905						attachments = append(attachments, Attachment{
 906							Filename:         "smime-status.internal",
 907							IsSMIMESignature: isOpaqueSigned,
 908							SMIMEVerified:    smimeTrusted,
 909							IsSMIMEEncrypted: isEncrypted,
 910						})
 911						return // Stop checking IMAP structure, we hijacked it
 912					}
 913					extractedBody = fmt.Sprintf("**S/MIME Decryption Failed:** %s\n", decryptionErr)
 914					extractedBodyMIMEType = mimeTextPlain
 915					htmlPartID = partExtracted
 916				}
 917			}
 918		}
 919
 920		// === S/MIME DETACHED SIGNATURE VERIFICATION ===
 921		if filename == "smime.p7s" || mimeType == "application/pkcs7-signature" {
 922			att := Attachment{
 923				Filename:         filename,
 924				PartID:           partID,
 925				Encoding:         part.Encoding,
 926				MIMEType:         mimeType,
 927				ContentID:        contentID,
 928				Inline:           isInline,
 929				IsSMIMESignature: true,
 930			}
 931			if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
 932				att.Data = data
 933				p7, err := pkcs7.Parse(data)
 934				if err == nil {
 935					boundary := getBodyStructureBoundary(msg.BodyStructure)
 936					if boundary != "" {
 937						rawEmail, err := fetchWholeMessage()
 938						if err == nil {
 939							fullBoundary := []byte("--" + boundary)
 940							firstIdx := bytes.Index(rawEmail, fullBoundary)
 941							if firstIdx != -1 {
 942								startIdx := firstIdx + len(fullBoundary)
 943								if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' {
 944									startIdx++
 945								}
 946								if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' {
 947									startIdx++
 948								}
 949								secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary)
 950								if secondIdx != -1 {
 951									endIdx := startIdx + secondIdx
 952									if endIdx > 0 && rawEmail[endIdx-1] == '\n' {
 953										endIdx--
 954									}
 955									if endIdx > 0 && rawEmail[endIdx-1] == '\r' {
 956										endIdx--
 957									}
 958									signedData := rawEmail[startIdx:endIdx]
 959									canonical := bytes.ReplaceAll(signedData, []byte("\r\n"), []byte("\n"))
 960									canonical = bytes.ReplaceAll(canonical, []byte("\n"), []byte("\r\n"))
 961
 962									roots, _ := x509.SystemCertPool()
 963									if roots == nil {
 964										roots = x509.NewCertPool()
 965									}
 966
 967									p7.Content = canonical
 968									if err := p7.VerifyWithChain(roots); err == nil {
 969										att.SMIMEVerified = true
 970									} else {
 971										p7.Content = append(canonical, '\r', '\n') //nolint:gocritic
 972										if err := p7.VerifyWithChain(roots); err == nil {
 973											att.SMIMEVerified = true
 974										} else {
 975											p7.Content = bytes.TrimRight(canonical, "\r\n")
 976											if err := p7.VerifyWithChain(roots); err == nil {
 977												att.SMIMEVerified = true
 978											}
 979										}
 980									}
 981								}
 982							}
 983						}
 984					}
 985				}
 986			}
 987			attachments = append(attachments, att)
 988		}
 989
 990		// === PGP ENCRYPTED MESSAGE DETECTION ===
 991		// PGP encrypted messages have two parts: version info and encrypted data.
 992		// We handle decryption when we find the encrypted data part (application/octet-stream).
 993		// Skip the version info part (application/pgp-encrypted) and continue processing.
 994
 995		// Detect encrypted data part of PGP message
 996		if strings.Contains(filename, ".asc") || (mimeType == "application/octet-stream" && part.Encoding == "7bit") {
 997			// This might be PGP encrypted data
 998			data, err := fetchInlinePart(partID, part.Encoding)
 999			if err == nil && bytes.Contains(data, []byte("-----BEGIN PGP MESSAGE-----")) {
1000				// This is PGP encrypted content
1001				if account.PGPPrivateKey != "" {
1002					decrypted, err := decryptPGPMessage(data, account)
1003					if err == nil {
1004						// Parse the decrypted MIME content
1005						mr, err := mail.CreateReader(bytes.NewReader(decrypted))
1006						if err == nil {
1007							for {
1008								p, err := mr.NextPart()
1009								if errors.Is(err, io.EOF) {
1010									break
1011								}
1012								if err != nil {
1013									break
1014								}
1015
1016								if h, ok := p.Header.(*mail.InlineHeader); ok {
1017									ct, _, _ := h.ContentType()
1018									if strings.HasPrefix(ct, mimeTextHTML) {
1019										body, _ := io.ReadAll(p.Body)
1020										extractedBody = string(body)
1021										extractedBodyMIMEType = mimeTextHTML
1022										htmlPartID = "decrypted"
1023									} else if strings.HasPrefix(ct, mimeTextPlain) && extractedBody == "" {
1024										body, _ := io.ReadAll(p.Body)
1025										extractedBody = string(body)
1026										extractedBodyMIMEType = mimeTextPlain
1027										plainPartID = "decrypted"
1028									}
1029								}
1030							}
1031
1032							// Add status marker
1033							attachments = append(attachments, Attachment{
1034								Filename:       "pgp-status.internal",
1035								IsPGPEncrypted: true,
1036								PGPVerified:    true, // Decryption succeeded
1037							})
1038						}
1039					} else {
1040						extractedBody = fmt.Sprintf("**PGP Decryption Failed:** %s\n", err)
1041						extractedBodyMIMEType = mimeTextPlain
1042						htmlPartID = partExtracted
1043					}
1044				} else {
1045					extractedBody = "**PGP Encrypted:** Private key not configured\n"
1046					extractedBodyMIMEType = mimeTextPlain
1047					htmlPartID = partExtracted
1048				}
1049			}
1050		}
1051
1052		// === PGP DETACHED SIGNATURE VERIFICATION ===
1053		if filename == "signature.asc" || mimeType == "application/pgp-signature" { //nolint:gocritic
1054			att := Attachment{
1055				Filename:       filename,
1056				PartID:         partID,
1057				Encoding:       part.Encoding,
1058				MIMEType:       mimeType,
1059				ContentID:      contentID,
1060				Inline:         isInline,
1061				IsPGPSignature: true,
1062			}
1063
1064			if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
1065				att.Data = data
1066
1067				// Try to verify the signature
1068				boundary := getBodyStructureBoundary(msg.BodyStructure)
1069				if boundary != "" {
1070					rawEmail, err := fetchWholeMessage()
1071					if err == nil {
1072						// Extract signed content (similar to S/MIME)
1073						fullBoundary := []byte("--" + boundary)
1074						firstIdx := bytes.Index(rawEmail, fullBoundary)
1075						if firstIdx != -1 {
1076							startIdx := firstIdx + len(fullBoundary)
1077							if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' {
1078								startIdx++
1079							}
1080							if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' {
1081								startIdx++
1082							}
1083							secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary)
1084							if secondIdx != -1 {
1085								endIdx := startIdx + secondIdx
1086								if endIdx > 0 && rawEmail[endIdx-1] == '\n' {
1087									endIdx--
1088								}
1089								if endIdx > 0 && rawEmail[endIdx-1] == '\r' {
1090									endIdx--
1091								}
1092								signedData := rawEmail[startIdx:endIdx]
1093
1094								// Verify PGP signature
1095								verified := verifyPGPSignature(signedData, data, account)
1096								att.PGPVerified = verified
1097							}
1098						}
1099					}
1100				}
1101			}
1102			attachments = append(attachments, att)
1103		} else if mimeType == "text/calendar" || strings.HasSuffix(strings.ToLower(filename), ".ics") {
1104			// === CALENDAR INVITE DETECTION ===
1105			att := Attachment{
1106				Filename:         filename,
1107				PartID:           partID,
1108				Encoding:         part.Encoding,
1109				MIMEType:         mimeType,
1110				IsCalendarInvite: true,
1111			}
1112
1113			// Fetch and parse calendar data
1114			if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
1115				att.Data = data
1116				// Parse will be done lazily in calendar package when needed
1117			}
1118			attachments = append(attachments, att)
1119		} else if (filename != "" || isCID) && (strings.EqualFold(dispValue, "attachment") || isInline || !strings.EqualFold(part.Type, "text")) {
1120			att := Attachment{
1121				Filename:  filename,
1122				PartID:    partID,
1123				Encoding:  part.Encoding, // Store encoding for proper decoding
1124				MIMEType:  mimeType,
1125				ContentID: contentID,
1126				Inline:    isInline,
1127			}
1128			if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
1129				if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
1130					att.Data = data
1131				}
1132			}
1133			attachments = append(attachments, att)
1134		}
1135	}
1136
1137	// Walk the body structure tree
1138	msg.BodyStructure.Walk(func(path []int, part imap.BodyStructure) bool {
1139		if sp, ok := part.(*imap.BodyStructureSinglePart); ok {
1140			partID := formatPartPath(path)
1141			checkPart(sp, partID)
1142		}
1143		return true
1144	})
1145
1146	// If we hijacked and decrypted the body, return it immediately
1147	if extractedBody != "" {
1148		return extractedBody, extractedBodyMIMEType, attachments, nil
1149	}
1150
1151	var body string
1152	var bodyMIMEType string
1153	textPartID := ""
1154	textPartEncoding := ""
1155	if htmlPartID != "" {
1156		textPartID = htmlPartID
1157		textPartEncoding = htmlPartEncoding
1158		bodyMIMEType = mimeTextHTML
1159	} else if plainPartID != "" {
1160		textPartID = plainPartID
1161		textPartEncoding = plainPartEncoding
1162		bodyMIMEType = mimeTextPlain
1163	}
1164	if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
1165		msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
1166		log.Print(msg)
1167		if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
1168			// Use a closure with defer so a panic between open and
1169			// WriteString doesn't leak the file descriptor (#894).
1170			func() {
1171				f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) //nolint:gosec
1172				if err != nil {
1173					return
1174				}
1175				defer f.Close() //nolint:errcheck
1176				_, _ = f.WriteString(msg)
1177			}()
1178		}
1179	}
1180	if textPartID != "" {
1181		part := parsePartID(textPartID)
1182		section := &imap.FetchItemBodySection{
1183			Part: part,
1184			Peek: true,
1185		}
1186
1187		fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
1188			BodySection: []*imap.FetchItemBodySection{section},
1189		})
1190		msgs, err := fetchCmd.Collect()
1191		if err != nil {
1192			return "", "", nil, err
1193		}
1194
1195		if len(msgs) > 0 {
1196			if buf := msgs[0].FindBodySection(section); buf != nil {
1197				// Use the encoding from BodyStructure to decode
1198				if decoded, err := decodeAttachmentData(buf, textPartEncoding); err == nil {
1199					body = string(decoded)
1200				} else {
1201					body = string(buf)
1202				}
1203			}
1204		}
1205	}
1206
1207	return body, bodyMIMEType, attachments, nil
1208}
1209
1210func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
1211	c, err := connect(account)
1212	if err != nil {
1213		return nil, err
1214	}
1215	defer c.Close() //nolint:errcheck
1216
1217	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1218		return nil, err
1219	}
1220
1221	uidSet := imap.UIDSetNum(imap.UID(uid))
1222	part := parsePartID(partID)
1223	section := &imap.FetchItemBodySection{
1224		Part: part,
1225		Peek: true,
1226	}
1227
1228	fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
1229		BodySection: []*imap.FetchItemBodySection{section},
1230	})
1231	msgs, err := fetchCmd.Collect()
1232	if err != nil {
1233		return nil, err
1234	}
1235
1236	if len(msgs) == 0 {
1237		return nil, fmt.Errorf("could not fetch attachment")
1238	}
1239
1240	rawBytes := msgs[0].FindBodySection(section)
1241	if rawBytes == nil {
1242		return nil, fmt.Errorf("could not get attachment body")
1243	}
1244
1245	decoded, err := decodeAttachmentData(rawBytes, encoding)
1246	if err != nil {
1247		return rawBytes, nil
1248	}
1249	return decoded, nil
1250}
1251
1252func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
1253	c, err := connect(account)
1254	if err != nil {
1255		return err
1256	}
1257	defer c.Close() //nolint:errcheck
1258
1259	if _, err := c.Select(sourceMailbox, nil).Wait(); err != nil {
1260		return err
1261	}
1262
1263	uidSet := imap.UIDSetNum(imap.UID(uid))
1264	_, err = c.Move(uidSet, destMailbox).Wait()
1265	return err
1266}
1267
1268func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint32) error {
1269	c, err := connect(account)
1270	if err != nil {
1271		return err
1272	}
1273	defer c.Close() //nolint:errcheck
1274
1275	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1276		return err
1277	}
1278
1279	uidSet := imap.UIDSetNum(imap.UID(uid))
1280	return c.Store(uidSet, &imap.StoreFlags{
1281		Op:     imap.StoreFlagsAdd,
1282		Silent: true,
1283		Flags:  []imap.Flag{imap.FlagSeen},
1284	}, nil).Close()
1285}
1286
1287func MarkEmailAsUnreadInMailbox(account *config.Account, mailbox string, uid uint32) error {
1288	c, err := connect(account)
1289	if err != nil {
1290		return err
1291	}
1292	defer c.Close() //nolint:errcheck
1293
1294	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1295		return err
1296	}
1297
1298	uidSet := imap.UIDSetNum(imap.UID(uid))
1299	return c.Store(uidSet, &imap.StoreFlags{
1300		Op:     imap.StoreFlagsDel,
1301		Silent: true,
1302		Flags:  []imap.Flag{imap.FlagSeen},
1303	}, nil).Close()
1304}
1305
1306func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
1307	c, err := connect(account)
1308	if err != nil {
1309		return err
1310	}
1311	defer c.Close() //nolint:errcheck
1312
1313	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1314		return err
1315	}
1316
1317	uidSet := imap.UIDSetNum(imap.UID(uid))
1318	if err := c.Store(uidSet, &imap.StoreFlags{
1319		Op:     imap.StoreFlagsAdd,
1320		Silent: true,
1321		Flags:  []imap.Flag{imap.FlagDeleted},
1322	}, nil).Close(); err != nil {
1323		return err
1324	}
1325
1326	return c.Expunge().Close()
1327}
1328
1329func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
1330	c, err := connect(account)
1331	if err != nil {
1332		return err
1333	}
1334	defer c.Close() //nolint:errcheck
1335
1336	var archiveMailbox string
1337	switch account.ServiceProvider {
1338	case config.ProviderGmail:
1339		// For Gmail, find the mailbox with the \All attribute
1340		archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll)
1341		if err != nil {
1342			// Fallback to hardcoded path if attribute lookup fails
1343			archiveMailbox = "[Gmail]/All Mail"
1344		}
1345	default:
1346		archiveMailbox = "Archive"
1347	}
1348
1349	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1350		return err
1351	}
1352
1353	uidSet := imap.UIDSetNum(imap.UID(uid))
1354	_, err = c.Move(uidSet, archiveMailbox).Wait()
1355	return err
1356}
1357
1358// Batch operations for multiple emails
1359
1360// DeleteEmailsFromMailbox deletes multiple emails from a mailbox (batch operation)
1361func DeleteEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
1362	if len(uids) == 0 {
1363		return nil
1364	}
1365
1366	c, err := connect(account)
1367	if err != nil {
1368		return err
1369	}
1370	defer c.Close() //nolint:errcheck
1371
1372	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1373		return err
1374	}
1375
1376	uidSet := uidsToUIDSet(uids)
1377	if err := c.Store(uidSet, &imap.StoreFlags{
1378		Op:     imap.StoreFlagsAdd,
1379		Silent: true,
1380		Flags:  []imap.Flag{imap.FlagDeleted},
1381	}, nil).Close(); err != nil {
1382		return err
1383	}
1384
1385	return c.Expunge().Close()
1386}
1387
1388// ArchiveEmailsFromMailbox archives multiple emails from a mailbox (batch operation)
1389func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
1390	if len(uids) == 0 {
1391		return nil
1392	}
1393
1394	c, err := connect(account)
1395	if err != nil {
1396		return err
1397	}
1398	defer c.Close() //nolint:errcheck
1399
1400	var archiveMailbox string
1401	switch account.ServiceProvider {
1402	case config.ProviderGmail:
1403		archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll)
1404		if err != nil {
1405			archiveMailbox = "[Gmail]/All Mail"
1406		}
1407	default:
1408		archiveMailbox = "Archive"
1409	}
1410
1411	if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1412		return err
1413	}
1414
1415	uidSet := uidsToUIDSet(uids)
1416	_, err = c.Move(uidSet, archiveMailbox).Wait()
1417	return err
1418}
1419
1420// MoveEmailsToFolder moves multiple emails to a different folder (batch operation)
1421func MoveEmailsToFolder(account *config.Account, uids []uint32, sourceFolder, destFolder string) error {
1422	if len(uids) == 0 {
1423		return nil
1424	}
1425
1426	c, err := connect(account)
1427	if err != nil {
1428		return err
1429	}
1430	defer c.Close() //nolint:errcheck
1431
1432	if _, err := c.Select(sourceFolder, nil).Wait(); err != nil {
1433		return err
1434	}
1435
1436	uidSet := uidsToUIDSet(uids)
1437	_, err = c.Move(uidSet, destFolder).Wait()
1438	return err
1439}
1440
1441// Convenience wrappers defaulting to INBOX for existing call sites.
1442
1443func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1444	return FetchMailboxEmails(account, "INBOX", limit, offset)
1445}
1446
1447func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1448	return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
1449}
1450
1451func FetchEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) {
1452	return FetchEmailBodyFromMailbox(account, "INBOX", uid)
1453}
1454
1455func FetchSentEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) {
1456	return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
1457}
1458
1459func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1460	return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
1461}
1462
1463func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1464	return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
1465}
1466
1467func DeleteEmail(account *config.Account, uid uint32) error {
1468	return DeleteEmailFromMailbox(account, "INBOX", uid)
1469}
1470
1471func DeleteSentEmail(account *config.Account, uid uint32) error {
1472	return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
1473}
1474
1475func ArchiveEmail(account *config.Account, uid uint32) error {
1476	return ArchiveEmailFromMailbox(account, "INBOX", uid)
1477}
1478
1479func ArchiveSentEmail(account *config.Account, uid uint32) error {
1480	return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
1481}
1482
1483// AppendToSentMailbox appends a raw RFC822 message to the Sent mailbox via IMAP APPEND.
1484func AppendToSentMailbox(account *config.Account, rawMsg []byte) error {
1485	c, err := connect(account)
1486	if err != nil {
1487		return err
1488	}
1489	defer c.Close() //nolint:errcheck
1490
1491	sentMailbox := getSentMailbox(account)
1492	appendCmd := c.Append(sentMailbox, int64(len(rawMsg)), &imap.AppendOptions{
1493		Flags: []imap.Flag{imap.FlagSeen},
1494		Time:  time.Now(),
1495	})
1496	if _, err := appendCmd.Write(rawMsg); err != nil {
1497		return err
1498	}
1499	if err := appendCmd.Close(); err != nil {
1500		return err
1501	}
1502	_, err = appendCmd.Wait()
1503	return err
1504}
1505
1506// getTrashMailbox returns the trash mailbox name for the account
1507func getTrashMailbox(account *config.Account) string {
1508	switch account.ServiceProvider {
1509	case config.ProviderGmail:
1510		return "[Gmail]/Trash"
1511	case "outlook":
1512		return "Deleted Items"
1513	case "icloud":
1514		return "Deleted Messages"
1515	default:
1516		return "Trash"
1517	}
1518}
1519
1520// getArchiveMailbox returns the archive/all mail mailbox name for the account
1521func getArchiveMailbox(account *config.Account) string {
1522	switch account.ServiceProvider {
1523	case config.ProviderGmail:
1524		return "[Gmail]/All Mail"
1525	case "outlook", "icloud":
1526		return "Archive"
1527	default:
1528		return "Archive"
1529	}
1530}
1531
1532// FetchTrashEmails fetches emails from the trash folder
1533func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1534	c, err := connect(account)
1535	if err != nil {
1536		return nil, err
1537	}
1538	defer c.Close() //nolint:errcheck
1539
1540	// Try to find trash by attribute first
1541	trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1542	if err != nil {
1543		// Fallback to hardcoded path
1544		trashMailbox = getTrashMailbox(account)
1545	}
1546
1547	return FetchMailboxEmails(account, trashMailbox, limit, offset)
1548}
1549
1550// FetchArchiveEmails fetches emails from the archive/all mail folder
1551// Archive contains all emails, so we match where user is sender OR recipient
1552func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1553	c, err := connect(account)
1554	if err != nil {
1555		return nil, err
1556	}
1557	defer c.Close() //nolint:errcheck
1558
1559	// Try to find archive by attribute first (Gmail uses \All)
1560	archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1561	if err != nil {
1562		// Fallback to hardcoded path
1563		archiveMailbox = getArchiveMailbox(account)
1564	}
1565
1566	selectData, err := c.Select(archiveMailbox, nil).Wait()
1567	if err != nil {
1568		return nil, err
1569	}
1570
1571	if selectData.NumMessages == 0 {
1572		return []Email{}, nil
1573	}
1574
1575	to := selectData.NumMessages - offset
1576	from := uint32(1)
1577	if to > limit {
1578		from = to - limit + 1
1579	}
1580
1581	if to < 1 {
1582		return []Email{}, nil
1583	}
1584
1585	var seqset imap.SeqSet
1586	seqset.AddRange(from, to)
1587
1588	// Delivery header section for matching auto-forwarded emails
1589	deliveryHeaderSection := &imap.FetchItemBodySection{
1590		Specifier:    imap.PartSpecifierHeader,
1591		HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To", "References"},
1592		Peek:         true,
1593	}
1594
1595	fetchCmd := c.Fetch(seqset, &imap.FetchOptions{
1596		Envelope:    true,
1597		UID:         true,
1598		Flags:       true,
1599		BodySection: []*imap.FetchItemBodySection{deliveryHeaderSection},
1600	})
1601	msgs, err := fetchCmd.Collect()
1602	if err != nil {
1603		return nil, err
1604	}
1605
1606	// Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
1607	fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
1608	if fetchEmail == "" {
1609		fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
1610	}
1611
1612	var emails []Email
1613	for _, msg := range msgs {
1614		if msg.Envelope == nil {
1615			continue
1616		}
1617
1618		var fromAddr string
1619		if len(msg.Envelope.From) > 0 {
1620			fromAddr = formatAddress(msg.Envelope.From[0])
1621		}
1622
1623		var toAddrList []string
1624		for _, addr := range msg.Envelope.To {
1625			toAddrList = append(toAddrList, addr.Addr())
1626		}
1627		for _, addr := range msg.Envelope.Cc {
1628			toAddrList = append(toAddrList, addr.Addr())
1629		}
1630
1631		// For archive/All Mail, match emails where user is sender OR recipient
1632		matched := false
1633		if account.CatchAll {
1634			matched = true
1635		} else {
1636			// Check if user is the sender
1637			if addressMatches(fromAddr, fetchEmail, account) {
1638				matched = true
1639			}
1640			// Check if user is a recipient
1641			if !matched {
1642				for _, r := range toAddrList {
1643					if addressMatches(r, fetchEmail, account) {
1644						matched = true
1645						break
1646					}
1647				}
1648			}
1649			// Check delivery headers for auto-forwarded emails
1650			if !matched {
1651				headerData := msg.FindBodySection(deliveryHeaderSection)
1652				matched = deliveryHeadersMatch(headerData, fetchEmail, account)
1653			}
1654		}
1655
1656		if !matched {
1657			continue
1658		}
1659
1660		headerData := msg.FindBodySection(deliveryHeaderSection)
1661		emails = append(emails, Email{
1662			UID:        uint32(msg.UID),
1663			From:       fromAddr,
1664			To:         toAddrList,
1665			Subject:    decodeHeader(msg.Envelope.Subject),
1666			Date:       msg.Envelope.Date,
1667			IsRead:     hasSeenFlag(msg.Flags),
1668			MessageID:  msg.Envelope.MessageID,
1669			InReplyTo:  firstEnvelopeInReplyTo(msg.Envelope.InReplyTo),
1670			References: headerMessageIDs(headerData, "References"),
1671			AccountID:  account.ID,
1672		})
1673	}
1674
1675	// Reverse to get newest first
1676	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
1677		emails[i], emails[j] = emails[j], emails[i]
1678	}
1679
1680	return emails, nil
1681}
1682
1683// FetchTrashEmailBody fetches the body of an email from trash
1684func FetchTrashEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) {
1685	c, err := connect(account)
1686	if err != nil {
1687		return "", "", nil, err
1688	}
1689	defer c.Close() //nolint:errcheck
1690
1691	trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1692	if err != nil {
1693		trashMailbox = getTrashMailbox(account)
1694	}
1695
1696	return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
1697}
1698
1699// FetchArchiveEmailBody fetches the body of an email from archive
1700func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) {
1701	c, err := connect(account)
1702	if err != nil {
1703		return "", "", nil, err
1704	}
1705	defer c.Close() //nolint:errcheck
1706
1707	archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1708	if err != nil {
1709		archiveMailbox = getArchiveMailbox(account)
1710	}
1711
1712	return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
1713}
1714
1715// FetchTrashAttachment fetches an attachment from trash
1716func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1717	c, err := connect(account)
1718	if err != nil {
1719		return nil, err
1720	}
1721	defer c.Close() //nolint:errcheck
1722
1723	trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1724	if err != nil {
1725		trashMailbox = getTrashMailbox(account)
1726	}
1727
1728	return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
1729}
1730
1731// FetchArchiveAttachment fetches an attachment from archive
1732func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1733	c, err := connect(account)
1734	if err != nil {
1735		return nil, err
1736	}
1737	defer c.Close() //nolint:errcheck
1738
1739	archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1740	if err != nil {
1741		archiveMailbox = getArchiveMailbox(account)
1742	}
1743
1744	return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
1745}
1746
1747// DeleteTrashEmail permanently deletes an email from trash
1748func DeleteTrashEmail(account *config.Account, uid uint32) error {
1749	c, err := connect(account)
1750	if err != nil {
1751		return err
1752	}
1753	defer c.Close() //nolint:errcheck
1754
1755	trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1756	if err != nil {
1757		trashMailbox = getTrashMailbox(account)
1758	}
1759
1760	return DeleteEmailFromMailbox(account, trashMailbox, uid)
1761}
1762
1763// DeleteArchiveEmail deletes an email from archive (moves to trash)
1764func DeleteArchiveEmail(account *config.Account, uid uint32) error {
1765	c, err := connect(account)
1766	if err != nil {
1767		return err
1768	}
1769	defer c.Close() //nolint:errcheck
1770
1771	archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1772	if err != nil {
1773		archiveMailbox = getArchiveMailbox(account)
1774	}
1775
1776	return DeleteEmailFromMailbox(account, archiveMailbox, uid)
1777}
1778
1779// FetchFolders lists all IMAP folders/mailboxes for an account.
1780func FetchFolders(account *config.Account) ([]Folder, error) {
1781	c, err := connect(account)
1782	if err != nil {
1783		return nil, err
1784	}
1785	defer c.Close() //nolint:errcheck
1786
1787	listCmd := c.List("", "*", &imap.ListOptions{
1788		ReturnStatus: &imap.StatusOptions{
1789			NumUnseen: true,
1790		},
1791	})
1792	defer listCmd.Close() //nolint:errcheck
1793
1794	var folders []Folder
1795	for {
1796		data := listCmd.Next()
1797		if data == nil {
1798			break
1799		}
1800		delim := ""
1801		if data.Delim != 0 {
1802			delim = string(data.Delim)
1803		}
1804
1805		var unread uint32
1806		if data.Status != nil {
1807			unread = *data.Status.NumUnseen
1808		}
1809
1810		var attrs []string
1811		for _, a := range data.Attrs {
1812			attrs = append(attrs, string(a))
1813		}
1814		folders = append(folders, Folder{
1815			Name:       data.Mailbox,
1816			Delimiter:  delim,
1817			Unread:     unread,
1818			Attributes: attrs,
1819		})
1820	}
1821
1822	if err := listCmd.Close(); err != nil {
1823		return nil, err
1824	}
1825
1826	return folders, nil
1827}
1828
1829// MoveEmailToFolder moves an email from one folder to another via IMAP.
1830func MoveEmailToFolder(account *config.Account, uid uint32, sourceFolder, destFolder string) error {
1831	return moveEmail(account, uid, sourceFolder, destFolder)
1832}
1833
1834// FetchFolderEmails fetches emails from an arbitrary folder.
1835func FetchFolderEmails(account *config.Account, folder string, limit, offset uint32) ([]Email, error) {
1836	return FetchMailboxEmails(account, folder, limit, offset)
1837}
1838
1839// FetchFolderEmailBody fetches the body of an email from an arbitrary folder.
1840func FetchFolderEmailBody(account *config.Account, folder string, uid uint32) (string, string, []Attachment, error) {
1841	return FetchEmailBodyFromMailbox(account, folder, uid)
1842}
1843
1844// FetchFolderAttachment fetches an attachment from an arbitrary folder.
1845func FetchFolderAttachment(account *config.Account, folder string, uid uint32, partID string, encoding string) ([]byte, error) {
1846	return FetchAttachmentFromMailbox(account, folder, uid, partID, encoding)
1847}
1848
1849// DeleteFolderEmail deletes an email from an arbitrary folder.
1850func DeleteFolderEmail(account *config.Account, folder string, uid uint32) error {
1851	return DeleteEmailFromMailbox(account, folder, uid)
1852}
1853
1854// ArchiveFolderEmail archives an email from an arbitrary folder.
1855func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) error {
1856	return ArchiveEmailFromMailbox(account, folder, uid)
1857}
1858
1859// decryptPGPMessage decrypts a PGP-encrypted message using the account's private key.
1860func decryptPGPMessage(encryptedData []byte, account *config.Account) ([]byte, error) {
1861	if account.PGPPrivateKey == "" {
1862		return nil, errors.New("PGP private key not configured")
1863	}
1864
1865	// Load private key
1866	keyFile, err := os.ReadFile(account.PGPPrivateKey)
1867	if err != nil {
1868		return nil, fmt.Errorf("failed to read PGP private key: %w", err)
1869	}
1870
1871	// Try armored format first
1872	entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
1873	if err != nil {
1874		// Try binary format
1875		entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
1876		if err != nil {
1877			return nil, fmt.Errorf("failed to parse PGP private key: %w", err)
1878		}
1879	}
1880
1881	if len(entityList) == 0 {
1882		return nil, errors.New("no PGP keys found in private keyring")
1883	}
1884
1885	// Decrypt using go-pgpmail
1886	mr, err := pgpmail.Read(bytes.NewReader(encryptedData), openpgp.EntityList{entityList[0]}, nil, nil)
1887	if err != nil {
1888		return nil, fmt.Errorf("failed to decrypt PGP message: %w", err)
1889	}
1890
1891	// Read decrypted content from UnverifiedBody
1892	if mr.MessageDetails == nil || mr.MessageDetails.UnverifiedBody == nil {
1893		return nil, errors.New("no decrypted content available")
1894	}
1895
1896	var decrypted bytes.Buffer
1897	if _, err := io.Copy(&decrypted, mr.MessageDetails.UnverifiedBody); err != nil {
1898		return nil, fmt.Errorf("failed to read decrypted content: %w", err)
1899	}
1900
1901	return decrypted.Bytes(), nil
1902}
1903
1904// loadPGPKeyring builds an openpgp.EntityList from the account's public key
1905// and any keys stored in the pgp/ config directory.
1906func loadPGPKeyring(account *config.Account) openpgp.EntityList {
1907	var keyring openpgp.EntityList
1908
1909	readKeys := func(path string) {
1910		data, err := os.ReadFile(path)
1911		if err != nil {
1912			return
1913		}
1914		entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
1915		if err != nil {
1916			entities, err = openpgp.ReadKeyRing(bytes.NewReader(data))
1917			if err != nil {
1918				return
1919			}
1920		}
1921		keyring = append(keyring, entities...)
1922	}
1923
1924	// Load account's own public key
1925	if account.PGPPublicKey != "" {
1926		readKeys(account.PGPPublicKey)
1927	}
1928
1929	// Load all keys from the pgp/ config directory
1930	cfgDir, err := config.GetConfigDir()
1931	if err == nil {
1932		pgpDir := cfgDir + "/pgp"
1933		entries, err := os.ReadDir(pgpDir)
1934		if err == nil {
1935			for _, entry := range entries {
1936				if entry.IsDir() {
1937					continue
1938				}
1939				name := entry.Name()
1940				if strings.HasSuffix(name, ".asc") || strings.HasSuffix(name, ".gpg") {
1941					readKeys(pgpDir + "/" + name)
1942				}
1943			}
1944		}
1945	}
1946
1947	return keyring
1948}
1949
1950// verifyPGPSignature verifies a PGP detached signature against signed content.
1951func verifyPGPSignature(signedContent, signatureData []byte, account *config.Account) bool {
1952	keyring := loadPGPKeyring(account)
1953	if len(keyring) == 0 {
1954		return false
1955	}
1956
1957	// Build a complete multipart/signed message for go-pgpmail
1958	boundary := "pgp-verify-boundary"
1959	var msg bytes.Buffer
1960	msg.WriteString("Content-Type: multipart/signed; boundary=\"" + boundary + "\"; micalg=pgp-sha256; protocol=\"application/pgp-signature\"\r\n\r\n")
1961	msg.WriteString("--" + boundary + "\r\n")
1962	msg.Write(signedContent)
1963	msg.WriteString("\r\n--" + boundary + "\r\n")
1964	msg.WriteString("Content-Type: application/pgp-signature\r\n\r\n")
1965	msg.Write(signatureData)
1966	msg.WriteString("\r\n--" + boundary + "--\r\n")
1967
1968	mr, err := pgpmail.Read(&msg, keyring, nil, nil)
1969	if err != nil {
1970		return false
1971	}
1972
1973	if mr.MessageDetails == nil {
1974		return false
1975	}
1976
1977	// Must read UnverifiedBody to EOF to trigger signature verification
1978	_, _ = io.ReadAll(mr.MessageDetails.UnverifiedBody)
1979
1980	return mr.MessageDetails.SignatureError == nil
1981}