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