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