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