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