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 by Date descending (newest first)
 311		// UID ordering is not reliable for all IMAP servers (e.g. Proton Bridge)
 312		for i := 0; i < len(batchEmails); i++ {
 313			for j := i + 1; j < len(batchEmails); j++ {
 314				if batchEmails[j].Date.After(batchEmails[i].Date) {
 315					batchEmails[i], batchEmails[j] = batchEmails[j], batchEmails[i]
 316				}
 317			}
 318		}
 319
 320		// Append to allEmails
 321		allEmails = append(allEmails, batchEmails...)
 322
 323		// Update cursor for next iteration
 324		cursor = from - 1
 325	}
 326
 327	// Trim if we have too many
 328	if len(allEmails) > int(limit) {
 329		allEmails = allEmails[:limit]
 330	}
 331
 332	return allEmails, nil
 333}
 334
 335func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) {
 336	c, err := connect(account)
 337	if err != nil {
 338		return "", nil, err
 339	}
 340	defer c.Logout()
 341
 342	if _, err := c.Select(mailbox, false); err != nil {
 343		return "", nil, err
 344	}
 345
 346	seqset := new(imap.SeqSet)
 347	seqset.AddNum(uid)
 348
 349	fetchWholeMessage := func() ([]byte, error) {
 350		fetchItem := imap.FetchItem("BODY.PEEK[]")
 351		section, _ := imap.ParseBodySectionName(fetchItem)
 352		partMessages := make(chan *imap.Message, 1)
 353		partDone := make(chan error, 1)
 354		go func() {
 355			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
 356		}()
 357		if err := <-partDone; err != nil {
 358			return nil, err
 359		}
 360		partMsg := <-partMessages
 361		if partMsg != nil {
 362			literal := partMsg.GetBody(section)
 363			if literal != nil {
 364				return ioutil.ReadAll(literal)
 365			}
 366		}
 367		return nil, fmt.Errorf("could not fetch whole message")
 368	}
 369
 370	fetchInlinePart := func(partID, encoding string) ([]byte, error) {
 371		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
 372		section, err := imap.ParseBodySectionName(fetchItem)
 373		if err != nil {
 374			return nil, err
 375		}
 376
 377		partMessages := make(chan *imap.Message, 1)
 378		partDone := make(chan error, 1)
 379		go func() {
 380			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
 381		}()
 382
 383		if err := <-partDone; err != nil {
 384			return nil, err
 385		}
 386
 387		partMsg := <-partMessages
 388		if partMsg == nil {
 389			return nil, fmt.Errorf("could not fetch inline part %s", partID)
 390		}
 391
 392		literal := partMsg.GetBody(section)
 393		if literal == nil {
 394			return nil, fmt.Errorf("could not get inline part body %s", partID)
 395		}
 396
 397		rawBytes, err := ioutil.ReadAll(literal)
 398		if err != nil {
 399			return nil, err
 400		}
 401
 402		return decodeAttachmentData(rawBytes, encoding)
 403	}
 404
 405	messages := make(chan *imap.Message, 1)
 406	done := make(chan error, 1)
 407	fetchItems := []imap.FetchItem{imap.FetchBodyStructure}
 408	go func() {
 409		done <- c.UidFetch(seqset, fetchItems, messages)
 410	}()
 411
 412	if err := <-done; err != nil {
 413		return "", nil, err
 414	}
 415
 416	msg := <-messages
 417	if msg == nil || msg.BodyStructure == nil {
 418		return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
 419	}
 420
 421	var plainPartID, plainPartEncoding string
 422	var htmlPartID, htmlPartEncoding string
 423	var attachments []Attachment
 424	var extractedBody string // Used if we intercept and decrypt a payload
 425
 426	var checkPart func(part *imap.BodyStructure, partID string)
 427	checkPart = func(part *imap.BodyStructure, partID string) {
 428		// Check for text content (prefer html over plain)
 429		if part.MIMEType == "text" {
 430			sub := strings.ToLower(part.MIMESubType)
 431			switch sub {
 432			case "html":
 433				if htmlPartID == "" {
 434					htmlPartID = partID
 435					htmlPartEncoding = part.Encoding
 436				}
 437			case "plain":
 438				if plainPartID == "" {
 439					plainPartID = partID
 440					plainPartEncoding = part.Encoding
 441				}
 442			}
 443		}
 444
 445		// Check for attachments using multiple methods
 446		filename := ""
 447		// First try the Filename() method which handles various cases
 448		if fn, err := part.Filename(); err == nil && fn != "" {
 449			filename = fn
 450		}
 451		// Fallback: check DispositionParams
 452		if filename == "" {
 453			if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
 454				filename = fn
 455			}
 456		}
 457		// Fallback: check Params (for name parameter)
 458		if filename == "" {
 459			if fn, ok := part.Params["name"]; ok && fn != "" {
 460				filename = fn
 461			}
 462		}
 463		// Fallback: check Params for filename
 464		if filename == "" {
 465			if fn, ok := part.Params["filename"]; ok && fn != "" {
 466				filename = fn
 467			}
 468		}
 469
 470		// Add as attachment if it has a disposition or a filename (and not just plain text).
 471		// Allow inline parts without filenames (common for cid images).
 472		contentID := strings.Trim(part.Id, "<>")
 473		mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType))
 474		isCID := contentID != ""
 475		isInline := part.Disposition == "inline" || isCID
 476
 477		if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
 478			filename = "inline"
 479		}
 480
 481		// === S/MIME ENCRYPTION AND OPAQUE VERIFICATION ===
 482		if filename == "smime.p7m" || mimeType == "application/pkcs7-mime" {
 483			data, err := fetchInlinePart(partID, part.Encoding)
 484			if err != nil && partID == "1" {
 485				// Fallback for single-part messages where PEEK[1] fails
 486				data, err = fetchInlinePart("TEXT", part.Encoding)
 487			}
 488
 489			if err != nil {
 490				extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to fetch encrypted part from IMAP server: %v\n", err)
 491				htmlPartID = "extracted"
 492			} else {
 493				p7, parseErr := pkcs7.Parse(data)
 494				if parseErr != nil {
 495					// Fallback: IMAP servers sometimes drop the transfer-encoding header.
 496					// We manually strip newlines and attempt a base64 decode just in case.
 497					cleanData := bytes.ReplaceAll(data, []byte("\n"), []byte(""))
 498					cleanData = bytes.ReplaceAll(cleanData, []byte("\r"), []byte(""))
 499					if decoded, b64err := base64.StdEncoding.DecodeString(string(cleanData)); b64err == nil {
 500						p7, parseErr = pkcs7.Parse(decoded)
 501					}
 502				}
 503
 504				if parseErr != nil {
 505					extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to parse PKCS7 payload: %v\n", parseErr)
 506					htmlPartID = "extracted"
 507				} else {
 508					var innerBytes []byte
 509					isEncrypted, isOpaqueSigned, smimeTrusted := false, false, false
 510					decryptionErr := ""
 511
 512					// 1. Try to Decrypt
 513					if account.SMIMECert != "" && account.SMIMEKey != "" {
 514						cData, err1 := os.ReadFile(account.SMIMECert)
 515						kData, err2 := os.ReadFile(account.SMIMEKey)
 516						if err1 != nil || err2 != nil {
 517							decryptionErr = fmt.Sprintf("Failed to read cert/key files. Cert: %v, Key: %v", err1, err2)
 518						} else {
 519							cBlock, _ := pem.Decode(cData)
 520							kBlock, _ := pem.Decode(kData)
 521							if cBlock == nil || kBlock == nil {
 522								decryptionErr = "Failed to decode PEM blocks from cert/key files."
 523							} else {
 524								cert, err3 := x509.ParseCertificate(cBlock.Bytes)
 525								var privKey any
 526								var err4 error
 527								if key, err := x509.ParsePKCS8PrivateKey(kBlock.Bytes); err == nil {
 528									privKey = key
 529								} else if key, err := x509.ParsePKCS1PrivateKey(kBlock.Bytes); err == nil {
 530									privKey = key
 531								} else if key, err := x509.ParseECPrivateKey(kBlock.Bytes); err == nil {
 532									privKey = key
 533								} else {
 534									err4 = errors.New("unsupported private key format")
 535								}
 536
 537								if err3 != nil || err4 != nil {
 538									decryptionErr = fmt.Sprintf("Failed to parse cert/key. Cert: %v, Key: %v", err3, err4)
 539								} else {
 540									dec, err := p7.Decrypt(cert, privKey)
 541									if err == nil {
 542										innerBytes = dec
 543										isEncrypted = true
 544									} else {
 545										decryptionErr = fmt.Sprintf("PKCS7 Decrypt failed: %v", err)
 546									}
 547								}
 548							}
 549						}
 550					} else {
 551						// Only set error if it actually is enveloped data (encrypted)
 552						// If it's just opaque signed, we shouldn't error out.
 553						decryptionErr = "S/MIME Cert or Key path is missing in settings."
 554					}
 555
 556					// 2. If not encrypted, check if it's an opaque signature
 557					if !isEncrypted && len(p7.Signers) > 0 {
 558						isOpaqueSigned = true
 559						innerBytes = p7.Content
 560						decryptionErr = "" // Clear encryption error because it wasn't encrypted to begin with
 561						roots, _ := x509.SystemCertPool()
 562						if roots == nil {
 563							roots = x509.NewCertPool()
 564						}
 565						if err := p7.VerifyWithChain(roots); err == nil {
 566							smimeTrusted = true
 567						}
 568					}
 569
 570					// 3. Parse Inner MIME payload
 571					if len(innerBytes) > 0 {
 572						mr, err := mail.CreateReader(bytes.NewReader(innerBytes))
 573						if err == nil {
 574							for {
 575								p, err := mr.NextPart()
 576								if err != nil {
 577									break
 578								}
 579								cType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
 580								disp, dParams, _ := mime.ParseMediaType(p.Header.Get("Content-Disposition"))
 581								b, _ := ioutil.ReadAll(p.Body) // Auto-decodes quoted-printable/base64
 582
 583								if disp == "attachment" || disp == "inline" || (!strings.HasPrefix(cType, "multipart/") && cType != "text/plain" && cType != "text/html") {
 584									fn := dParams["filename"]
 585									if fn == "" {
 586										_, cp, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
 587										fn = cp["name"]
 588									}
 589									attachments = append(attachments, Attachment{
 590										Filename: fn, Data: b, MIMEType: cType, Inline: disp == "inline",
 591									})
 592								} else {
 593									if cType == "text/html" {
 594										extractedBody = string(b)
 595										htmlPartID = "extracted" // Skip IMAP fetch
 596									} else if cType == "text/plain" && extractedBody == "" {
 597										extractedBody = string(b)
 598										plainPartID = "extracted"
 599									}
 600								}
 601							}
 602						} else {
 603							extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to read inner decrypted MIME: %v\n\n```\n%s\n```", err, string(innerBytes))
 604							htmlPartID = "extracted"
 605						}
 606
 607						attachments = append(attachments, Attachment{
 608							Filename:         "smime-status.internal",
 609							IsSMIMESignature: isOpaqueSigned,
 610							SMIMEVerified:    smimeTrusted,
 611							IsSMIMEEncrypted: isEncrypted,
 612						})
 613						return // Stop checking IMAP structure, we hijacked it
 614					} else {
 615						extractedBody = fmt.Sprintf("**S/MIME Decryption Failed:** %s\n", decryptionErr)
 616						htmlPartID = "extracted"
 617					}
 618				}
 619			}
 620		}
 621
 622		// === S/MIME DETACHED SIGNATURE VERIFICATION ===
 623		if filename == "smime.p7s" || mimeType == "application/pkcs7-signature" {
 624			att := Attachment{
 625				Filename:         filename,
 626				PartID:           partID,
 627				Encoding:         part.Encoding,
 628				MIMEType:         mimeType,
 629				ContentID:        contentID,
 630				Inline:           isInline,
 631				IsSMIMESignature: true,
 632			}
 633			if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
 634				att.Data = data
 635				p7, err := pkcs7.Parse(data)
 636				if err == nil {
 637					boundary := msg.BodyStructure.Params["boundary"]
 638					if boundary != "" {
 639						rawEmail, err := fetchWholeMessage()
 640						if err == nil {
 641							fullBoundary := []byte("--" + boundary)
 642							firstIdx := bytes.Index(rawEmail, fullBoundary)
 643							if firstIdx != -1 {
 644								startIdx := firstIdx + len(fullBoundary)
 645								if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' {
 646									startIdx++
 647								}
 648								if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' {
 649									startIdx++
 650								}
 651								secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary)
 652								if secondIdx != -1 {
 653									endIdx := startIdx + secondIdx
 654									if endIdx > 0 && rawEmail[endIdx-1] == '\n' {
 655										endIdx--
 656									}
 657									if endIdx > 0 && rawEmail[endIdx-1] == '\r' {
 658										endIdx--
 659									}
 660									signedData := rawEmail[startIdx:endIdx]
 661									canonical := bytes.ReplaceAll(signedData, []byte("\r\n"), []byte("\n"))
 662									canonical = bytes.ReplaceAll(canonical, []byte("\n"), []byte("\r\n"))
 663
 664									roots, _ := x509.SystemCertPool()
 665									if roots == nil {
 666										roots = x509.NewCertPool()
 667									}
 668
 669									p7.Content = canonical
 670									if err := p7.VerifyWithChain(roots); err == nil {
 671										att.SMIMEVerified = true
 672									} else {
 673										p7.Content = append(canonical, '\r', '\n')
 674										if err := p7.VerifyWithChain(roots); err == nil {
 675											att.SMIMEVerified = true
 676										} else {
 677											p7.Content = bytes.TrimRight(canonical, "\r\n")
 678											if err := p7.VerifyWithChain(roots); err == nil {
 679												att.SMIMEVerified = true
 680											}
 681										}
 682									}
 683								}
 684							}
 685						}
 686					}
 687				}
 688			}
 689			attachments = append(attachments, att)
 690		} else if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") {
 691			att := Attachment{
 692				Filename:  filename,
 693				PartID:    partID,
 694				Encoding:  part.Encoding, // Store encoding for proper decoding
 695				MIMEType:  mimeType,
 696				ContentID: contentID,
 697				Inline:    isInline,
 698			}
 699			if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
 700				if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
 701					att.Data = data
 702				}
 703			}
 704			attachments = append(attachments, att)
 705		}
 706	}
 707
 708	var findParts func(*imap.BodyStructure, string)
 709	findParts = func(bs *imap.BodyStructure, prefix string) {
 710		// If this is a non-multipart message, check the body structure itself
 711		if len(bs.Parts) == 0 {
 712			partID := prefix
 713			if partID == "" {
 714				partID = "1"
 715			}
 716			checkPart(bs, partID)
 717			return
 718		}
 719
 720		// Iterate through parts
 721		for i, part := range bs.Parts {
 722			partID := fmt.Sprintf("%d", i+1)
 723			if prefix != "" {
 724				partID = fmt.Sprintf("%s.%d", prefix, i+1)
 725			}
 726
 727			checkPart(part, partID)
 728
 729			if len(part.Parts) > 0 {
 730				findParts(part, partID)
 731			}
 732		}
 733	}
 734	findParts(msg.BodyStructure, "")
 735
 736	// If we hijacked and decrypted the body, return it immediately
 737	if extractedBody != "" {
 738		return extractedBody, attachments, nil
 739	}
 740
 741	var body string
 742	textPartID := ""
 743	textPartEncoding := ""
 744	if htmlPartID != "" {
 745		textPartID = htmlPartID
 746		textPartEncoding = htmlPartEncoding
 747	} else if plainPartID != "" {
 748		textPartID = plainPartID
 749		textPartEncoding = plainPartEncoding
 750	}
 751	if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
 752		msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
 753		fmt.Print(msg)
 754		if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
 755			if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
 756				_, _ = f.WriteString(msg)
 757				_ = f.Close()
 758			}
 759		}
 760	}
 761	if textPartID != "" {
 762		partMessages := make(chan *imap.Message, 1)
 763		partDone := make(chan error, 1)
 764
 765		fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID))
 766		section, err := imap.ParseBodySectionName(fetchItem)
 767		if err != nil {
 768			return "", nil, err
 769		}
 770
 771		go func() {
 772			partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages)
 773		}()
 774
 775		if err := <-partDone; err != nil {
 776			return "", nil, err
 777		}
 778
 779		partMsg := <-partMessages
 780		if partMsg != nil {
 781			literal := partMsg.GetBody(section)
 782			if literal != nil {
 783				buf, _ := ioutil.ReadAll(literal)
 784				// Use the encoding from BodyStructure to decode
 785				if decoded, err := decodeAttachmentData(buf, textPartEncoding); err == nil {
 786					body = string(decoded)
 787				} else {
 788					body = string(buf)
 789				}
 790			}
 791		}
 792	}
 793
 794	return body, attachments, nil
 795}
 796
 797func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
 798	c, err := connect(account)
 799	if err != nil {
 800		return nil, err
 801	}
 802	defer c.Logout()
 803
 804	if _, err := c.Select(mailbox, false); err != nil {
 805		return nil, err
 806	}
 807
 808	seqset := new(imap.SeqSet)
 809	seqset.AddNum(uid)
 810
 811	fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID))
 812	section, err := imap.ParseBodySectionName(fetchItem)
 813	if err != nil {
 814		return nil, err
 815	}
 816
 817	messages := make(chan *imap.Message, 1)
 818	done := make(chan error, 1)
 819	go func() {
 820		done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages)
 821	}()
 822
 823	if err := <-done; err != nil {
 824		return nil, err
 825	}
 826
 827	msg := <-messages
 828	if msg == nil {
 829		return nil, fmt.Errorf("could not fetch attachment")
 830	}
 831
 832	literal := msg.GetBody(section)
 833	if literal == nil {
 834		return nil, fmt.Errorf("could not get attachment body")
 835	}
 836
 837	rawBytes, err := ioutil.ReadAll(literal)
 838	if err != nil {
 839		return nil, err
 840	}
 841
 842	decoded, err := decodeAttachmentData(rawBytes, encoding)
 843	if err != nil {
 844		return rawBytes, nil
 845	}
 846	return decoded, nil
 847}
 848
 849func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
 850	c, err := connect(account)
 851	if err != nil {
 852		return err
 853	}
 854	defer c.Logout()
 855
 856	if _, err := c.Select(sourceMailbox, false); err != nil {
 857		return err
 858	}
 859
 860	seqSet := new(imap.SeqSet)
 861	seqSet.AddNum(uid)
 862
 863	return c.UidMove(seqSet, destMailbox)
 864}
 865
 866func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
 867	c, err := connect(account)
 868	if err != nil {
 869		return err
 870	}
 871	defer c.Logout()
 872
 873	if _, err := c.Select(mailbox, false); err != nil {
 874		return err
 875	}
 876
 877	seqSet := new(imap.SeqSet)
 878	seqSet.AddNum(uid)
 879
 880	item := imap.FormatFlagsOp(imap.AddFlags, true)
 881	flags := []interface{}{imap.DeletedFlag}
 882
 883	if err := c.UidStore(seqSet, item, flags, nil); err != nil {
 884		return err
 885	}
 886
 887	return c.Expunge(nil)
 888}
 889
 890func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
 891	c, err := connect(account)
 892	if err != nil {
 893		return err
 894	}
 895	defer c.Logout()
 896
 897	var archiveMailbox string
 898	switch account.ServiceProvider {
 899	case "gmail":
 900		// For Gmail, find the mailbox with the \All attribute
 901		archiveMailbox, err = getMailboxByAttr(c, imap.AllAttr)
 902		if err != nil {
 903			// Fallback to hardcoded path if attribute lookup fails
 904			archiveMailbox = "[Gmail]/All Mail"
 905		}
 906	default:
 907		archiveMailbox = "Archive"
 908	}
 909
 910	if _, err := c.Select(mailbox, false); err != nil {
 911		return err
 912	}
 913
 914	seqSet := new(imap.SeqSet)
 915	seqSet.AddNum(uid)
 916
 917	return c.UidMove(seqSet, archiveMailbox)
 918}
 919
 920// Convenience wrappers defaulting to INBOX for existing call sites.
 921
 922func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
 923	return FetchMailboxEmails(account, "INBOX", limit, offset)
 924}
 925
 926func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
 927	return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
 928}
 929
 930func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
 931	return FetchEmailBodyFromMailbox(account, "INBOX", uid)
 932}
 933
 934func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
 935	return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
 936}
 937
 938func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
 939	return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
 940}
 941
 942func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
 943	return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
 944}
 945
 946func DeleteEmail(account *config.Account, uid uint32) error {
 947	return DeleteEmailFromMailbox(account, "INBOX", uid)
 948}
 949
 950func DeleteSentEmail(account *config.Account, uid uint32) error {
 951	return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
 952}
 953
 954func ArchiveEmail(account *config.Account, uid uint32) error {
 955	return ArchiveEmailFromMailbox(account, "INBOX", uid)
 956}
 957
 958func ArchiveSentEmail(account *config.Account, uid uint32) error {
 959	return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
 960}
 961
 962// getTrashMailbox returns the trash mailbox name for the account
 963func getTrashMailbox(account *config.Account) string {
 964	switch account.ServiceProvider {
 965	case "gmail":
 966		return "[Gmail]/Trash"
 967	case "icloud":
 968		return "Deleted Messages"
 969	default:
 970		return "Trash"
 971	}
 972}
 973
 974// getArchiveMailbox returns the archive/all mail mailbox name for the account
 975func getArchiveMailbox(account *config.Account) string {
 976	switch account.ServiceProvider {
 977	case "gmail":
 978		return "[Gmail]/All Mail"
 979	case "icloud":
 980		return "Archive"
 981	default:
 982		return "Archive"
 983	}
 984}
 985
 986// FetchTrashEmails fetches emails from the trash folder
 987func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
 988	c, err := connect(account)
 989	if err != nil {
 990		return nil, err
 991	}
 992	defer c.Logout()
 993
 994	// Try to find trash by attribute first
 995	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
 996	if err != nil {
 997		// Fallback to hardcoded path
 998		trashMailbox = getTrashMailbox(account)
 999	}
1000
1001	return FetchMailboxEmails(account, trashMailbox, limit, offset)
1002}
1003
1004// FetchArchiveEmails fetches emails from the archive/all mail folder
1005// Archive contains all emails, so we match where user is sender OR recipient
1006func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1007	c, err := connect(account)
1008	if err != nil {
1009		return nil, err
1010	}
1011	defer c.Logout()
1012
1013	// Try to find archive by attribute first (Gmail uses \All)
1014	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1015	if err != nil {
1016		// Fallback to hardcoded path
1017		archiveMailbox = getArchiveMailbox(account)
1018	}
1019
1020	mbox, err := c.Select(archiveMailbox, false)
1021	if err != nil {
1022		return nil, err
1023	}
1024
1025	if mbox.Messages == 0 {
1026		return []Email{}, nil
1027	}
1028
1029	to := mbox.Messages - offset
1030	from := uint32(1)
1031	if to > limit {
1032		from = to - limit + 1
1033	}
1034
1035	if to < 1 {
1036		return []Email{}, nil
1037	}
1038
1039	seqset := new(imap.SeqSet)
1040	seqset.AddRange(from, to)
1041
1042	messages := make(chan *imap.Message, limit)
1043	done := make(chan error, 1)
1044	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
1045	go func() {
1046		done <- c.Fetch(seqset, fetchItems, messages)
1047	}()
1048
1049	var msgs []*imap.Message
1050	for msg := range messages {
1051		msgs = append(msgs, msg)
1052	}
1053
1054	if err := <-done; err != nil {
1055		return nil, err
1056	}
1057
1058	// Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
1059	fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
1060	if fetchEmail == "" {
1061		fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
1062	}
1063
1064	var emails []Email
1065	for _, msg := range msgs {
1066		if msg == nil || msg.Envelope == nil {
1067			continue
1068		}
1069
1070		var fromAddr string
1071		if len(msg.Envelope.From) > 0 {
1072			fromAddr = msg.Envelope.From[0].Address()
1073		}
1074
1075		var toAddrList []string
1076		for _, addr := range msg.Envelope.To {
1077			toAddrList = append(toAddrList, addr.Address())
1078		}
1079		for _, addr := range msg.Envelope.Cc {
1080			toAddrList = append(toAddrList, addr.Address())
1081		}
1082
1083		// For archive/All Mail, match emails where user is sender OR recipient
1084		matched := false
1085		// Check if user is the sender
1086		if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) {
1087			matched = true
1088		}
1089		// Check if user is a recipient
1090		if !matched {
1091			for _, r := range toAddrList {
1092				if strings.EqualFold(strings.TrimSpace(r), fetchEmail) {
1093					matched = true
1094					break
1095				}
1096			}
1097		}
1098
1099		if !matched {
1100			continue
1101		}
1102
1103		emails = append(emails, Email{
1104			UID:       msg.Uid,
1105			From:      fromAddr,
1106			To:        toAddrList,
1107			Subject:   decodeHeader(msg.Envelope.Subject),
1108			Date:      msg.Envelope.Date,
1109			AccountID: account.ID,
1110		})
1111	}
1112
1113	// Reverse to get newest first
1114	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
1115		emails[i], emails[j] = emails[j], emails[i]
1116	}
1117
1118	return emails, nil
1119}
1120
1121// FetchTrashEmailBody fetches the body of an email from trash
1122func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1123	c, err := connect(account)
1124	if err != nil {
1125		return "", nil, err
1126	}
1127	defer c.Logout()
1128
1129	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1130	if err != nil {
1131		trashMailbox = getTrashMailbox(account)
1132	}
1133
1134	return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
1135}
1136
1137// FetchArchiveEmailBody fetches the body of an email from archive
1138func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) {
1139	c, err := connect(account)
1140	if err != nil {
1141		return "", nil, err
1142	}
1143	defer c.Logout()
1144
1145	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1146	if err != nil {
1147		archiveMailbox = getArchiveMailbox(account)
1148	}
1149
1150	return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
1151}
1152
1153// FetchTrashAttachment fetches an attachment from trash
1154func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1155	c, err := connect(account)
1156	if err != nil {
1157		return nil, err
1158	}
1159	defer c.Logout()
1160
1161	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1162	if err != nil {
1163		trashMailbox = getTrashMailbox(account)
1164	}
1165
1166	return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
1167}
1168
1169// FetchArchiveAttachment fetches an attachment from archive
1170func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1171	c, err := connect(account)
1172	if err != nil {
1173		return nil, err
1174	}
1175	defer c.Logout()
1176
1177	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1178	if err != nil {
1179		archiveMailbox = getArchiveMailbox(account)
1180	}
1181
1182	return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
1183}
1184
1185// DeleteTrashEmail permanently deletes an email from trash
1186func DeleteTrashEmail(account *config.Account, uid uint32) error {
1187	c, err := connect(account)
1188	if err != nil {
1189		return err
1190	}
1191	defer c.Logout()
1192
1193	trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr)
1194	if err != nil {
1195		trashMailbox = getTrashMailbox(account)
1196	}
1197
1198	return DeleteEmailFromMailbox(account, trashMailbox, uid)
1199}
1200
1201// DeleteArchiveEmail deletes an email from archive (moves to trash)
1202func DeleteArchiveEmail(account *config.Account, uid uint32) error {
1203	c, err := connect(account)
1204	if err != nil {
1205		return err
1206	}
1207	defer c.Logout()
1208
1209	archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr)
1210	if err != nil {
1211		archiveMailbox = getArchiveMailbox(account)
1212	}
1213
1214	return DeleteEmailFromMailbox(account, archiveMailbox, uid)
1215}