Detailed changes
@@ -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
+}
@@ -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)
@@ -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":
@@ -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
+}
@@ -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.
@@ -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)
+}
@@ -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
}
@@ -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
+}