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