feat: caching, contacts, drafts

drew created

Change summary

config/cache.go      | 377 ++++++++++++++++++++++++++++++++++++++++++++++
main.go              | 218 ++++++++++++++++++++++++++
matcha               |   0 
tui/choice.go        |  12 +
tui/composer.go      | 185 +++++++++++++++++++++
tui/composer_test.go | 118 ++++++++++++++
tui/drafts.go        | 218 ++++++++++++++++++++++++++
tui/inbox.go         |  62 ++++++
tui/messages.go      |  60 +++++++
9 files changed, 1,228 insertions(+), 22 deletions(-)

Detailed changes

config/cache.go 🔗

@@ -0,0 +1,377 @@
+package config
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"time"
+)
+
+// CachedEmail stores essential email data for caching.
+type CachedEmail struct {
+	UID       uint32    `json:"uid"`
+	From      string    `json:"from"`
+	To        []string  `json:"to"`
+	Subject   string    `json:"subject"`
+	Date      time.Time `json:"date"`
+	MessageID string    `json:"message_id"`
+	AccountID string    `json:"account_id"`
+}
+
+// EmailCache stores cached emails for all accounts.
+type EmailCache struct {
+	Emails    []CachedEmail `json:"emails"`
+	UpdatedAt time.Time     `json:"updated_at"`
+}
+
+// cacheFile returns the full path to the email cache file.
+func cacheFile() (string, error) {
+	dir, err := configDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(dir, "email_cache.json"), nil
+}
+
+// SaveEmailCache saves emails to the cache file.
+func SaveEmailCache(cache *EmailCache) error {
+	path, err := cacheFile()
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+		return err
+	}
+	cache.UpdatedAt = time.Now()
+	data, err := json.MarshalIndent(cache, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0600)
+}
+
+// LoadEmailCache loads emails from the cache file.
+func LoadEmailCache() (*EmailCache, error) {
+	path, err := cacheFile()
+	if err != nil {
+		return nil, err
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	var cache EmailCache
+	if err := json.Unmarshal(data, &cache); err != nil {
+		return nil, err
+	}
+	return &cache, nil
+}
+
+// HasEmailCache checks if a cache file exists.
+func HasEmailCache() bool {
+	path, err := cacheFile()
+	if err != nil {
+		return false
+	}
+	_, err = os.Stat(path)
+	return err == nil
+}
+
+// ClearEmailCache removes the cache file.
+func ClearEmailCache() error {
+	path, err := cacheFile()
+	if err != nil {
+		return err
+	}
+	return os.Remove(path)
+}
+
+// --- Contacts Cache ---
+
+// Contact stores a contact's name and email address.
+type Contact struct {
+	Name     string    `json:"name"`
+	Email    string    `json:"email"`
+	LastUsed time.Time `json:"last_used"`
+	UseCount int       `json:"use_count"`
+}
+
+// ContactsCache stores all known contacts.
+type ContactsCache struct {
+	Contacts  []Contact `json:"contacts"`
+	UpdatedAt time.Time `json:"updated_at"`
+}
+
+// contactsFile returns the full path to the contacts cache file.
+func contactsFile() (string, error) {
+	dir, err := configDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(dir, "contacts.json"), nil
+}
+
+// SaveContactsCache saves contacts to the cache file.
+func SaveContactsCache(cache *ContactsCache) error {
+	path, err := contactsFile()
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+		return err
+	}
+	cache.UpdatedAt = time.Now()
+	data, err := json.MarshalIndent(cache, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0600)
+}
+
+// LoadContactsCache loads contacts from the cache file.
+func LoadContactsCache() (*ContactsCache, error) {
+	path, err := contactsFile()
+	if err != nil {
+		return nil, err
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	var cache ContactsCache
+	if err := json.Unmarshal(data, &cache); err != nil {
+		return nil, err
+	}
+	return &cache, nil
+}
+
+// AddContact adds or updates a contact in the cache.
+func AddContact(name, email string) error {
+	if email == "" {
+		return nil
+	}
+
+	email = strings.ToLower(strings.TrimSpace(email))
+	name = strings.TrimSpace(name)
+
+	cache, err := LoadContactsCache()
+	if err != nil {
+		cache = &ContactsCache{Contacts: []Contact{}}
+	}
+
+	// Check if contact exists
+	found := false
+	for i, c := range cache.Contacts {
+		if strings.EqualFold(c.Email, email) {
+			// Update existing contact
+			cache.Contacts[i].UseCount++
+			cache.Contacts[i].LastUsed = time.Now()
+			// Update name if we have a better one
+			if name != "" && (c.Name == "" || c.Name == email) {
+				cache.Contacts[i].Name = name
+			}
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		cache.Contacts = append(cache.Contacts, Contact{
+			Name:     name,
+			Email:    email,
+			LastUsed: time.Now(),
+			UseCount: 1,
+		})
+	}
+
+	return SaveContactsCache(cache)
+}
+
+// SearchContacts searches for contacts matching the query.
+func SearchContacts(query string) []Contact {
+	cache, err := LoadContactsCache()
+	if err != nil {
+		return nil
+	}
+
+	query = strings.ToLower(strings.TrimSpace(query))
+	if query == "" {
+		return nil
+	}
+
+	var matches []Contact
+	for _, c := range cache.Contacts {
+		if strings.Contains(strings.ToLower(c.Email), query) ||
+			strings.Contains(strings.ToLower(c.Name), query) {
+			matches = append(matches, c)
+		}
+	}
+
+	// Sort by use count (most used first), then by last used
+	sort.Slice(matches, func(i, j int) bool {
+		if matches[i].UseCount != matches[j].UseCount {
+			return matches[i].UseCount > matches[j].UseCount
+		}
+		return matches[i].LastUsed.After(matches[j].LastUsed)
+	})
+
+	// Limit to 5 suggestions
+	if len(matches) > 5 {
+		matches = matches[:5]
+	}
+
+	return matches
+}
+
+// --- Drafts Cache ---
+
+// Draft stores a saved email draft.
+type Draft struct {
+	ID             string    `json:"id"`
+	To             string    `json:"to"`
+	Subject        string    `json:"subject"`
+	Body           string    `json:"body"`
+	AttachmentPath string    `json:"attachment_path,omitempty"`
+	AccountID      string    `json:"account_id"`
+	InReplyTo      string    `json:"in_reply_to,omitempty"`
+	References     []string  `json:"references,omitempty"`
+	CreatedAt      time.Time `json:"created_at"`
+	UpdatedAt      time.Time `json:"updated_at"`
+}
+
+// DraftsCache stores all saved drafts.
+type DraftsCache struct {
+	Drafts    []Draft   `json:"drafts"`
+	UpdatedAt time.Time `json:"updated_at"`
+}
+
+// draftsFile returns the full path to the drafts cache file.
+func draftsFile() (string, error) {
+	dir, err := configDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(dir, "drafts.json"), nil
+}
+
+// SaveDraftsCache saves drafts to the cache file.
+func SaveDraftsCache(cache *DraftsCache) error {
+	path, err := draftsFile()
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+		return err
+	}
+	cache.UpdatedAt = time.Now()
+	data, err := json.MarshalIndent(cache, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(path, data, 0600)
+}
+
+// LoadDraftsCache loads drafts from the cache file.
+func LoadDraftsCache() (*DraftsCache, error) {
+	path, err := draftsFile()
+	if err != nil {
+		return nil, err
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	var cache DraftsCache
+	if err := json.Unmarshal(data, &cache); err != nil {
+		return nil, err
+	}
+	return &cache, nil
+}
+
+// SaveDraft saves or updates a draft.
+func SaveDraft(draft Draft) error {
+	cache, err := LoadDraftsCache()
+	if err != nil {
+		cache = &DraftsCache{Drafts: []Draft{}}
+	}
+
+	draft.UpdatedAt = time.Now()
+
+	// Check if draft exists (update) or is new
+	found := false
+	for i, d := range cache.Drafts {
+		if d.ID == draft.ID {
+			cache.Drafts[i] = draft
+			found = true
+			break
+		}
+	}
+
+	if !found {
+		if draft.CreatedAt.IsZero() {
+			draft.CreatedAt = time.Now()
+		}
+		cache.Drafts = append(cache.Drafts, draft)
+	}
+
+	return SaveDraftsCache(cache)
+}
+
+// DeleteDraft removes a draft by ID.
+func DeleteDraft(id string) error {
+	cache, err := LoadDraftsCache()
+	if err != nil {
+		return nil // No cache, nothing to delete
+	}
+
+	var filtered []Draft
+	for _, d := range cache.Drafts {
+		if d.ID != id {
+			filtered = append(filtered, d)
+		}
+	}
+	cache.Drafts = filtered
+
+	return SaveDraftsCache(cache)
+}
+
+// GetDraft retrieves a draft by ID.
+func GetDraft(id string) *Draft {
+	cache, err := LoadDraftsCache()
+	if err != nil {
+		return nil
+	}
+
+	for _, d := range cache.Drafts {
+		if d.ID == id {
+			return &d
+		}
+	}
+	return nil
+}
+
+// GetAllDrafts retrieves all drafts sorted by update time (newest first).
+func GetAllDrafts() []Draft {
+	cache, err := LoadDraftsCache()
+	if err != nil {
+		return nil
+	}
+
+	drafts := cache.Drafts
+	sort.Slice(drafts, func(i, j int) bool {
+		return drafts[i].UpdatedAt.After(drafts[j].UpdatedAt)
+	})
+
+	return drafts
+}
+
+// HasDrafts checks if there are any saved drafts.
+func HasDrafts() bool {
+	cache, err := LoadDraftsCache()
+	if err != nil {
+		return false
+	}
+	return len(cache.Drafts) > 0
+}

main.go 🔗

@@ -41,7 +41,7 @@ type mainModel struct {
 }
 
 func newInitialModel(cfg *config.Config) *mainModel {
-	hasCache := false
+	hasCache := config.HasEmailCache()
 	initialModel := &mainModel{
 		emailsByAcct: make(map[string][]fetcher.Email),
 	}
@@ -96,6 +96,15 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	case tui.DiscardDraftMsg:
 		m.cachedComposer = msg.ComposerState
+		// Save draft to disk
+		if msg.ComposerState != nil {
+			draft := msg.ComposerState.ToDraft()
+			go func() {
+				if err := config.SaveDraft(draft); err != nil {
+					log.Printf("Error saving draft: %v", err)
+				}
+			}()
+		}
 		m.current = tui.NewChoice(true)
 		return m, m.current.Init()
 
@@ -156,9 +165,82 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.current = tui.NewLogin()
 			return m, m.current.Init()
 		}
+		// Try to load from cache first for instant display
+		if config.HasEmailCache() {
+			return m, loadCachedEmails()
+		}
+		// No cache, fetch normally
 		m.current = tui.NewStatus("Fetching emails from all accounts...")
 		return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config))
 
+	case tui.CachedEmailsLoadedMsg:
+		if msg.Cache == nil {
+			// Cache load failed, fetch normally
+			m.current = tui.NewStatus("Fetching emails from all accounts...")
+			return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config))
+		}
+
+		// Convert cached emails to fetcher.Email
+		var cachedEmails []fetcher.Email
+		emailsByAcct := make(map[string][]fetcher.Email)
+		for _, cached := range msg.Cache.Emails {
+			email := fetcher.Email{
+				UID:       cached.UID,
+				From:      cached.From,
+				To:        cached.To,
+				Subject:   cached.Subject,
+				Date:      cached.Date,
+				MessageID: cached.MessageID,
+				AccountID: cached.AccountID,
+			}
+			cachedEmails = append(cachedEmails, email)
+			emailsByAcct[cached.AccountID] = append(emailsByAcct[cached.AccountID], email)
+		}
+
+		m.emails = cachedEmails
+		m.emailsByAcct = emailsByAcct
+		m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
+		m.current = m.inbox
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+
+		// Start background refresh
+		return m, tea.Batch(
+			m.current.Init(),
+			func() tea.Msg { return tui.RefreshingEmailsMsg{} },
+			refreshEmails(m.config),
+		)
+
+	case tui.EmailsRefreshedMsg:
+		m.emailsByAcct = msg.EmailsByAccount
+
+		// Flatten all emails
+		var allEmails []fetcher.Email
+		for _, emails := range msg.EmailsByAccount {
+			allEmails = append(allEmails, emails...)
+		}
+
+		// Sort by date (newest first)
+		for i := 0; i < len(allEmails); i++ {
+			for j := i + 1; j < len(allEmails); j++ {
+				if allEmails[j].Date.After(allEmails[i].Date) {
+					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
+				}
+			}
+		}
+
+		m.emails = allEmails
+
+		// Save to cache
+		go saveEmailsToCache(m.emails)
+
+		// Update inbox if it exists
+		if m.inbox != nil {
+			m.inbox.SetEmails(m.emails, m.config.Accounts)
+			// Forward the message to inbox to clear refreshing state
+			m.current, _ = m.current.Update(msg)
+		}
+		return m, nil
+
 	case tui.AllEmailsFetchedMsg:
 		m.emailsByAcct = msg.EmailsByAccount
 
@@ -178,6 +260,10 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 		m.emails = allEmails
+
+		// Save to cache
+		go saveEmailsToCache(m.emails)
+
 		m.inbox = tui.NewInbox(m.emails, m.config.Accounts)
 		m.current = m.inbox
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
@@ -249,6 +335,33 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
+	case tui.GoToDraftsMsg:
+		drafts := config.GetAllDrafts()
+		m.current = tui.NewDrafts(drafts)
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		return m, m.current.Init()
+
+	case tui.OpenDraftMsg:
+		m.cachedComposer = nil
+		var accounts []config.Account
+		if m.config != nil {
+			accounts = m.config.Accounts
+		}
+		composer := tui.NewComposerFromDraft(msg.Draft, accounts)
+		m.current = composer
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		return m, m.current.Init()
+
+	case tui.DeleteSavedDraftMsg:
+		go func() {
+			if err := config.DeleteDraft(msg.DraftID); err != nil {
+				log.Printf("Error deleting draft: %v", err)
+			}
+		}()
+		// Send message back to drafts view
+		m.current, cmd = m.current.Update(tui.DraftDeletedMsg{DraftID: msg.DraftID})
+		return m, cmd
+
 	case tui.GoToSettingsMsg:
 		if m.config != nil {
 			m.current = tui.NewSettings(m.config.Accounts)
@@ -354,6 +467,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, cmd)
 
 	case tui.SendEmailMsg:
+		// Get draft ID before clearing composer (if it's a composer)
+		var draftID string
+		if composer, ok := m.current.(*tui.Composer); ok {
+			draftID = composer.GetDraftID()
+		}
 		m.cachedComposer = nil
 		m.current = tui.NewStatus("Sending email...")
 
@@ -366,6 +484,24 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			account = m.config.GetFirstAccount()
 		}
 
+		// Save contact and delete draft in background
+		go func() {
+			// Save the recipient as a contact
+			if msg.To != "" {
+				// Parse "Name <email>" format
+				name, email := parseEmailAddress(msg.To)
+				if err := config.AddContact(name, email); err != nil {
+					log.Printf("Error saving contact: %v", err)
+				}
+			}
+			// Delete the draft since email is being sent
+			if draftID != "" {
+				if err := config.DeleteDraft(draftID); err != nil {
+					log.Printf("Error deleting draft after send: %v", err)
+				}
+			}
+		}()
+
 		return m, tea.Batch(m.current.Init(), sendEmail(account, msg))
 
 	case tui.EmailResultMsg:
@@ -566,6 +702,86 @@ func fetchEmails(account *config.Account, limit, offset uint32) tea.Cmd {
 	}
 }
 
+func loadCachedEmails() tea.Cmd {
+	return func() tea.Msg {
+		cache, err := config.LoadEmailCache()
+		if err != nil {
+			return tui.CachedEmailsLoadedMsg{Cache: nil}
+		}
+		return tui.CachedEmailsLoadedMsg{Cache: cache}
+	}
+}
+
+func refreshEmails(cfg *config.Config) tea.Cmd {
+	return func() tea.Msg {
+		emailsByAccount := make(map[string][]fetcher.Email)
+		var mu sync.Mutex
+		var wg sync.WaitGroup
+
+		for _, account := range cfg.Accounts {
+			wg.Add(1)
+			go func(acc config.Account) {
+				defer wg.Done()
+				emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0)
+				if err != nil {
+					log.Printf("Error fetching from %s: %v", acc.Email, err)
+					return
+				}
+				mu.Lock()
+				emailsByAccount[acc.ID] = emails
+				mu.Unlock()
+			}(account)
+		}
+
+		wg.Wait()
+		return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount}
+	}
+}
+
+func saveEmailsToCache(emails []fetcher.Email) {
+	var cachedEmails []config.CachedEmail
+	for _, email := range emails {
+		cachedEmails = append(cachedEmails, config.CachedEmail{
+			UID:       email.UID,
+			From:      email.From,
+			To:        email.To,
+			Subject:   email.Subject,
+			Date:      email.Date,
+			MessageID: email.MessageID,
+			AccountID: email.AccountID,
+		})
+
+		// Save sender as a contact
+		if email.From != "" {
+			name, emailAddr := parseEmailAddress(email.From)
+			if err := config.AddContact(name, emailAddr); err != nil {
+				log.Printf("Error saving contact from email: %v", err)
+			}
+		}
+	}
+	cache := &config.EmailCache{Emails: cachedEmails}
+	if err := config.SaveEmailCache(cache); err != nil {
+		log.Printf("Error saving email cache: %v", err)
+	}
+}
+
+// parseEmailAddress parses "Name <email>" or just "email" format
+func parseEmailAddress(addr string) (name, email string) {
+	addr = strings.TrimSpace(addr)
+	if idx := strings.Index(addr, "<"); idx != -1 {
+		name = strings.TrimSpace(addr[:idx])
+		endIdx := strings.Index(addr, ">")
+		if endIdx > idx {
+			email = strings.TrimSpace(addr[idx+1 : endIdx])
+		} else {
+			email = strings.TrimSpace(addr[idx+1:])
+		}
+	} else {
+		email = addr
+	}
+	return name, email
+}
+
 func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, accountID string) tea.Cmd {
 	return func() tea.Msg {
 		account := cfg.GetAccountByID(accountID)

tui/choice.go 🔗

@@ -6,6 +6,7 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
+	"github.com/floatpane/matcha/config"
 )
 
 // Styles defined locally to avoid import issues.
@@ -21,16 +22,23 @@ type Choice struct {
 	cursor         int
 	choices        []string
 	hasCachedDraft bool
+	hasSavedDrafts bool
 }
 
 func NewChoice(hasCachedDraft bool) Choice {
-	choices := []string{"View Inbox", "Compose Email", "Settings"}
+	hasSavedDrafts := config.HasDrafts()
+	choices := []string{"View Inbox", "Compose Email"}
+	if hasSavedDrafts {
+		choices = append(choices, "Drafts")
+	}
+	choices = append(choices, "Settings")
 	if hasCachedDraft {
 		choices = append(choices, "Restore Draft")
 	}
 	return Choice{
 		choices:        choices,
 		hasCachedDraft: hasCachedDraft,
+		hasSavedDrafts: hasSavedDrafts,
 	}
 }
 
@@ -57,6 +65,8 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return m, func() tea.Msg { return GoToInboxMsg{} }
 			case "Compose Email":
 				return m, func() tea.Msg { return GoToSendMsg{} }
+			case "Drafts":
+				return m, func() tea.Msg { return GoToDraftsMsg{} }
 			case "Settings":
 				return m, func() tea.Msg { return GoToSettingsMsg{} }
 			case "Restore Draft":

tui/composer.go 🔗

@@ -9,6 +9,13 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/floatpane/matcha/config"
+	"github.com/google/uuid"
+)
+
+var (
+	suggestionStyle         = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
+	selectedSuggestionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
+	suggestionBoxStyle      = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("240")).Padding(0, 1)
 )
 
 // Styles for the UI
@@ -49,11 +56,26 @@ type Composer struct {
 	accounts           []config.Account
 	selectedAccountIdx int
 	showAccountPicker  bool
+
+	// Contact suggestions
+	suggestions        []config.Contact
+	selectedSuggestion int
+	showSuggestions    bool
+	lastToValue        string
+
+	// Draft persistence
+	draftID string
+
+	// Reply context
+	inReplyTo  string
+	references []string
 }
 
 // NewComposer initializes a new composer model.
 func NewComposer(from, to, subject, body string) *Composer {
-	m := &Composer{}
+	m := &Composer{
+		draftID: uuid.New().String(),
+	}
 
 	m.toInput = textinput.New()
 	m.toInput.Cursor.Style = cursorStyle
@@ -149,6 +171,43 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 
 	case tea.KeyMsg:
+		// Handle contact suggestions mode
+		if m.showSuggestions && len(m.suggestions) > 0 {
+			switch msg.String() {
+			case "up", "ctrl+p":
+				if m.selectedSuggestion > 0 {
+					m.selectedSuggestion--
+				}
+				return m, nil
+			case "down", "ctrl+n":
+				if m.selectedSuggestion < len(m.suggestions)-1 {
+					m.selectedSuggestion++
+				}
+				return m, nil
+			case "tab", "enter":
+				// Select the suggestion
+				selected := m.suggestions[m.selectedSuggestion]
+				if selected.Name != "" && selected.Name != selected.Email {
+					m.toInput.SetValue(fmt.Sprintf("%s <%s>", selected.Name, selected.Email))
+				} else {
+					m.toInput.SetValue(selected.Email)
+				}
+				m.lastToValue = m.toInput.Value()
+				m.showSuggestions = false
+				m.suggestions = nil
+				return m, nil
+			case "esc":
+				m.showSuggestions = false
+				m.suggestions = nil
+				return m, nil
+			}
+			// For shift+tab, close suggestions and let it fall through to normal handling
+			if msg.Type == tea.KeyShiftTab {
+				m.showSuggestions = false
+				m.suggestions = nil
+			}
+		}
+
 		// Handle account picker mode
 		if m.showAccountPicker {
 			switch msg.String() {
@@ -195,9 +254,15 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 
 			maxFocus := focusSend
+			minFocus := focusFrom
+			// Skip From field if only one account (nothing to switch)
+			if len(m.accounts) <= 1 {
+				minFocus = focusTo
+			}
+
 			if m.focusIndex > maxFocus {
-				m.focusIndex = focusFrom
-			} else if m.focusIndex < focusFrom {
+				m.focusIndex = minFocus
+			} else if m.focusIndex < minFocus {
 				m.focusIndex = maxFocus
 			}
 
@@ -248,6 +313,20 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case focusTo:
 		m.toInput, cmd = m.toInput.Update(msg)
 		cmds = append(cmds, cmd)
+
+		// Check if To field value changed and update suggestions
+		currentValue := m.toInput.Value()
+		if currentValue != m.lastToValue {
+			m.lastToValue = currentValue
+			if len(currentValue) >= 2 {
+				m.suggestions = config.SearchContacts(currentValue)
+				m.showSuggestions = len(m.suggestions) > 0
+				m.selectedSuggestion = 0
+			} else {
+				m.showSuggestions = false
+				m.suggestions = nil
+			}
+		}
 	case focusSubject:
 		m.subjectInput, cmd = m.subjectInput.Update(msg)
 		cmds = append(cmds, cmd)
@@ -274,12 +353,14 @@ func (m *Composer) View() string {
 	var fromField string
 	if len(m.accounts) > 1 {
 		if m.focusIndex == focusFrom {
-			fromField = focusedStyle.Render(fmt.Sprintf("> From: %s (press Enter to change)", fromAddr))
+			fromField = focusedStyle.Render(fmt.Sprintf("> From: %s [Enter to switch]", fromAddr))
 		} else {
-			fromField = blurredStyle.Render(fmt.Sprintf("  From: %s", fromAddr))
+			fromField = blurredStyle.Render(fmt.Sprintf("  From: %s [switchable]", fromAddr))
 		}
+	} else if fromAddr != "" {
+		fromField = "  From: " + emailRecipientStyle.Render(fromAddr)
 	} else {
-		fromField = "From: " + emailRecipientStyle.Render(fromAddr)
+		fromField = blurredStyle.Render("  From: (no account configured)")
 	}
 
 	var attachmentField string
@@ -294,15 +375,33 @@ func (m *Composer) View() string {
 		attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachment: %s", attachmentText))
 	}
 
+	// Build To field with suggestions
+	toFieldView := m.toInput.View()
+	if m.showSuggestions && len(m.suggestions) > 0 {
+		var suggestionsBuilder strings.Builder
+		for i, s := range m.suggestions {
+			display := s.Email
+			if s.Name != "" && s.Name != s.Email {
+				display = fmt.Sprintf("%s <%s>", s.Name, s.Email)
+			}
+			if i == m.selectedSuggestion {
+				suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n")
+			} else {
+				suggestionsBuilder.WriteString(suggestionStyle.Render("  "+display) + "\n")
+			}
+		}
+		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
+	}
+
 	composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left,
 		"Compose New Email",
 		fromField,
-		m.toInput.View(),
+		toFieldView,
 		m.subjectInput.View(),
 		m.bodyInput.View(),
 		attachmentStyle.Render(attachmentField),
 		button,
-		helpStyle.Render("Markdown/HTML • tab: next field • esc: back to menu"),
+		helpStyle.Render("Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit"),
 	))
 
 	// Account picker overlay
@@ -366,3 +465,73 @@ func (m *Composer) GetSelectedAccountID() string {
 	}
 	return ""
 }
+
+// GetDraftID returns the draft ID for this composer.
+func (m *Composer) GetDraftID() string {
+	return m.draftID
+}
+
+// SetDraftID sets the draft ID (for loading existing drafts).
+func (m *Composer) SetDraftID(id string) {
+	m.draftID = id
+}
+
+// GetTo returns the current To field value.
+func (m *Composer) GetTo() string {
+	return m.toInput.Value()
+}
+
+// GetSubject returns the current Subject field value.
+func (m *Composer) GetSubject() string {
+	return m.subjectInput.Value()
+}
+
+// GetBody returns the current Body field value.
+func (m *Composer) GetBody() string {
+	return m.bodyInput.Value()
+}
+
+// GetAttachmentPath returns the current attachment path.
+func (m *Composer) GetAttachmentPath() string {
+	return m.attachmentPath
+}
+
+// SetReplyContext sets the reply context for the draft.
+func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
+	m.inReplyTo = inReplyTo
+	m.references = references
+}
+
+// GetInReplyTo returns the In-Reply-To header value.
+func (m *Composer) GetInReplyTo() string {
+	return m.inReplyTo
+}
+
+// GetReferences returns the References header values.
+func (m *Composer) GetReferences() []string {
+	return m.references
+}
+
+// ToDraft converts the composer state to a Draft for saving.
+func (m *Composer) ToDraft() config.Draft {
+	return config.Draft{
+		ID:             m.draftID,
+		To:             m.toInput.Value(),
+		Subject:        m.subjectInput.Value(),
+		Body:           m.bodyInput.Value(),
+		AttachmentPath: m.attachmentPath,
+		AccountID:      m.GetSelectedAccountID(),
+		InReplyTo:      m.inReplyTo,
+		References:     m.references,
+	}
+}
+
+// NewComposerFromDraft creates a composer from an existing draft.
+func NewComposerFromDraft(draft config.Draft, accounts []config.Account) *Composer {
+	m := NewComposerWithAccounts(accounts, draft.AccountID, draft.To, draft.Subject, draft.Body)
+	m.draftID = draft.ID
+	m.attachmentPath = draft.AttachmentPath
+	m.inReplyTo = draft.InReplyTo
+	m.references = draft.References
+	return m
+}

tui/composer_test.go 🔗

@@ -50,11 +50,12 @@ func TestComposerUpdate(t *testing.T) {
 			t.Errorf("After four Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
 		}
 
-		// Simulate one more Tab to wrap around to the 'From' field.
+		// Simulate one more Tab to wrap around.
+		// With single account, From field is skipped, so it wraps to focusTo.
 		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
 		composer = model.(*Composer)
-		if composer.focusIndex != focusFrom {
-			t.Errorf("After five Tabs, focusIndex should wrap to %d (focusFrom), got %d", focusFrom, composer.focusIndex)
+		if composer.focusIndex != focusTo {
+			t.Errorf("After five Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
 		}
 	})
 
@@ -154,6 +155,117 @@ func TestComposerUpdate(t *testing.T) {
 			t.Error("Account picker should not open with single account")
 		}
 	})
+
+	t.Run("Multi-account focus cycling includes From", func(t *testing.T) {
+		multiAccounts := []config.Account{
+			{ID: "account-1", Email: "test1@example.com"},
+			{ID: "account-2", Email: "test2@example.com"},
+		}
+		multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "")
+
+		// Initial focus is on 'To' field
+		if multiComposer.focusIndex != focusTo {
+			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
+		}
+
+		// Tab through all fields: To -> Subject -> Body -> Attachment -> Send -> From (wrap)
+		model, _ := multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // To -> Subject
+		multiComposer = model.(*Composer)
+		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Subject -> Body
+		multiComposer = model.(*Composer)
+		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Body -> Attachment
+		multiComposer = model.(*Composer)
+		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Attachment -> Send
+		multiComposer = model.(*Composer)
+		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Send -> From (wrap)
+		multiComposer = model.(*Composer)
+
+		// With multiple accounts, From field should be included in tab order
+		if multiComposer.focusIndex != focusFrom {
+			t.Errorf("After five Tabs with multi-account, focusIndex should wrap to %d (focusFrom), got %d", focusFrom, multiComposer.focusIndex)
+		}
+
+		// One more Tab should go to To
+		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // From -> To
+		multiComposer = model.(*Composer)
+		if multiComposer.focusIndex != focusTo {
+			t.Errorf("After Tab from From, focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
+		}
+	})
+
+	t.Run("Shift+Tab backwards navigation", func(t *testing.T) {
+		accounts := []config.Account{
+			{ID: "account-1", Email: "test@example.com"},
+		}
+		composer := NewComposerWithAccounts(accounts, "account-1", "", "", "")
+
+		// Start at focusTo, move forward a couple times
+		if composer.focusIndex != focusTo {
+			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, composer.focusIndex)
+		}
+
+		// Tab forward to Subject
+		model, _ := composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		composer = model.(*Composer)
+		if composer.focusIndex != focusSubject {
+			t.Errorf("After Tab, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex)
+		}
+
+		// Tab forward to Body
+		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		composer = model.(*Composer)
+		if composer.focusIndex != focusBody {
+			t.Errorf("After second Tab, focusIndex should be %d (focusBody), got %d", focusBody, composer.focusIndex)
+		}
+
+		// Shift+Tab back to Subject
+		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
+		composer = model.(*Composer)
+		if composer.focusIndex != focusSubject {
+			t.Errorf("After Shift+Tab, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex)
+		}
+
+		// Shift+Tab back to To
+		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
+		composer = model.(*Composer)
+		if composer.focusIndex != focusTo {
+			t.Errorf("After second Shift+Tab, focusIndex should be %d (focusTo), got %d", focusTo, composer.focusIndex)
+		}
+
+		// Shift+Tab should wrap to Send (skipping From since single account)
+		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
+		composer = model.(*Composer)
+		if composer.focusIndex != focusSend {
+			t.Errorf("After Shift+Tab from To, focusIndex should wrap to %d (focusSend), got %d", focusSend, composer.focusIndex)
+		}
+	})
+
+	t.Run("Multi-account Shift+Tab includes From", func(t *testing.T) {
+		multiAccounts := []config.Account{
+			{ID: "account-1", Email: "test1@example.com"},
+			{ID: "account-2", Email: "test2@example.com"},
+		}
+		multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "")
+
+		// Start at focusTo
+		if multiComposer.focusIndex != focusTo {
+			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
+		}
+
+		// Shift+Tab should go to From (since multi-account)
+		model, _ := multiComposer.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
+		multiComposer = model.(*Composer)
+		if multiComposer.focusIndex != focusFrom {
+			t.Errorf("After Shift+Tab from To with multi-account, focusIndex should be %d (focusFrom), got %d", focusFrom, multiComposer.focusIndex)
+		}
+
+		// Shift+Tab again should wrap to Send
+		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
+		multiComposer = model.(*Composer)
+		if multiComposer.focusIndex != focusSend {
+			t.Errorf("After Shift+Tab from From, focusIndex should wrap to %d (focusSend), got %d", focusSend, multiComposer.focusIndex)
+		}
+	})
 }
 
 // TestComposerGetFromAddress verifies the from address formatting.

tui/drafts.go 🔗

@@ -0,0 +1,218 @@
+package tui
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/floatpane/matcha/config"
+)
+
+// draftItem represents a draft in the list
+type draftItem struct {
+	draft config.Draft
+}
+
+func (i draftItem) Title() string {
+	if i.draft.Subject != "" {
+		return i.draft.Subject
+	}
+	return "(No subject)"
+}
+
+func (i draftItem) Description() string {
+	to := i.draft.To
+	if to == "" {
+		to = "(No recipient)"
+	}
+	timeAgo := formatTimeAgo(i.draft.UpdatedAt)
+	return fmt.Sprintf("To: %s • %s", to, timeAgo)
+}
+
+func (i draftItem) FilterValue() string {
+	return i.draft.Subject + " " + i.draft.To + " " + i.draft.Body
+}
+
+// formatTimeAgo returns a human-readable time difference
+func formatTimeAgo(t time.Time) string {
+	diff := time.Since(t)
+	switch {
+	case diff < time.Minute:
+		return "just now"
+	case diff < time.Hour:
+		mins := int(diff.Minutes())
+		if mins == 1 {
+			return "1 minute ago"
+		}
+		return fmt.Sprintf("%d minutes ago", mins)
+	case diff < 24*time.Hour:
+		hours := int(diff.Hours())
+		if hours == 1 {
+			return "1 hour ago"
+		}
+		return fmt.Sprintf("%d hours ago", hours)
+	case diff < 7*24*time.Hour:
+		days := int(diff.Hours() / 24)
+		if days == 1 {
+			return "1 day ago"
+		}
+		return fmt.Sprintf("%d days ago", days)
+	default:
+		return t.Format("Jan 2, 2006")
+	}
+}
+
+// Drafts is the model for the drafts list view
+type Drafts struct {
+	list          list.Model
+	drafts        []config.Draft
+	width         int
+	height        int
+	confirmDelete bool
+	selectedDraft *config.Draft
+}
+
+// NewDrafts creates a new drafts list view
+func NewDrafts(drafts []config.Draft) *Drafts {
+	items := make([]list.Item, len(drafts))
+	for i, d := range drafts {
+		items[i] = draftItem{draft: d}
+	}
+
+	l := list.New(items, list.NewDefaultDelegate(), 0, 0)
+	l.Title = "Drafts"
+	l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
+	l.SetShowStatusBar(true)
+	l.SetFilteringEnabled(true)
+	l.SetStatusBarItemName("draft", "drafts")
+	l.AdditionalShortHelpKeys = func() []key.Binding {
+		return []key.Binding{
+			key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open")),
+			key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
+		}
+	}
+	l.KeyMap.Quit.SetEnabled(false)
+
+	return &Drafts{
+		list:   l,
+		drafts: drafts,
+	}
+}
+
+func (m *Drafts) Init() tea.Cmd {
+	return nil
+}
+
+func (m *Drafts) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		m.height = msg.Height
+		m.list.SetWidth(msg.Width)
+		m.list.SetHeight(msg.Height - 4)
+		return m, nil
+
+	case tea.KeyMsg:
+		// Handle delete confirmation
+		if m.confirmDelete {
+			switch msg.String() {
+			case "y", "Y":
+				if m.selectedDraft != nil {
+					draftID := m.selectedDraft.ID
+					m.confirmDelete = false
+					m.selectedDraft = nil
+					return m, func() tea.Msg {
+						return DeleteSavedDraftMsg{DraftID: draftID}
+					}
+				}
+			case "n", "N", "esc":
+				m.confirmDelete = false
+				m.selectedDraft = nil
+			}
+			return m, nil
+		}
+
+		// Skip key handling during filtering
+		if m.list.FilterState() == list.Filtering {
+			break
+		}
+
+		switch msg.String() {
+		case "esc":
+			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
+		case "enter":
+			if item, ok := m.list.SelectedItem().(draftItem); ok {
+				return m, func() tea.Msg {
+					return OpenDraftMsg{Draft: item.draft}
+				}
+			}
+		case "d":
+			if item, ok := m.list.SelectedItem().(draftItem); ok {
+				m.confirmDelete = true
+				m.selectedDraft = &item.draft
+				return m, nil
+			}
+		}
+
+	case DraftDeletedMsg:
+		if msg.Err == nil {
+			// Remove the deleted draft from the list
+			var newDrafts []config.Draft
+			for _, d := range m.drafts {
+				if d.ID != msg.DraftID {
+					newDrafts = append(newDrafts, d)
+				}
+			}
+			m.drafts = newDrafts
+
+			items := make([]list.Item, len(m.drafts))
+			for i, d := range m.drafts {
+				items[i] = draftItem{draft: d}
+			}
+			m.list.SetItems(items)
+		}
+		return m, nil
+	}
+
+	var cmd tea.Cmd
+	m.list, cmd = m.list.Update(msg)
+	return m, cmd
+}
+
+func (m *Drafts) View() string {
+	var b strings.Builder
+
+	if m.confirmDelete {
+		dialog := DialogBoxStyle.Render(
+			lipgloss.JoinVertical(lipgloss.Center,
+				"Delete this draft?",
+				HelpStyle.Render("\n(y/n)"),
+			),
+		)
+		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
+	}
+
+	if len(m.drafts) == 0 {
+		emptyMsg := lipgloss.NewStyle().
+			Foreground(lipgloss.Color("240")).
+			Render("No drafts saved.\n\nPress esc to go back.")
+		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, emptyMsg)
+	}
+
+	b.WriteString(m.list.View())
+	return b.String()
+}
+
+// SetDrafts updates the drafts list
+func (m *Drafts) SetDrafts(drafts []config.Draft) {
+	m.drafts = drafts
+	items := make([]list.Item, len(drafts))
+	for i, d := range drafts {
+		items[i] = draftItem{draft: d}
+	}
+	m.list.SetItems(items)
+}

tui/inbox.go 🔗

@@ -83,6 +83,7 @@ type AccountTab struct {
 type Inbox struct {
 	list             list.Model
 	isFetching       bool
+	isRefreshing     bool
 	emailsCount      int
 	accounts         []config.Account
 	emailsByAccount  map[string][]fetcher.Email
@@ -207,18 +208,26 @@ func (m *Inbox) updateList() {
 }
 
 func (m *Inbox) getTitle() string {
+	var title string
 	if m.currentAccountID == "" {
-		return "Inbox - All Accounts"
-	}
-	for _, acc := range m.accounts {
-		if acc.ID == m.currentAccountID {
-			if acc.Name != "" {
-				return fmt.Sprintf("Inbox - %s", acc.Name)
+		title = "Inbox - All Accounts"
+	} else {
+		title = "Inbox"
+		for _, acc := range m.accounts {
+			if acc.ID == m.currentAccountID {
+				if acc.Name != "" {
+					title = fmt.Sprintf("Inbox - %s", acc.Name)
+				} else {
+					title = fmt.Sprintf("Inbox - %s", acc.Email)
+				}
+				break
 			}
-			return fmt.Sprintf("Inbox - %s", acc.Email)
 		}
 	}
-	return "Inbox"
+	if m.isRefreshing {
+		title += " (refreshing...)"
+	}
+	return title
 }
 
 func (m *Inbox) Init() tea.Cmd {
@@ -301,6 +310,43 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		m.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID])
 
+		m.updateList()
+		return m, nil
+
+	case RefreshingEmailsMsg:
+		m.isRefreshing = true
+		m.list.Title = m.getTitle()
+		return m, nil
+
+	case EmailsRefreshedMsg:
+		m.isRefreshing = false
+
+		// Replace emails with fresh data
+		m.emailsByAccount = msg.EmailsByAccount
+
+		// Flatten all emails
+		var allEmails []fetcher.Email
+		for _, emails := range msg.EmailsByAccount {
+			allEmails = append(allEmails, emails...)
+		}
+
+		// Sort by date (newest first)
+		for i := 0; i < len(allEmails); i++ {
+			for j := i + 1; j < len(allEmails); j++ {
+				if allEmails[j].Date.After(allEmails[i].Date) {
+					allEmails[i], allEmails[j] = allEmails[j], allEmails[i]
+				}
+			}
+		}
+
+		m.allEmails = allEmails
+
+		// Update email counts
+		m.emailCountByAcct = make(map[string]int)
+		for accID, accEmails := range m.emailsByAccount {
+			m.emailCountByAcct[accID] = len(accEmails)
+		}
+
 		m.updateList()
 		return m, nil
 	}

tui/messages.go 🔗

@@ -1,6 +1,9 @@
 package tui
 
-import "github.com/floatpane/matcha/fetcher"
+import (
+	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/fetcher"
+)
 
 type ViewEmailMsg struct {
 	Index     int
@@ -179,3 +182,58 @@ type SwitchFromAccountMsg struct {
 
 // GoToAccountListMsg signals navigation to the account list in settings.
 type GoToAccountListMsg struct{}
+
+// --- Draft Messages (persisted) ---
+
+// SaveDraftMsg signals that the current draft should be saved to disk.
+type SaveDraftMsg struct {
+	Draft config.Draft
+}
+
+// DraftSavedMsg signals that a draft was saved successfully.
+type DraftSavedMsg struct {
+	DraftID string
+	Err     error
+}
+
+// LoadDraftsMsg signals a request to load all saved drafts.
+type LoadDraftsMsg struct{}
+
+// DraftsLoadedMsg signals that drafts were loaded from disk.
+type DraftsLoadedMsg struct {
+	Drafts []config.Draft
+}
+
+// OpenDraftMsg signals that a specific draft should be opened in the composer.
+type OpenDraftMsg struct {
+	Draft config.Draft
+}
+
+// DeleteDraftMsg signals that a draft should be deleted.
+type DeleteSavedDraftMsg struct {
+	DraftID string
+}
+
+// DraftDeletedMsg signals that a draft was deleted.
+type DraftDeletedMsg struct {
+	DraftID string
+	Err     error
+}
+
+// GoToDraftsMsg signals navigation to the drafts list.
+type GoToDraftsMsg struct{}
+
+// --- Cache Messages ---
+
+// CachedEmailsLoadedMsg signals that cached emails were loaded from disk.
+type CachedEmailsLoadedMsg struct {
+	Cache *config.EmailCache
+}
+
+// RefreshingEmailsMsg signals that a background refresh is in progress.
+type RefreshingEmailsMsg struct{}
+
+// EmailsRefreshedMsg signals that fresh emails have been fetched in the background.
+type EmailsRefreshedMsg struct {
+	EmailsByAccount map[string][]fetcher.Email
+}