fetcher.go

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