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