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