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