fetcher.go

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