fetcher.go

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