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