diff --git a/config/cache.go b/config/cache.go new file mode 100644 index 0000000000000000000000000000000000000000..7f77addb05ecb43fb6bb3771c5ee86189355c719 --- /dev/null +++ b/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 +} diff --git a/main.go b/main.go index 9cd77ab7a0c07dcddd2919fa598ca27105b88065..33201d51f61a923765db9dcbc0976604801d8393 100644 --- a/main.go +++ b/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 " 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 " 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) diff --git a/matcha b/matcha index 8ccb5c7ad06a5c750287ffd3eb3e8c39f1f43715..01b45708c8ca43f0524d22ff729ef865d65b2f8e 100755 Binary files a/matcha and b/matcha differ diff --git a/tui/choice.go b/tui/choice.go index 1011ff71ec39362dbb5e03dcd1a92143e428e32f..f2866dc0bffd10aa664b7465ae09c23196cfee67 100644 --- a/tui/choice.go +++ b/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": diff --git a/tui/composer.go b/tui/composer.go index f2d7c96568535e0c2deced8a36a394ea9b77325e..505a9822e33027f11fd52b505567b9fbd97e0dff 100644 --- a/tui/composer.go +++ b/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 +} diff --git a/tui/composer_test.go b/tui/composer_test.go index f768054cedfe4bff36cb75e39e3301c5c2db020a..257a1f10b9327ef980dc4f1c716e8542fb0c01ee 100644 --- a/tui/composer_test.go +++ b/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. diff --git a/tui/drafts.go b/tui/drafts.go new file mode 100644 index 0000000000000000000000000000000000000000..4b099ae6cc723daea869000c3ca58f4e9c8e015e --- /dev/null +++ b/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) +} diff --git a/tui/inbox.go b/tui/inbox.go index 4156cf73f0b54bb68c9601f17b79b1985ba6c0f3..05f0d237f82e1ca112d19ca31876f5f4d3d11729 100644 --- a/tui/inbox.go +++ b/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 } diff --git a/tui/messages.go b/tui/messages.go index a327a1176a8643192c7194d9d62e958fa4411e9a..8a2c94dbadaa885d0a81643db328e73a3d6dd20e 100644 --- a/tui/messages.go +++ b/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 +}