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