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