fetcher.go

   1package fetcher
   2
   3import (
   4	"bytes"
   5	"crypto/tls"
   6	"crypto/x509"
   7	"encoding/base64"
   8	"encoding/pem"
   9	"errors"
  10	"fmt"
  11	"io"
  12	"io/ioutil"
  13	"mime"
  14	"mime/quotedprintable"
  15	"os"
  16	"slices"
  17	"strings"
  18	"time"
  19
  20	"github.com/ProtonMail/go-crypto/openpgp"
  21	"github.com/emersion/go-imap"
  22	"github.com/emersion/go-imap/client"
  23	"github.com/emersion/go-message/mail"
  24	"github.com/emersion/go-pgpmail"
  25	"github.com/floatpane/matcha/config"
  26	"go.mozilla.org/pkcs7"
  27	"golang.org/x/text/encoding/ianaindex"
  28	"golang.org/x/text/transform"
  29)
  30
  31// Attachment holds data for an email attachment.
  32type Attachment struct {
  33	Filename         string
  34	PartID           string // Keep PartID to fetch on demand
  35	Data             []byte
  36	Encoding         string // Store encoding for proper decoding
  37	MIMEType         string // Full MIME type (e.g., image/png)
  38	ContentID        string // Content-ID for inline assets (e.g., cid: references)
  39	Inline           bool   // True when the part is meant to be displayed inline
  40	IsSMIMESignature bool   // True if this attachment is an S/MIME signature
  41	SMIMEVerified    bool   // True if the S/MIME signature was verified successfully
  42	IsSMIMEEncrypted bool   // True if the S/MIME content was successfully decrypted
  43	IsPGPSignature   bool   // True if this attachment is a PGP signature
  44	PGPVerified      bool   // True if the PGP signature was verified successfully
  45	IsPGPEncrypted   bool   // True if the PGP content was successfully decrypted
  46}
  47
  48type Email struct {
  49	UID         uint32
  50	From        string
  51	To          []string
  52	Subject     string
  53	Body        string
  54	Date        time.Time
  55	IsRead      bool
  56	MessageID   string
  57	References  []string
  58	Attachments []Attachment
  59	AccountID   string // ID of the account this email belongs to
  60}
  61
  62// Folder represents an IMAP mailbox/folder.
  63type Folder struct {
  64	Name       string
  65	Delimiter  string
  66	Attributes []string
  67}
  68
  69// formatAddress returns "Name <email>" when a PersonalName is present,
  70// otherwise just "email".
  71func formatAddress(addr *imap.Address) string {
  72	email := addr.Address()
  73	if addr.PersonalName != "" {
  74		return addr.PersonalName + " <" + email + ">"
  75	}
  76	return email
  77}
  78
  79func hasSeenFlag(flags []string) bool {
  80	return slices.Contains(flags, imap.SeenFlag)
  81}
  82
  83func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
  84	mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
  85	if err != nil {
  86		body, _ := ioutil.ReadAll(reader)
  87		return string(body), nil
  88	}
  89
  90	charset := "utf-8"
  91	if params["charset"] != "" {
  92		charset = strings.ToLower(params["charset"])
  93	}
  94
  95	encoding, err := ianaindex.IANA.Encoding(charset)
  96	if err != nil || encoding == nil {
  97		encoding, _ = ianaindex.IANA.Encoding("utf-8")
  98	}
  99
 100	transformReader := transform.NewReader(reader, encoding.NewDecoder())
 101	decodedBody, err := ioutil.ReadAll(transformReader)
 102	if err != nil {
 103		return "", err
 104	}
 105
 106	if strings.HasPrefix(mediaType, "multipart/") {
 107		return "[This is a multipart message]", nil
 108	}
 109
 110	return string(decodedBody), nil
 111}
 112
 113func decodeHeader(header string) string {
 114	dec := new(mime.WordDecoder)
 115	dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
 116		encoding, err := ianaindex.IANA.Encoding(charset)
 117		if err != nil {
 118			return nil, err
 119		}
 120		return transform.NewReader(input, encoding.NewDecoder()), nil
 121	}
 122	decoded, err := dec.DecodeHeader(header)
 123	if err != nil {
 124		return header
 125	}
 126	return decoded
 127}
 128
 129func decodeAttachmentData(rawBytes []byte, encoding string) ([]byte, error) {
 130	switch strings.ToLower(encoding) {
 131	case "base64":
 132		decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
 133		return ioutil.ReadAll(decoder)
 134	case "quoted-printable":
 135		return ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
 136	default:
 137		return rawBytes, nil
 138	}
 139}
 140
 141func connect(account *config.Account) (*client.Client, error) {
 142	imapServer := account.GetIMAPServer()
 143	imapPort := account.GetIMAPPort()
 144
 145	if imapServer == "" {
 146		return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
 147	}
 148
 149	addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
 150
 151	tlsConfig := &tls.Config{
 152		ServerName:         imapServer,
 153		InsecureSkipVerify: account.Insecure,
 154	}
 155
 156	var c *client.Client
 157	var err error
 158
 159	// If using standard non-implicit ports (1143 or 143), use Dial + STARTTLS
 160	if imapPort == 1143 || imapPort == 143 {
 161		c, err = client.Dial(addr)
 162		if err != nil {
 163			return nil, err
 164		}
 165		if err := c.StartTLS(tlsConfig); err != nil {
 166			return nil, err
 167		}
 168	} else {
 169		// Otherwise default to implicit TLS (port 993)
 170		c, err = client.DialTLS(addr, tlsConfig)
 171		if err != nil {
 172			return nil, err
 173		}
 174	}
 175
 176	// Authenticate using OAuth2 (XOAUTH2) or plain password
 177	if account.IsOAuth2() {
 178		token, err := config.GetOAuth2Token(account.Email)
 179		if err != nil {
 180			return nil, fmt.Errorf("oauth2: %w", err)
 181		}
 182		if err := c.Authenticate(newXOAuth2Client(account.Email, token)); err != nil {
 183			return nil, fmt.Errorf("XOAUTH2 authentication failed: %w", err)
 184		}
 185	} else {
 186		if err := c.Login(account.Email, account.Password); err != nil {
 187			return nil, err
 188		}
 189	}
 190
 191	return c, nil
 192}
 193
 194func getSentMailbox(account *config.Account) string {
 195	switch account.ServiceProvider {
 196	case "gmail":
 197		return "[Gmail]/Sent Mail"
 198	case "icloud":
 199		return "Sent Messages"
 200	default:
 201		return "Sent"
 202	}
 203}
 204
 205// getMailboxByAttr finds a mailbox with the given IMAP attribute (e.g., \All, \Sent, \Trash).
 206func getMailboxByAttr(c *client.Client, attr string) (string, error) {
 207	mailboxes := make(chan *imap.MailboxInfo, 10)
 208	done := make(chan error, 1)
 209	go func() {
 210		done <- c.List("", "*", mailboxes)
 211	}()
 212
 213	var foundMailbox string
 214	for m := range mailboxes {
 215		for _, a := range m.Attributes {
 216			if a == attr {
 217				foundMailbox = m.Name
 218				break
 219			}
 220		}
 221	}
 222
 223	if err := <-done; err != nil {
 224		return "", err
 225	}
 226
 227	if foundMailbox == "" {
 228		return "", fmt.Errorf("no mailbox found with attribute %s", attr)
 229	}
 230
 231	return foundMailbox, nil
 232}
 233
 234func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) {
 235	c, err := connect(account)
 236	if err != nil {
 237		return nil, err
 238	}
 239	defer c.Logout()
 240
 241	mbox, err := c.Select(mailbox, false)
 242	if err != nil {
 243		return nil, err
 244	}
 245
 246	if mbox.Messages == 0 {
 247		return []Email{}, nil
 248	}
 249
 250	var allEmails []Email
 251
 252	// Start from the top minus offset
 253	cursor := uint32(0)
 254	if mbox.Messages > offset {
 255		cursor = mbox.Messages - offset
 256	} else {
 257		return []Email{}, nil
 258	}
 259
 260	// Determine if we should filter
 261	fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
 262	if fetchEmail == "" {
 263		fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
 264	}
 265	isSentMailbox := mailbox == getSentMailbox(account)
 266
 267	// Loop until we have enough emails or run out of messages
 268	for len(allEmails) < int(limit) && cursor > 0 {
 269		// Determine chunk size
 270		// Fetch at least 'limit' or 50 messages to reduce round trips
 271		chunkSize := limit
 272		if chunkSize < 50 {
 273			chunkSize = 50
 274		}
 275
 276		from := uint32(1)
 277		if cursor > uint32(chunkSize) {
 278			from = cursor - uint32(chunkSize) + 1
 279		}
 280
 281		seqset := new(imap.SeqSet)
 282		seqset.AddRange(from, cursor)
 283
 284		messages := make(chan *imap.Message, chunkSize)
 285		done := make(chan error, 1)
 286		fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags}
 287
 288		go func() {
 289			done <- c.Fetch(seqset, fetchItems, messages)
 290		}()
 291
 292		var batchMsgs []*imap.Message
 293		for msg := range messages {
 294			batchMsgs = append(batchMsgs, msg)
 295		}
 296
 297		if err := <-done; err != nil {
 298			return nil, err
 299		}
 300
 301		// Filter messages in this batch
 302		var batchEmails []Email
 303		for _, msg := range batchMsgs {
 304			if msg == nil || msg.Envelope == nil {
 305				continue
 306			}
 307
 308			var fromAddr string
 309			if len(msg.Envelope.From) > 0 {
 310				fromAddr = formatAddress(msg.Envelope.From[0])
 311			}
 312
 313			var toAddrList []string
 314			for _, addr := range msg.Envelope.To {
 315				toAddrList = append(toAddrList, addr.Address())
 316			}
 317			for _, addr := range msg.Envelope.Cc {
 318				toAddrList = append(toAddrList, addr.Address())
 319			}
 320
 321			matched := false
 322			if isSentMailbox {
 323				var senderEmail string
 324				if len(msg.Envelope.From) > 0 {
 325					senderEmail = msg.Envelope.From[0].Address()
 326				}
 327				if strings.EqualFold(strings.TrimSpace(senderEmail), fetchEmail) {
 328					matched = true
 329				}
 330			} else {
 331				for _, r := range toAddrList {
 332					if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
 333						matched = true
 334						break
 335					}
 336				}
 337			}
 338
 339			if !matched {
 340				continue
 341			}
 342
 343			batchEmails = append(batchEmails, Email{
 344				UID:       msg.Uid,
 345				From:      fromAddr,
 346				To:        toAddrList,
 347				Subject:   decodeHeader(msg.Envelope.Subject),
 348				Date:      msg.Envelope.Date,
 349				IsRead:    hasSeenFlag(msg.Flags),
 350				AccountID: account.ID,
 351			})
 352		}
 353
 354		// Sort batch Newest -> Oldest (since IMAP usually returns Oldest->Newest or arbitrary)
 355		// Assuming seqset order or standard behavior, we want to ensure we append Newest emails first
 356		// so that the final list is correct.
 357		// Actually, let's just sort the batch by UID desc (Newest first)
 358		// Simple bubble sort for small batch
 359		for i := 0; i < len(batchEmails); i++ {
 360			for j := i + 1; j < len(batchEmails); j++ {
 361				if batchEmails[j].UID > batchEmails[i].UID {
 362					batchEmails[i], batchEmails[j] = batchEmails[j], batchEmails[i]
 363				}
 364			}
 365		}
 366
 367		// Append to allEmails
 368		allEmails = append(allEmails, batchEmails...)
 369
 370		// Update cursor for next iteration
 371		cursor = from - 1
 372	}
 373
 374	// Trim if we have too many
 375	if len(allEmails) > int(limit) {
 376		allEmails = allEmails[:limit]
 377	}
 378
 379	return allEmails, nil
 380}
 381
 382func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
 383	c, err := connect(account)
 384	if err != nil {
 385		return "", nil, err
 386	}
 387	defer c.Logout()
 388
 389	if _, err := c.Select(mailbox, false); err != nil {
 390		return "", nil, err
 391	}
 392
 393	seqset := new(imap.SeqSet)
 394	seqset.AddNum(uid)
 395
 396	fetchWholeMessage := func() ([]byte, error) {
 397		fetchItem := imap.FetchItem("BODY.PEEK[]")
 398		section, _ := imap.ParseBodySectionName(fetchItem)
 399		partMessages := make(chan *imap.Message, 1)
 400		partDone := make(chan error, 1)
 401		go func() {
 402			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
 403		}()
 404		if err := <-partDone; err != nil {
 405			return nil, err
 406		}
 407		partMsg := <-partMessages
 408		if partMsg != nil {
 409			literal := partMsg.GetBody(section)
 410			if literal != nil {
 411				return ioutil.ReadAll(literal)
 412			}
 413		}
 414		return nil, fmt.Errorf("could not fetch whole message")
 415	}
 416
 417	fetchInlinePart := func(partID, encoding string) ([]byte, error) {
 418		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
 419		section, err := imap.ParseBodySectionName(fetchItem)
 420		if err != nil {
 421			return nil, err
 422		}
 423
 424		partMessages := make(chan *imap.Message, 1)
 425		partDone := make(chan error, 1)
 426		go func() {
 427			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
 428		}()
 429
 430		if err := <-partDone; err != nil {
 431			return nil, err
 432		}
 433
 434		partMsg := <-partMessages
 435		if partMsg == nil {
 436			return nil, fmt.Errorf("could not fetch inline part %s", partID)
 437		}
 438
 439		literal := partMsg.GetBody(section)
 440		if literal == nil {
 441			return nil, fmt.Errorf("could not get inline part body %s", partID)
 442		}
 443
 444		rawBytes, err := ioutil.ReadAll(literal)
 445		if err != nil {
 446			return nil, err
 447		}
 448
 449		return decodeAttachmentData(rawBytes, encoding)
 450	}
 451
 452	messages := make(chan *imap.Message, 1)
 453	done := make(chan error, 1)
 454	fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
 455	go func() {
 456		done <- c.UidFetch(seqset, fetchItems, messages)
 457	}()
 458
 459	if err := <-done; err != nil {
 460		return "", nil, err
 461	}
 462
 463	msg := <-messages
 464	if msg == nil || msg.BodyStructure == nil {
 465		return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
 466	}
 467
 468	var plainPartID, plainPartEncoding string
 469	var htmlPartID, htmlPartEncoding string
 470	var attachments []Attachment
 471	var extractedBody string // Used if we intercept and decrypt a payload
 472
 473	var checkPart func(part *imap.BodyStructure, partID string)
 474	checkPart = func(part *imap.BodyStructure, partID string) {
 475		// Check for text content (prefer html over plain)
 476		if part.MIMEType == "text" {
 477			sub := strings.ToLower(part.MIMESubType)
 478			switch sub {
 479			case "html":
 480				if htmlPartID == "" {
 481					htmlPartID = partID
 482					htmlPartEncoding = part.Encoding
 483				}
 484			case "plain":
 485				if plainPartID == "" {
 486					plainPartID = partID
 487					plainPartEncoding = part.Encoding
 488				}
 489			}
 490		}
 491
 492		// Check for attachments using multiple methods
 493		filename := ""
 494		// First try the Filename() method which handles various cases
 495		if fn, err := part.Filename(); err == nil && fn != "" {
 496			filename = fn
 497		}
 498		// Fallback: check DispositionParams
 499		if filename == "" {
 500			if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
 501				filename = fn
 502			}
 503		}
 504		// Fallback: check Params (for name parameter)
 505		if filename == "" {
 506			if fn, ok := part.Params["name"]; ok && fn != "" {
 507				filename = fn
 508			}
 509		}
 510		// Fallback: check Params for filename
 511		if filename == "" {
 512			if fn, ok := part.Params["filename"]; ok && fn != "" {
 513				filename = fn
 514			}
 515		}
 516
 517		// Add as attachment if it has a disposition or a filename (and not just plain text).
 518		// Allow inline parts without filenames (common for cid images).
 519		contentID := strings.Trim(part.Id, "<>")
 520		mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType))
 521		isCID := contentID != ""
 522		isInline := part.Disposition == "inline" || isCID
 523
 524		if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
 525			filename = "inline"
 526		}
 527
 528		// === S/MIME ENCRYPTION AND OPAQUE VERIFICATION ===
 529		if filename == "smime.p7m" || mimeType == "application/pkcs7-mime" {
 530			data, err := fetchInlinePart(partID, part.Encoding)
 531			if err != nil && partID == "1" {
 532				// Fallback for single-part messages where PEEK[1] fails
 533				data, err = fetchInlinePart("TEXT", part.Encoding)
 534			}
 535
 536			if err != nil {
 537				extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to fetch encrypted part from IMAP server: %v\n", err)
 538				htmlPartID = "extracted"
 539			} else {
 540				p7, parseErr := pkcs7.Parse(data)
 541				if parseErr != nil {
 542					// Fallback: IMAP servers sometimes drop the transfer-encoding header.
 543					// We manually strip newlines and attempt a base64 decode just in case.
 544					cleanData := bytes.ReplaceAll(data, []byte("\n"), []byte(""))
 545					cleanData = bytes.ReplaceAll(cleanData, []byte("\r"), []byte(""))
 546					if decoded, b64err := base64.StdEncoding.DecodeString(string(cleanData)); b64err == nil {
 547						p7, parseErr = pkcs7.Parse(decoded)
 548					}
 549				}
 550
 551				if parseErr != nil {
 552					extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to parse PKCS7 payload: %v\n", parseErr)
 553					htmlPartID = "extracted"
 554				} else {
 555					var innerBytes []byte
 556					isEncrypted, isOpaqueSigned, smimeTrusted := false, false, false
 557					decryptionErr := ""
 558
 559					// 1. Try to Decrypt
 560					if account.SMIMECert != "" && account.SMIMEKey != "" {
 561						cData, err1 := os.ReadFile(account.SMIMECert)
 562						kData, err2 := os.ReadFile(account.SMIMEKey)
 563						if err1 != nil || err2 != nil {
 564							decryptionErr = fmt.Sprintf("Failed to read cert/key files. Cert: %v, Key: %v", err1, err2)
 565						} else {
 566							cBlock, _ := pem.Decode(cData)
 567							kBlock, _ := pem.Decode(kData)
 568							if cBlock == nil || kBlock == nil {
 569								decryptionErr = "Failed to decode PEM blocks from cert/key files."
 570							} else {
 571								cert, err3 := x509.ParseCertificate(cBlock.Bytes)
 572								var privKey any
 573								var err4 error
 574								if key, err := x509.ParsePKCS8PrivateKey(kBlock.Bytes); err == nil {
 575									privKey = key
 576								} else if key, err := x509.ParsePKCS1PrivateKey(kBlock.Bytes); err == nil {
 577									privKey = key
 578								} else if key, err := x509.ParseECPrivateKey(kBlock.Bytes); err == nil {
 579									privKey = key
 580								} else {
 581									err4 = errors.New("unsupported private key format")
 582								}
 583
 584								if err3 != nil || err4 != nil {
 585									decryptionErr = fmt.Sprintf("Failed to parse cert/key. Cert: %v, Key: %v", err3, err4)
 586								} else {
 587									dec, err := p7.Decrypt(cert, privKey)
 588									if err == nil {
 589										innerBytes = dec
 590										isEncrypted = true
 591									} else {
 592										decryptionErr = fmt.Sprintf("PKCS7 Decrypt failed: %v", err)
 593									}
 594								}
 595							}
 596						}
 597					} else {
 598						// Only set error if it actually is enveloped data (encrypted)
 599						// If it's just opaque signed, we shouldn't error out.
 600						decryptionErr = "S/MIME Cert or Key path is missing in settings."
 601					}
 602
 603					// 2. If not encrypted, check if it's an opaque signature
 604					if !isEncrypted && len(p7.Signers) > 0 {
 605						isOpaqueSigned = true
 606						innerBytes = p7.Content
 607						decryptionErr = "" // Clear encryption error because it wasn't encrypted to begin with
 608						roots, _ := x509.SystemCertPool()
 609						if roots == nil {
 610							roots = x509.NewCertPool()
 611						}
 612						if err := p7.VerifyWithChain(roots); err == nil {
 613							smimeTrusted = true
 614						}
 615					}
 616
 617					// 3. Parse Inner MIME payload
 618					if len(innerBytes) > 0 {
 619						mr, err := mail.CreateReader(bytes.NewReader(innerBytes))
 620						if err == nil {
 621							for {
 622								p, err := mr.NextPart()
 623								if err != nil {
 624									break
 625								}
 626								cType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
 627								disp, dParams, _ := mime.ParseMediaType(p.Header.Get("Content-Disposition"))
 628								b, _ := ioutil.ReadAll(p.Body) // Auto-decodes quoted-printable/base64
 629
 630								if disp == "attachment" || disp == "inline" || (!strings.HasPrefix(cType, "multipart/") && cType != "text/plain" && cType != "text/html") {
 631									fn := dParams["filename"]
 632									if fn == "" {
 633										_, cp, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
 634										fn = cp["name"]
 635									}
 636									attachments = append(attachments, Attachment{
 637										Filename: fn, Data: b, MIMEType: cType, Inline: disp == "inline",
 638									})
 639								} else {
 640									if cType == "text/html" {
 641										extractedBody = string(b)
 642										htmlPartID = "extracted" // Skip IMAP fetch
 643									} else if cType == "text/plain" && extractedBody == "" {
 644										extractedBody = string(b)
 645										plainPartID = "extracted"
 646									}
 647								}
 648							}
 649						} else {
 650							extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to read inner decrypted MIME: %v\n\n```\n%s\n```", err, string(innerBytes))
 651							htmlPartID = "extracted"
 652						}
 653
 654						attachments = append(attachments, Attachment{
 655							Filename:         "smime-status.internal",
 656							IsSMIMESignature: isOpaqueSigned,
 657							SMIMEVerified:    smimeTrusted,
 658							IsSMIMEEncrypted: isEncrypted,
 659						})
 660						return // Stop checking IMAP structure, we hijacked it
 661					} else {
 662						extractedBody = fmt.Sprintf("**S/MIME Decryption Failed:** %s\n", decryptionErr)
 663						htmlPartID = "extracted"
 664					}
 665				}
 666			}
 667		}
 668
 669		// === S/MIME DETACHED SIGNATURE VERIFICATION ===
 670		if filename == "smime.p7s" || mimeType == "application/pkcs7-signature" {
 671			att := Attachment{
 672				Filename:         filename,
 673				PartID:           partID,
 674				Encoding:         part.Encoding,
 675				MIMEType:         mimeType,
 676				ContentID:        contentID,
 677				Inline:           isInline,
 678				IsSMIMESignature: true,
 679			}
 680			if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
 681				att.Data = data
 682				p7, err := pkcs7.Parse(data)
 683				if err == nil {
 684					boundary := msg.BodyStructure.Params["boundary"]
 685					if boundary != "" {
 686						rawEmail, err := fetchWholeMessage()
 687						if err == nil {
 688							fullBoundary := []byte("--" + boundary)
 689							firstIdx := bytes.Index(rawEmail, fullBoundary)
 690							if firstIdx != -1 {
 691								startIdx := firstIdx + len(fullBoundary)
 692								if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' {
 693									startIdx++
 694								}
 695								if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' {
 696									startIdx++
 697								}
 698								secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary)
 699								if secondIdx != -1 {
 700									endIdx := startIdx + secondIdx
 701									if endIdx > 0 && rawEmail[endIdx-1] == '\n' {
 702										endIdx--
 703									}
 704									if endIdx > 0 && rawEmail[endIdx-1] == '\r' {
 705										endIdx--
 706									}
 707									signedData := rawEmail[startIdx:endIdx]
 708									canonical := bytes.ReplaceAll(signedData, []byte("\r\n"), []byte("\n"))
 709									canonical = bytes.ReplaceAll(canonical, []byte("\n"), []byte("\r\n"))
 710
 711									roots, _ := x509.SystemCertPool()
 712									if roots == nil {
 713										roots = x509.NewCertPool()
 714									}
 715
 716									p7.Content = canonical
 717									if err := p7.VerifyWithChain(roots); err == nil {
 718										att.SMIMEVerified = true
 719									} else {
 720										p7.Content = append(canonical, '\r', '\n')
 721										if err := p7.VerifyWithChain(roots); err == nil {
 722											att.SMIMEVerified = true
 723										} else {
 724											p7.Content = bytes.TrimRight(canonical, "\r\n")
 725											if err := p7.VerifyWithChain(roots); err == nil {
 726												att.SMIMEVerified = true
 727											}
 728										}
 729									}
 730								}
 731							}
 732						}
 733					}
 734				}
 735			}
 736			attachments = append(attachments, att)
 737		}
 738
 739		// === PGP ENCRYPTED MESSAGE DETECTION ===
 740		if mimeType == "application/pgp-encrypted" || (mimeType == "multipart/encrypted" && strings.Contains(part.MIMESubType, "pgp")) {
 741			// PGP encrypted messages typically have two parts:
 742			// 1. Version info (application/pgp-encrypted)
 743			// 2. Encrypted data (application/octet-stream)
 744			// We'll handle decryption when we find the encrypted data part
 745			// Skip this part and continue processing
 746		}
 747
 748		// Detect encrypted data part of PGP message
 749		if strings.Contains(filename, ".asc") || (mimeType == "application/octet-stream" && part.Encoding == "7bit") {
 750			// This might be PGP encrypted data
 751			data, err := fetchInlinePart(partID, part.Encoding)
 752			if err == nil && bytes.Contains(data, []byte("-----BEGIN PGP MESSAGE-----")) {
 753				// This is PGP encrypted content
 754				if account.PGPPrivateKey != "" {
 755					decrypted, err := decryptPGPMessage(data, account)
 756					if err == nil {
 757						// Parse the decrypted MIME content
 758						mr, err := mail.CreateReader(bytes.NewReader(decrypted))
 759						if err == nil {
 760							for {
 761								p, err := mr.NextPart()
 762								if err == io.EOF {
 763									break
 764								}
 765								if err != nil {
 766									break
 767								}
 768
 769								switch h := p.Header.(type) {
 770								case *mail.InlineHeader:
 771									ct, _, _ := h.ContentType()
 772									if strings.HasPrefix(ct, "text/html") {
 773										body, _ := io.ReadAll(p.Body)
 774										extractedBody = string(body)
 775										htmlPartID = "decrypted"
 776									} else if strings.HasPrefix(ct, "text/plain") && extractedBody == "" {
 777										body, _ := io.ReadAll(p.Body)
 778										extractedBody = string(body)
 779										htmlPartID = "decrypted"
 780									}
 781								}
 782							}
 783
 784							// Add status marker
 785							attachments = append(attachments, Attachment{
 786								Filename:       "pgp-status.internal",
 787								IsPGPEncrypted: true,
 788								PGPVerified:    true, // Decryption succeeded
 789							})
 790						}
 791					} else {
 792						extractedBody = fmt.Sprintf("**PGP Decryption Failed:** %s\n", err)
 793						htmlPartID = "extracted"
 794					}
 795				} else {
 796					extractedBody = "**PGP Encrypted:** Private key not configured\n"
 797					htmlPartID = "extracted"
 798				}
 799			}
 800		}
 801
 802		// === PGP DETACHED SIGNATURE VERIFICATION ===
 803		if filename == "signature.asc" || mimeType == "application/pgp-signature" {
 804			att := Attachment{
 805				Filename:       filename,
 806				PartID:         partID,
 807				Encoding:       part.Encoding,
 808				MIMEType:       mimeType,
 809				ContentID:      contentID,
 810				Inline:         isInline,
 811				IsPGPSignature: true,
 812			}
 813
 814			if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
 815				att.Data = data
 816
 817				// Try to verify the signature
 818				boundary := msg.BodyStructure.Params["boundary"]
 819				if boundary != "" {
 820					rawEmail, err := fetchWholeMessage()
 821					if err == nil {
 822						// Extract signed content (similar to S/MIME)
 823						fullBoundary := []byte("--" + boundary)
 824						firstIdx := bytes.Index(rawEmail, fullBoundary)
 825						if firstIdx != -1 {
 826							startIdx := firstIdx + len(fullBoundary)
 827							if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' {
 828								startIdx++
 829							}
 830							if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' {
 831								startIdx++
 832							}
 833							secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary)
 834							if secondIdx != -1 {
 835								endIdx := startIdx + secondIdx
 836								if endIdx > 0 && rawEmail[endIdx-1] == '\n' {
 837									endIdx--
 838								}
 839								if endIdx > 0 && rawEmail[endIdx-1] == '\r' {
 840									endIdx--
 841								}
 842								signedData := rawEmail[startIdx:endIdx]
 843
 844								// Verify PGP signature
 845								verified := verifyPGPSignature(signedData, data, account)
 846								att.PGPVerified = verified
 847							}
 848						}
 849					}
 850				}
 851			}
 852			attachments = append(attachments, att)
 853		} else if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
 854			att := Attachment{
 855				Filename:  filename,
 856				PartID:    partID,
 857				Encoding:  part.Encoding, // Store encoding for proper decoding
 858				MIMEType:  mimeType,
 859				ContentID: contentID,
 860				Inline:    isInline,
 861			}
 862			if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
 863				if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
 864					att.Data = data
 865				}
 866			}
 867			attachments = append(attachments, att)
 868		}
 869	}
 870
 871	var findParts func(*imap.BodyStructure, string)
 872	findParts = func(bs *imap.BodyStructure, prefix string) {
 873		// If this is a non-multipart message, check the body structure itself
 874		if len(bs.Parts) == 0 {
 875			partID := prefix
 876			if partID == "" {
 877				partID = "1"
 878			}
 879			checkPart(bs, partID)
 880			return
 881		}
 882
 883		// Iterate through parts
 884		for i, part := range bs.Parts {
 885			partID := fmt.Sprintf("%d", i+1)
 886			if prefix != "" {
 887				partID = fmt.Sprintf("%s.%d", prefix, i+1)
 888			}
 889
 890			checkPart(part, partID)
 891
 892			if len(part.Parts) > 0 {
 893				findParts(part, partID)
 894			}
 895		}
 896	}
 897	findParts(msg.BodyStructure, "")
 898
 899	// If we hijacked and decrypted the body, return it immediately
 900	if extractedBody != "" {
 901		return extractedBody, attachments, nil
 902	}
 903
 904	var body string
 905	textPartID := ""
 906	textPartEncoding := ""
 907	if htmlPartID != "" {
 908		textPartID = htmlPartID
 909		textPartEncoding = htmlPartEncoding
 910	} else if plainPartID != "" {
 911		textPartID = plainPartID
 912		textPartEncoding = plainPartEncoding
 913	}
 914	if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
 915		msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
 916		fmt.Print(msg)
 917		if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
 918			if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
 919				_, _ = f.WriteString(msg)
 920				_ = f.Close()
 921			}
 922		}
 923	}
 924	if textPartID != "" {
 925		partMessages := make(chan *imap.Message, 1)
 926		partDone := make(chan error, 1)
 927
 928		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
 929		section, err := imap.ParseBodySectionName(fetchItem)
 930		if err != nil {
 931			return "", nil, err
 932		}
 933
 934		go func() {
 935			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
 936		}()
 937
 938		if err := <-partDone; err != nil {
 939			return "", nil, err
 940		}
 941
 942		partMsg := <-partMessages
 943		if partMsg != nil {
 944			literal := partMsg.GetBody(section)
 945			if literal != nil {
 946				buf, _ := ioutil.ReadAll(literal)
 947				// Use the encoding from BodyStructure to decode
 948				if decoded, err := decodeAttachmentData(buf, textPartEncoding); err == nil {
 949					body = string(decoded)
 950				} else {
 951					body = string(buf)
 952				}
 953			}
 954		}
 955	}
 956
 957	return body, attachments, nil
 958}
 959
 960func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
 961	c, err := connect(account)
 962	if err != nil {
 963		return nil, err
 964	}
 965	defer c.Logout()
 966
 967	if _, err := c.Select(mailbox, false); err != nil {
 968		return nil, err
 969	}
 970
 971	seqset := new(imap.SeqSet)
 972	seqset.AddNum(uid)
 973
 974	fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
 975	section, err := imap.ParseBodySectionName(fetchItem)
 976	if err != nil {
 977		return nil, err
 978	}
 979
 980	messages := make(chan *imap.Message, 1)
 981	done := make(chan error, 1)
 982	go func() {
 983		done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
 984	}()
 985
 986	if err := <-done; err != nil {
 987		return nil, err
 988	}
 989
 990	msg := <-messages
 991	if msg == nil {
 992		return nil, fmt.Errorf("could not fetch attachment")
 993	}
 994
 995	literal := msg.GetBody(section)
 996	if literal == nil {
 997		return nil, fmt.Errorf("could not get attachment body")
 998	}
 999
1000	rawBytes, err := ioutil.ReadAll(literal)
1001	if err != nil {
1002		return nil, err
1003	}
1004
1005	decoded, err := decodeAttachmentData(rawBytes, encoding)
1006	if err != nil {
1007		return rawBytes, nil
1008	}
1009	return decoded, nil
1010}
1011
1012func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
1013	c, err := connect(account)
1014	if err != nil {
1015		return err
1016	}
1017	defer c.Logout()
1018
1019	if _, err := c.Select(sourceMailbox, false); err != nil {
1020		return err
1021	}
1022
1023	seqSet := new(imap.SeqSet)
1024	seqSet.AddNum(uid)
1025
1026	return c.UidMove(seqSet, destMailbox)
1027}
1028
1029func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint32) error {
1030	c, err := connect(account)
1031	if err != nil {
1032		return err
1033	}
1034	defer c.Logout()
1035
1036	if _, err := c.Select(mailbox, false); err != nil {
1037		return err
1038	}
1039
1040	seqSet := new(imap.SeqSet)
1041	seqSet.AddNum(uid)
1042
1043	item := imap.FormatFlagsOp(imap.AddFlags, true)
1044	flags := []interface{}{imap.SeenFlag}
1045
1046	return c.UidStore(seqSet, item, flags, nil)
1047}
1048
1049func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
1050	c, err := connect(account)
1051	if err != nil {
1052		return err
1053	}
1054	defer c.Logout()
1055
1056	if _, err := c.Select(mailbox, false); err != nil {
1057		return err
1058	}
1059
1060	seqSet := new(imap.SeqSet)
1061	seqSet.AddNum(uid)
1062
1063	item := imap.FormatFlagsOp(imap.AddFlags, true)
1064	flags := []interface{}{imap.DeletedFlag}
1065
1066	if err := c.UidStore(seqSet, item, flags, nil); err != nil {
1067		return err
1068	}
1069
1070	return c.Expunge(nil)
1071}
1072
1073func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
1074	c, err := connect(account)
1075	if err != nil {
1076		return err
1077	}
1078	defer c.Logout()
1079
1080	var archiveMailbox string
1081	switch account.ServiceProvider {
1082	case "gmail":
1083		// For Gmail, find the mailbox with the \All attribute
1084		archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr)
1085		if err != nil {
1086			// Fallback to hardcoded path if attribute lookup fails
1087			archiveMailbox = "[Gmail]/All Mail"
1088		}
1089	default:
1090		archiveMailbox = "Archive"
1091	}
1092
1093	if _, err := c.Select(mailbox, false); err != nil {
1094		return err
1095	}
1096
1097	seqSet := new(imap.SeqSet)
1098	seqSet.AddNum(uid)
1099
1100	return c.UidMove(seqSet, archiveMailbox)
1101}
1102
1103// Convenience wrappers defaulting to INBOX for existing call sites.
1104
1105func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1106	return FetchMailboxEmails(account, "INBOX", limit, offset)
1107}
1108
1109func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1110	return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
1111}
1112
1113func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1114	return FetchEmailBodyFromMailbox(account, "INBOX", uid)
1115}
1116
1117func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1118	return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
1119}
1120
1121func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1122	return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
1123}
1124
1125func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1126	return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
1127}
1128
1129func DeleteEmail(account *config.Account, uid uint32) error {
1130	return DeleteEmailFromMailbox(account, "INBOX", uid)
1131}
1132
1133func DeleteSentEmail(account *config.Account, uid uint32) error {
1134	return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
1135}
1136
1137func ArchiveEmail(account *config.Account, uid uint32) error {
1138	return ArchiveEmailFromMailbox(account, "INBOX", uid)
1139}
1140
1141func ArchiveSentEmail(account *config.Account, uid uint32) error {
1142	return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
1143}
1144
1145// getTrashMailbox returns the trash mailbox name for the account
1146func getTrashMailbox(account *config.Account) string {
1147	switch account.ServiceProvider {
1148	case "gmail":
1149		return "[Gmail]/Trash"
1150	case "icloud":
1151		return "Deleted Messages"
1152	default:
1153		return "Trash"
1154	}
1155}
1156
1157// getArchiveMailbox returns the archive/all mail mailbox name for the account
1158func getArchiveMailbox(account *config.Account) string {
1159	switch account.ServiceProvider {
1160	case "gmail":
1161		return "[Gmail]/All Mail"
1162	case "icloud":
1163		return "Archive"
1164	default:
1165		return "Archive"
1166	}
1167}
1168
1169// FetchTrashEmails fetches emails from the trash folder
1170func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1171	c, err := connect(account)
1172	if err != nil {
1173		return nil, err
1174	}
1175	defer c.Logout()
1176
1177	// Try to find trash by attribute first
1178	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1179	if err != nil {
1180		// Fallback to hardcoded path
1181		trashMailbox = getTrashMailbox(account)
1182	}
1183
1184	return FetchMailboxEmails(account, trashMailbox, limit, offset)
1185}
1186
1187// FetchArchiveEmails fetches emails from the archive/all mail folder
1188// Archive contains all emails, so we match where user is sender OR recipient
1189func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1190	c, err := connect(account)
1191	if err != nil {
1192		return nil, err
1193	}
1194	defer c.Logout()
1195
1196	// Try to find archive by attribute first (Gmail uses \All)
1197	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1198	if err != nil {
1199		// Fallback to hardcoded path
1200		archiveMailbox = getArchiveMailbox(account)
1201	}
1202
1203	mbox, err := c.Select(archiveMailbox, false)
1204	if err != nil {
1205		return nil, err
1206	}
1207
1208	if mbox.Messages == 0 {
1209		return []Email{}, nil
1210	}
1211
1212	to := mbox.Messages - offset
1213	from := uint32(1)
1214	if to > limit {
1215		from = to - limit + 1
1216	}
1217
1218	if to < 1 {
1219		return []Email{}, nil
1220	}
1221
1222	seqset := new(imap.SeqSet)
1223	seqset.AddRange(from, to)
1224
1225	messages := make(chan *imap.Message, limit)
1226	done := make(chan error, 1)
1227	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags}
1228	go func() {
1229		done <- c.Fetch(seqset, fetchItems, messages)
1230	}()
1231
1232	var msgs []*imap.Message
1233	for msg := range messages {
1234		msgs = append(msgs, msg)
1235	}
1236
1237	if err := <-done; err != nil {
1238		return nil, err
1239	}
1240
1241	// Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
1242	fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
1243	if fetchEmail == "" {
1244		fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
1245	}
1246
1247	var emails []Email
1248	for _, msg := range msgs {
1249		if msg == nil || msg.Envelope == nil {
1250			continue
1251		}
1252
1253		var fromAddr string
1254		if len(msg.Envelope.From) > 0 {
1255			fromAddr = formatAddress(msg.Envelope.From[0])
1256		}
1257
1258		var toAddrList []string
1259		for _, addr := range msg.Envelope.To {
1260			toAddrList = append(toAddrList, addr.Address())
1261		}
1262		for _, addr := range msg.Envelope.Cc {
1263			toAddrList = append(toAddrList, addr.Address())
1264		}
1265
1266		// For archive/All Mail, match emails where user is sender OR recipient
1267		matched := false
1268		// Check if user is the sender
1269		if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
1270			matched = true
1271		}
1272		// Check if user is a recipient
1273		if !matched {
1274			for _, r := range toAddrList {
1275				if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
1276					matched = true
1277					break
1278				}
1279			}
1280		}
1281
1282		if !matched {
1283			continue
1284		}
1285
1286		emails = append(emails, Email{
1287			UID:       msg.Uid,
1288			From:      fromAddr,
1289			To:        toAddrList,
1290			Subject:   decodeHeader(msg.Envelope.Subject),
1291			Date:      msg.Envelope.Date,
1292			IsRead:    hasSeenFlag(msg.Flags),
1293			AccountID: account.ID,
1294		})
1295	}
1296
1297	// Reverse to get newest first
1298	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
1299		emails[i], emails[j] = emails[j], emails[i]
1300	}
1301
1302	return emails, nil
1303}
1304
1305// FetchTrashEmailBody fetches the body of an email from trash
1306func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1307	c, err := connect(account)
1308	if err != nil {
1309		return "", nil, err
1310	}
1311	defer c.Logout()
1312
1313	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1314	if err != nil {
1315		trashMailbox = getTrashMailbox(account)
1316	}
1317
1318	return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
1319}
1320
1321// FetchArchiveEmailBody fetches the body of an email from archive
1322func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1323	c, err := connect(account)
1324	if err != nil {
1325		return "", nil, err
1326	}
1327	defer c.Logout()
1328
1329	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1330	if err != nil {
1331		archiveMailbox = getArchiveMailbox(account)
1332	}
1333
1334	return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
1335}
1336
1337// FetchTrashAttachment fetches an attachment from trash
1338func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1339	c, err := connect(account)
1340	if err != nil {
1341		return nil, err
1342	}
1343	defer c.Logout()
1344
1345	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1346	if err != nil {
1347		trashMailbox = getTrashMailbox(account)
1348	}
1349
1350	return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
1351}
1352
1353// FetchArchiveAttachment fetches an attachment from archive
1354func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1355	c, err := connect(account)
1356	if err != nil {
1357		return nil, err
1358	}
1359	defer c.Logout()
1360
1361	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1362	if err != nil {
1363		archiveMailbox = getArchiveMailbox(account)
1364	}
1365
1366	return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
1367}
1368
1369// DeleteTrashEmail permanently deletes an email from trash
1370func DeleteTrashEmail(account *config.Account, uid uint32) error {
1371	c, err := connect(account)
1372	if err != nil {
1373		return err
1374	}
1375	defer c.Logout()
1376
1377	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1378	if err != nil {
1379		trashMailbox = getTrashMailbox(account)
1380	}
1381
1382	return DeleteEmailFromMailbox(account, trashMailbox, uid)
1383}
1384
1385// DeleteArchiveEmail deletes an email from archive (moves to trash)
1386func DeleteArchiveEmail(account *config.Account, uid uint32) error {
1387	c, err := connect(account)
1388	if err != nil {
1389		return err
1390	}
1391	defer c.Logout()
1392
1393	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1394	if err != nil {
1395		archiveMailbox = getArchiveMailbox(account)
1396	}
1397
1398	return DeleteEmailFromMailbox(account, archiveMailbox, uid)
1399}
1400
1401// FetchFolders lists all IMAP folders/mailboxes for an account.
1402func FetchFolders(account *config.Account) ([]Folder, error) {
1403	c, err := connect(account)
1404	if err != nil {
1405		return nil, err
1406	}
1407	defer c.Logout()
1408
1409	mailboxes := make(chan *imap.MailboxInfo, 50)
1410	done := make(chan error, 1)
1411	go func() {
1412		done <- c.List("", "*", mailboxes)
1413	}()
1414
1415	var folders []Folder
1416	for m := range mailboxes {
1417		folders = append(folders, Folder{
1418			Name:       m.Name,
1419			Delimiter:  m.Delimiter,
1420			Attributes: m.Attributes,
1421		})
1422	}
1423
1424	if err := <-done; err != nil {
1425		return nil, err
1426	}
1427
1428	return folders, nil
1429}
1430
1431// MoveEmailToFolder moves an email from one folder to another via IMAP.
1432func MoveEmailToFolder(account *config.Account, uid uint32, sourceFolder, destFolder string) error {
1433	return moveEmail(account, uid, sourceFolder, destFolder)
1434}
1435
1436// FetchFolderEmails fetches emails from an arbitrary folder.
1437func FetchFolderEmails(account *config.Account, folder string, limit, offset uint32) ([]Email, error) {
1438	return FetchMailboxEmails(account, folder, limit, offset)
1439}
1440
1441// FetchFolderEmailBody fetches the body of an email from an arbitrary folder.
1442func FetchFolderEmailBody(account *config.Account, folder string, uid uint32) (string, []Attachment, error) {
1443	return FetchEmailBodyFromMailbox(account, folder, uid)
1444}
1445
1446// FetchFolderAttachment fetches an attachment from an arbitrary folder.
1447func FetchFolderAttachment(account *config.Account, folder string, uid uint32, partID string, encoding string) ([]byte, error) {
1448	return FetchAttachmentFromMailbox(account, folder, uid, partID, encoding)
1449}
1450
1451// DeleteFolderEmail deletes an email from an arbitrary folder.
1452func DeleteFolderEmail(account *config.Account, folder string, uid uint32) error {
1453	return DeleteEmailFromMailbox(account, folder, uid)
1454}
1455
1456// ArchiveFolderEmail archives an email from an arbitrary folder.
1457func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) error {
1458	return ArchiveEmailFromMailbox(account, folder, uid)
1459}
1460
1461// decryptPGPMessage decrypts a PGP-encrypted message using the account's private key.
1462func decryptPGPMessage(encryptedData []byte, account *config.Account) ([]byte, error) {
1463	if account.PGPPrivateKey == "" {
1464		return nil, errors.New("PGP private key not configured")
1465	}
1466
1467	// Load private key
1468	keyFile, err := os.ReadFile(account.PGPPrivateKey)
1469	if err != nil {
1470		return nil, fmt.Errorf("failed to read PGP private key: %w", err)
1471	}
1472
1473	// Try armored format first
1474	entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
1475	if err != nil {
1476		// Try binary format
1477		entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
1478		if err != nil {
1479			return nil, fmt.Errorf("failed to parse PGP private key: %w", err)
1480		}
1481	}
1482
1483	if len(entityList) == 0 {
1484		return nil, errors.New("no PGP keys found in private keyring")
1485	}
1486
1487	// Decrypt using go-pgpmail
1488	mr, err := pgpmail.Read(bytes.NewReader(encryptedData), openpgp.EntityList{entityList[0]}, nil, nil)
1489	if err != nil {
1490		return nil, fmt.Errorf("failed to decrypt PGP message: %w", err)
1491	}
1492
1493	// Read decrypted content from UnverifiedBody
1494	if mr.MessageDetails == nil || mr.MessageDetails.UnverifiedBody == nil {
1495		return nil, errors.New("no decrypted content available")
1496	}
1497
1498	var decrypted bytes.Buffer
1499	if _, err := io.Copy(&decrypted, mr.MessageDetails.UnverifiedBody); err != nil {
1500		return nil, fmt.Errorf("failed to read decrypted content: %w", err)
1501	}
1502
1503	return decrypted.Bytes(), nil
1504}
1505
1506// loadPGPKeyring builds an openpgp.EntityList from the account's public key
1507// and any keys stored in the pgp/ config directory.
1508func loadPGPKeyring(account *config.Account) openpgp.EntityList {
1509	var keyring openpgp.EntityList
1510
1511	readKeys := func(path string) {
1512		data, err := os.ReadFile(path)
1513		if err != nil {
1514			return
1515		}
1516		entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
1517		if err != nil {
1518			entities, err = openpgp.ReadKeyRing(bytes.NewReader(data))
1519			if err != nil {
1520				return
1521			}
1522		}
1523		keyring = append(keyring, entities...)
1524	}
1525
1526	// Load account's own public key
1527	if account.PGPPublicKey != "" {
1528		readKeys(account.PGPPublicKey)
1529	}
1530
1531	// Load all keys from the pgp/ config directory
1532	cfgDir, err := config.GetConfigDir()
1533	if err == nil {
1534		pgpDir := cfgDir + "/pgp"
1535		entries, err := os.ReadDir(pgpDir)
1536		if err == nil {
1537			for _, entry := range entries {
1538				if entry.IsDir() {
1539					continue
1540				}
1541				name := entry.Name()
1542				if strings.HasSuffix(name, ".asc") || strings.HasSuffix(name, ".gpg") {
1543					readKeys(pgpDir + "/" + name)
1544				}
1545			}
1546		}
1547	}
1548
1549	return keyring
1550}
1551
1552// verifyPGPSignature verifies a PGP detached signature against signed content.
1553func verifyPGPSignature(signedContent, signatureData []byte, account *config.Account) bool {
1554	keyring := loadPGPKeyring(account)
1555	if len(keyring) == 0 {
1556		return false
1557	}
1558
1559	// Build a complete multipart/signed message for go-pgpmail
1560	boundary := "pgp-verify-boundary"
1561	var msg bytes.Buffer
1562	msg.WriteString("Content-Type: multipart/signed; boundary=\"" + boundary + "\"; micalg=pgp-sha256; protocol=\"application/pgp-signature\"\r\n\r\n")
1563	msg.WriteString("--" + boundary + "\r\n")
1564	msg.Write(signedContent)
1565	msg.WriteString("\r\n--" + boundary + "\r\n")
1566	msg.WriteString("Content-Type: application/pgp-signature\r\n\r\n")
1567	msg.Write(signatureData)
1568	msg.WriteString("\r\n--" + boundary + "--\r\n")
1569
1570	mr, err := pgpmail.Read(&msg, keyring, nil, nil)
1571	if err != nil {
1572		return false
1573	}
1574
1575	if mr.MessageDetails == nil {
1576		return false
1577	}
1578
1579	// Must read UnverifiedBody to EOF to trigger signature verification
1580	_, _ = io.ReadAll(mr.MessageDetails.UnverifiedBody)
1581
1582	return mr.MessageDetails.SignatureError == nil
1583}