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