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/config/config.go b/config/config.go index c0eedd55c7fa81a6a8a6535ec7808cb30ae11519..500e522df01a72c9709cd1a1fb846989325a0f26 100644 --- a/config/config.go +++ b/config/config.go @@ -4,14 +4,86 @@ import ( "encoding/json" "os" "path/filepath" + + "github.com/google/uuid" ) -// Config stores the user's email configuration. -type Config struct { - ServiceProvider string `json:"service_provider"` +// Account stores the configuration for a single email account. +type Account struct { + ID string `json:"id"` + Name string `json:"name"` Email string `json:"email"` Password string `json:"password"` - Name string `json:"name"` + ServiceProvider string `json:"service_provider"` // "gmail", "icloud", or "custom" + + // Custom server settings (used when ServiceProvider is "custom") + IMAPServer string `json:"imap_server,omitempty"` + IMAPPort int `json:"imap_port,omitempty"` + SMTPServer string `json:"smtp_server,omitempty"` + SMTPPort int `json:"smtp_port,omitempty"` +} + +// Config stores the user's email configuration with multiple accounts. +type Config struct { + Accounts []Account `json:"accounts"` +} + +// GetIMAPServer returns the IMAP server address for the account. +func (a *Account) GetIMAPServer() string { + switch a.ServiceProvider { + case "gmail": + return "imap.gmail.com" + case "icloud": + return "imap.mail.me.com" + case "custom": + return a.IMAPServer + default: + return "" + } +} + +// GetIMAPPort returns the IMAP port for the account. +func (a *Account) GetIMAPPort() int { + switch a.ServiceProvider { + case "gmail", "icloud": + return 993 + case "custom": + if a.IMAPPort != 0 { + return a.IMAPPort + } + return 993 // Default IMAP SSL port + default: + return 993 + } +} + +// GetSMTPServer returns the SMTP server address for the account. +func (a *Account) GetSMTPServer() string { + switch a.ServiceProvider { + case "gmail": + return "smtp.gmail.com" + case "icloud": + return "smtp.mail.me.com" + case "custom": + return a.SMTPServer + default: + return "" + } +} + +// GetSMTPPort returns the SMTP port for the account. +func (a *Account) GetSMTPPort() int { + switch a.ServiceProvider { + case "gmail", "icloud": + return 587 + case "custom": + if a.SMTPPort != 0 { + return a.SMTPPort + } + return 587 // Default SMTP TLS port + default: + return 587 + } } // configDir returns the path to the configuration directory. @@ -60,22 +132,88 @@ func LoadConfig() (*Config, error) { } var config Config if err := json.Unmarshal(data, &config); err != nil { + // Try to load legacy single-account config + var legacyConfig legacyConfigFormat + if legacyErr := json.Unmarshal(data, &legacyConfig); legacyErr == nil && legacyConfig.Email != "" { + // Convert legacy config to new format + config = Config{ + Accounts: []Account{ + { + ID: uuid.New().String(), + Name: legacyConfig.Name, + Email: legacyConfig.Email, + Password: legacyConfig.Password, + ServiceProvider: legacyConfig.ServiceProvider, + }, + }, + } + // Save the migrated config + if saveErr := SaveConfig(&config); saveErr != nil { + return nil, saveErr + } + return &config, nil + } return nil, err } return &config, nil } -// IMAPServer returns the IMAP server address based on the service provider. -// This is used to connect to the email provider's IMAP server. -// It returns an empty string if the service provider is not supported. -func (c *Config) IMAPServer() string { - switch c.ServiceProvider { - case "gmail": - return "imap.gmail.com" - case "icloud": - return "imap.mail.me.com" - // Add other providers here - default: - return "" +// legacyConfigFormat represents the old single-account configuration format. +type legacyConfigFormat struct { + ServiceProvider string `json:"service_provider"` + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` +} + +// AddAccount adds a new account to the configuration. +func (c *Config) AddAccount(account Account) { + if account.ID == "" { + account.ID = uuid.New().String() + } + c.Accounts = append(c.Accounts, account) +} + +// RemoveAccount removes an account by its ID. +func (c *Config) RemoveAccount(id string) bool { + for i, acc := range c.Accounts { + if acc.ID == id { + c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...) + return true + } + } + return false +} + +// GetAccountByID returns an account by its ID. +func (c *Config) GetAccountByID(id string) *Account { + for i := range c.Accounts { + if c.Accounts[i].ID == id { + return &c.Accounts[i] + } + } + return nil +} + +// GetAccountByEmail returns an account by its email address. +func (c *Config) GetAccountByEmail(email string) *Account { + for i := range c.Accounts { + if c.Accounts[i].Email == email { + return &c.Accounts[i] + } + } + return nil +} + +// HasAccounts returns true if there are any configured accounts. +func (c *Config) HasAccounts() bool { + return len(c.Accounts) > 0 +} + +// GetFirstAccount returns the first account or nil if none exist. +func (c *Config) GetFirstAccount() *Account { + if len(c.Accounts) > 0 { + return &c.Accounts[0] } + return nil } diff --git a/config/config_test.go b/config/config_test.go index e4403a24965d2e316f731f38cd29bb2ee3664abf..633b3982e4ba26bd7550ceeef730642e8c6a91cc 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -14,12 +14,28 @@ func TestSaveAndLoadConfig(t *testing.T) { // This ensures that our config file is written to a predictable, temporary location. t.Setenv("HOME", tempDir) - // Define a sample configuration to save. + // Define a sample configuration to save with multiple accounts. expectedConfig := &Config{ - ServiceProvider: "gmail", - Email: "test@example.com", - Password: "supersecret", - Name: "Test User", + Accounts: []Account{ + { + ID: "test-id-1", + Name: "Test User", + Email: "test@example.com", + Password: "supersecret", + ServiceProvider: "gmail", + }, + { + ID: "test-id-2", + Name: "Custom User", + Email: "custom@example.com", + Password: "customsecret", + ServiceProvider: "custom", + IMAPServer: "imap.custom.com", + IMAPPort: 993, + SMTPServer: "smtp.custom.com", + SMTPPort: 587, + }, + }, } // Attempt to save the configuration. @@ -41,26 +57,184 @@ func TestSaveAndLoadConfig(t *testing.T) { } } -// TestIMAPServer tests the logic that determines the IMAP server address. -func TestIMAPServer(t *testing.T) { +// TestAccountGetIMAPServer tests the logic that determines the IMAP server address. +func TestAccountGetIMAPServer(t *testing.T) { + testCases := []struct { + name string + account Account + want string + }{ + {"Gmail", Account{ServiceProvider: "gmail"}, "imap.gmail.com"}, + {"iCloud", Account{ServiceProvider: "icloud"}, "imap.mail.me.com"}, + {"Custom", Account{ServiceProvider: "custom", IMAPServer: "imap.custom.com"}, "imap.custom.com"}, + {"Unsupported", Account{ServiceProvider: "yahoo"}, ""}, + {"Empty", Account{ServiceProvider: ""}, ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got := tc.account.GetIMAPServer() + if got != tc.want { + t.Errorf("GetIMAPServer() = %q, want %q", got, tc.want) + } + }) + } +} + +// TestAccountGetSMTPServer tests the logic that determines the SMTP server address. +func TestAccountGetSMTPServer(t *testing.T) { testCases := []struct { - name string - provider string - want string + name string + account Account + want string }{ - {"Gmail", "gmail", "imap.gmail.com"}, - {"iCloud", "icloud", "imap.mail.me.com"}, - {"Unsupported", "yahoo", ""}, - {"Empty", "", ""}, + {"Gmail", Account{ServiceProvider: "gmail"}, "smtp.gmail.com"}, + {"iCloud", Account{ServiceProvider: "icloud"}, "smtp.mail.me.com"}, + {"Custom", Account{ServiceProvider: "custom", SMTPServer: "smtp.custom.com"}, "smtp.custom.com"}, + {"Unsupported", Account{ServiceProvider: "yahoo"}, ""}, + {"Empty", Account{ServiceProvider: ""}, ""}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - cfg := &Config{ServiceProvider: tc.provider} - got := cfg.IMAPServer() + got := tc.account.GetSMTPServer() if got != tc.want { - t.Errorf("IMAPServer() = %q, want %q", got, tc.want) + t.Errorf("GetSMTPServer() = %q, want %q", got, tc.want) } }) } } + +// TestConfigAddRemoveAccount tests adding and removing accounts from config. +func TestConfigAddRemoveAccount(t *testing.T) { + cfg := &Config{} + + // Add an account + account := Account{ + Name: "Test", + Email: "test@example.com", + ServiceProvider: "gmail", + } + cfg.AddAccount(account) + + if len(cfg.Accounts) != 1 { + t.Fatalf("Expected 1 account, got %d", len(cfg.Accounts)) + } + + // Check that ID was auto-generated + if cfg.Accounts[0].ID == "" { + t.Error("Expected account ID to be auto-generated") + } + + // Remove the account + accountID := cfg.Accounts[0].ID + removed := cfg.RemoveAccount(accountID) + if !removed { + t.Error("RemoveAccount should return true when account exists") + } + + if len(cfg.Accounts) != 0 { + t.Fatalf("Expected 0 accounts after removal, got %d", len(cfg.Accounts)) + } + + // Try to remove non-existent account + removed = cfg.RemoveAccount("non-existent") + if removed { + t.Error("RemoveAccount should return false for non-existent account") + } +} + +// TestConfigGetAccountByID tests retrieving accounts by ID. +func TestConfigGetAccountByID(t *testing.T) { + cfg := &Config{ + Accounts: []Account{ + {ID: "id-1", Email: "test1@example.com"}, + {ID: "id-2", Email: "test2@example.com"}, + }, + } + + account := cfg.GetAccountByID("id-1") + if account == nil { + t.Fatal("Expected to find account with id-1") + } + if account.Email != "test1@example.com" { + t.Errorf("Expected email test1@example.com, got %s", account.Email) + } + + // Non-existent ID + account = cfg.GetAccountByID("non-existent") + if account != nil { + t.Error("Expected nil for non-existent account ID") + } +} + +// TestConfigGetAccountByEmail tests retrieving accounts by email. +func TestConfigGetAccountByEmail(t *testing.T) { + cfg := &Config{ + Accounts: []Account{ + {ID: "id-1", Email: "test1@example.com"}, + {ID: "id-2", Email: "test2@example.com"}, + }, + } + + account := cfg.GetAccountByEmail("test2@example.com") + if account == nil { + t.Fatal("Expected to find account with test2@example.com") + } + if account.ID != "id-2" { + t.Errorf("Expected ID id-2, got %s", account.ID) + } + + // Non-existent email + account = cfg.GetAccountByEmail("nonexistent@example.com") + if account != nil { + t.Error("Expected nil for non-existent account email") + } +} + +// TestConfigHasAccounts tests the HasAccounts method. +func TestConfigHasAccounts(t *testing.T) { + cfg := &Config{} + if cfg.HasAccounts() { + t.Error("Expected HasAccounts to return false for empty config") + } + + cfg.AddAccount(Account{Email: "test@example.com"}) + if !cfg.HasAccounts() { + t.Error("Expected HasAccounts to return true after adding account") + } +} + +// TestAccountGetPorts tests the port retrieval methods. +func TestAccountGetPorts(t *testing.T) { + // Gmail account should use default ports + gmailAccount := Account{ServiceProvider: "gmail"} + if gmailAccount.GetIMAPPort() != 993 { + t.Errorf("Expected Gmail IMAP port 993, got %d", gmailAccount.GetIMAPPort()) + } + if gmailAccount.GetSMTPPort() != 587 { + t.Errorf("Expected Gmail SMTP port 587, got %d", gmailAccount.GetSMTPPort()) + } + + // Custom account with custom ports + customAccount := Account{ + ServiceProvider: "custom", + IMAPPort: 1993, + SMTPPort: 1587, + } + if customAccount.GetIMAPPort() != 1993 { + t.Errorf("Expected custom IMAP port 1993, got %d", customAccount.GetIMAPPort()) + } + if customAccount.GetSMTPPort() != 1587 { + t.Errorf("Expected custom SMTP port 1587, got %d", customAccount.GetSMTPPort()) + } + + // Custom account with default ports (0 means use default) + customDefaultAccount := Account{ServiceProvider: "custom"} + if customDefaultAccount.GetIMAPPort() != 993 { + t.Errorf("Expected default IMAP port 993 for custom with no port, got %d", customDefaultAccount.GetIMAPPort()) + } + if customDefaultAccount.GetSMTPPort() != 587 { + t.Errorf("Expected default SMTP port 587 for custom with no port, got %d", customDefaultAccount.GetSMTPPort()) + } +} diff --git a/demo.tape b/demo.tape new file mode 100644 index 0000000000000000000000000000000000000000..23e84b0626f9a8b8f7d3d98b6518f7d1887475a8 --- /dev/null +++ b/demo.tape @@ -0,0 +1,142 @@ +# This VHS tape file creates a demo video showcasing Matcha's features +# Run with: vhs demo.tape + +# Output configuration +Output demo.gif + +# Terminal settings +Set FontSize 18 +Set FontFamily "SF Mono,Menlo,Monaco,Cascadia Code,Consolas,monospace" +Set Width 1000 +Set Height 600 +Set Theme "Catppuccin Mocha" +Set Padding 24 +Set Framerate 24 +Set PlaybackSpeed 0.7 + +# Window chrome +Set WindowBar Colorful +Set WindowBarSize 40 +Set BorderRadius 10 + +# ============================================ +# DEMO START +# ============================================ + +Sleep 500ms + +# Show the command being typed +Type "matcha" +Sleep 500ms +Enter + +# Wait for app to load and show main menu +Sleep 2s + +# ============================================ +# SECTION 1: Show Menu Navigation +# ============================================ + +# Highlight the menu by navigating through options +Down +Sleep 600ms +Down +Sleep 600ms +Down +Sleep 600ms + +# Navigate back up to "View Inbox" +Up +Sleep 400ms +Up +Sleep 400ms +Up +Sleep 600ms + +# ============================================ +# SECTION 2: Compose Email Demo +# ============================================ + +# Go to Compose Email +Down +Sleep 400ms +Enter +Sleep 1.5s + +# Type the recipient +Type "friend@example.com" +Sleep 400ms +Tab +Sleep 300ms + +# Type the subject +Type "Hello from the terminal!" +Sleep 400ms +Tab +Sleep 300ms + +# Type the email body with Markdown +Type "# Greetings!" +Enter +Enter +Type "I'm sending this email from **Matcha** - " +Type "a beautiful terminal email client." +Enter +Enter +Type "Features I love:" +Enter +Type "- Keyboard-first design" +Enter +Type "- Markdown support" +Enter +Type "- Multi-account management" +Enter +Enter +Type "Try it yourself!" +Sleep 1s + +# Show the completed compose form +Sleep 1.5s + +# Exit without sending (for demo) +Escape +Sleep 800ms +Type "y" +Sleep 1s + +# ============================================ +# SECTION 3: Quick Settings Peek +# ============================================ + +# Navigate to Settings +Down +Sleep 400ms +Down +Sleep 400ms +Down +Sleep 400ms +Enter +Sleep 1.5s + +# Show settings briefly +Sleep 1s + +# Go back to menu +Escape +Sleep 1s + +# ============================================ +# FINALE +# ============================================ + +# Show main menu one more time +Sleep 1.5s + +# Exit gracefully +Ctrl+C +Sleep 800ms + +# Final message +Type "# Email, right in your terminal." +Enter +Sleep 1.5s diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 8576842f2cab31685465ed91e9d852bea01b1a7b..20929102a58ef827be2084adf26292f190ed6490 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -36,6 +36,7 @@ type Email struct { MessageID string References []string Attachments []Attachment + AccountID string // ID of the account this email belongs to } func decodePart(reader io.Reader, header mail.PartHeader) (string, error) { @@ -84,19 +85,12 @@ func decodeHeader(header string) string { return decoded } -func connect(cfg *config.Config) (*client.Client, error) { - var imapServer string - var imapPort int +func connect(account *config.Account) (*client.Client, error) { + imapServer := account.GetIMAPServer() + imapPort := account.GetIMAPPort() - switch cfg.ServiceProvider { - case "gmail": - imapServer = "imap.gmail.com" - imapPort = 993 - case "icloud": - imapServer = "imap.mail.me.com" - imapPort = 993 - default: - return nil, fmt.Errorf("unsupported service_provider: %s", cfg.ServiceProvider) + if imapServer == "" { + return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider) } addr := fmt.Sprintf("%s:%d", imapServer, imapPort) @@ -105,15 +99,15 @@ func connect(cfg *config.Config) (*client.Client, error) { return nil, err } - if err := c.Login(cfg.Email, cfg.Password); err != nil { + if err := c.Login(account.Email, account.Password); err != nil { return nil, err } return c, nil } -func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { - c, err := connect(cfg) +func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) { + c, err := connect(account) if err != nil { return nil, err } @@ -174,11 +168,12 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { } emails = append(emails, Email{ - UID: msg.Uid, - From: fromAddr, - To: toAddrList, - Subject: decodeHeader(msg.Envelope.Subject), - Date: msg.Envelope.Date, + UID: msg.Uid, + From: fromAddr, + To: toAddrList, + Subject: decodeHeader(msg.Envelope.Subject), + Date: msg.Envelope.Date, + AccountID: account.ID, }) } @@ -189,8 +184,8 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { return emails, nil } -func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error) { - c, err := connect(cfg) +func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { + c, err := connect(account) if err != nil { return "", nil, err } @@ -350,8 +345,8 @@ func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error return body, attachments, nil } -func FetchAttachment(cfg *config.Config, uid uint32, partID string) ([]byte, error) { - c, err := connect(cfg) +func FetchAttachment(account *config.Account, uid uint32, partID string) ([]byte, error) { + c, err := connect(account) if err != nil { return nil, err } @@ -393,8 +388,8 @@ func FetchAttachment(cfg *config.Config, uid uint32, partID string) ([]byte, err return ioutil.ReadAll(literal) } -func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error { - c, err := connect(cfg) +func moveEmail(account *config.Account, uid uint32, destMailbox string) error { + c, err := connect(account) if err != nil { return err } @@ -410,8 +405,8 @@ func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error { return c.UidMove(seqSet, destMailbox) } -func DeleteEmail(cfg *config.Config, uid uint32) error { - c, err := connect(cfg) +func DeleteEmail(account *config.Account, uid uint32) error { + c, err := connect(account) if err != nil { return err } @@ -434,13 +429,13 @@ func DeleteEmail(cfg *config.Config, uid uint32) error { return c.Expunge(nil) } -func ArchiveEmail(cfg *config.Config, uid uint32) error { +func ArchiveEmail(account *config.Account, uid uint32) error { var archiveMailbox string - switch cfg.ServiceProvider { + switch account.ServiceProvider { case "gmail": archiveMailbox = "[Gmail]/All Mail" default: archiveMailbox = "Archive" } - return moveEmail(cfg, uid, archiveMailbox) + return moveEmail(account, uid, archiveMailbox) } diff --git a/fetcher/fetcher_test.go b/fetcher/fetcher_test.go index f1dd982b05984ca196a5740a81ec19b795977549..fb4119ac5aaa322cb6cb9f8c6037558e69e98bac 100644 --- a/fetcher/fetcher_test.go +++ b/fetcher/fetcher_test.go @@ -18,12 +18,23 @@ func TestFetchEmails(t *testing.T) { t.Skipf("Skipping TestFetchEmails: could not load config: %v", err) } + // Check if there are any accounts configured + if !cfg.HasAccounts() { + t.Skip("Skipping TestFetchEmails: no accounts configured.") + } + + // Get the first account + account := cfg.GetFirstAccount() + if account == nil { + t.Skip("Skipping TestFetchEmails: no accounts available.") + } + // If the password is a placeholder, skip the test to avoid failed auth attempts. - if cfg.Password == "" || cfg.Password == "supersecret" { + if account.Password == "" || account.Password == "supersecret" { t.Skip("Skipping TestFetchEmails: placeholder or empty password found in config.") } - emails, err := FetchEmails(cfg, 10, 10) + emails, err := FetchEmails(account, 10, 10) if err != nil { t.Fatalf("FetchEmails() failed with error: %v", err) } @@ -55,4 +66,49 @@ func TestFetchEmails(t *testing.T) { t.Errorf("Fetched email has empty subject and from fields: %+v", email) } } + + // Verify that AccountID is set on fetched emails + for _, email := range emails { + if email.AccountID != account.ID { + t.Errorf("Expected AccountID %s, got %s", account.ID, email.AccountID) + } + } +} + +// TestFetchEmailsWithCustomServer tests fetching with a custom server configuration. +// This test is skipped unless a custom account is configured. +func TestFetchEmailsWithCustomServer(t *testing.T) { + // Attempt to load the configuration. + cfg, err := config.LoadConfig() + if err != nil { + t.Skipf("Skipping TestFetchEmailsWithCustomServer: could not load config: %v", err) + } + + // Look for a custom account + var customAccount *config.Account + for i := range cfg.Accounts { + if cfg.Accounts[i].ServiceProvider == "custom" { + customAccount = &cfg.Accounts[i] + break + } + } + + if customAccount == nil { + t.Skip("Skipping TestFetchEmailsWithCustomServer: no custom account configured.") + } + + if customAccount.Password == "" || customAccount.Password == "supersecret" { + t.Skip("Skipping TestFetchEmailsWithCustomServer: placeholder or empty password found.") + } + + if customAccount.IMAPServer == "" { + t.Skip("Skipping TestFetchEmailsWithCustomServer: no IMAP server configured.") + } + + emails, err := FetchEmails(customAccount, 5, 0) + if err != nil { + t.Fatalf("FetchEmails() with custom server failed: %v", err) + } + + t.Logf("Fetched %d emails from custom server %s", len(emails), customAccount.IMAPServer) } diff --git a/main.go b/main.go index eeffda5d2ad6014f00a851ba7730e31b74422990..2aec244889062c0e591a44773faa6e3db55fc646 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "time" tea "github.com/charmbracelet/bubbletea" @@ -27,25 +28,26 @@ const ( ) type mainModel struct { - current tea.Model - previousModel tea.Model - cachedComposer *tui.Composer // To cache a discarded draft - config *config.Config - emails []fetcher.Email - inbox *tui.Inbox - width int - height int - err error + current tea.Model + previousModel tea.Model + config *config.Config + emails []fetcher.Email + emailsByAcct map[string][]fetcher.Email + inbox *tui.Inbox + width int + height int + err error } func newInitialModel(cfg *config.Config) *mainModel { - // Determine if there is a cached composer to pass to the initial choice view - hasCache := false - initialModel := &mainModel{} - if cfg == nil { + initialModel := &mainModel{ + emailsByAcct: make(map[string][]fetcher.Email), + } + + if cfg == nil || !cfg.HasAccounts() { initialModel.current = tui.NewLogin() } else { - initialModel.current = tui.NewChoice(hasCache) + initialModel.current = tui.NewChoice() initialModel.config = cfg } return initialModel @@ -77,7 +79,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case *tui.FilePicker: return m, func() tea.Msg { return tui.CancelFilePickerMsg{} } case *tui.Inbox, *tui.Login: - m.current = tui.NewChoice(m.cachedComposer != nil) + m.current = tui.NewChoice() return m, m.current.Init() } } @@ -86,76 +88,314 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.inbox != nil { m.current = m.inbox } else { - m.current = tui.NewChoice(m.cachedComposer != nil) + m.current = tui.NewChoice() } return m, nil case tui.DiscardDraftMsg: - m.cachedComposer = msg.ComposerState - m.current = tui.NewChoice(true) // Now there is a cached draft - return m, m.current.Init() - - case tui.RestoreDraftMsg: - if m.cachedComposer != nil { - m.current = m.cachedComposer - m.cachedComposer.ResetConfirmation() - m.cachedComposer = nil // Clear cache after restoring - return m, m.current.Init() + // 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() + return m, m.current.Init() case tui.Credentials: - cfg := &config.Config{ - ServiceProvider: msg.Provider, + // Add new account or update existing + account := config.Account{ + ID: uuid.New().String(), Name: msg.Name, Email: msg.Email, Password: msg.Password, + ServiceProvider: msg.Provider, + } + + if msg.Provider == "custom" { + account.IMAPServer = msg.IMAPServer + account.IMAPPort = msg.IMAPPort + account.SMTPServer = msg.SMTPServer + account.SMTPPort = msg.SMTPPort } - if err := config.SaveConfig(cfg); err != nil { + + if m.config == nil { + m.config = &config.Config{} + } + + // Check if we're editing an existing account + if login, ok := m.current.(*tui.Login); ok && login.IsEditMode() { + // Find and update the existing account + existingID := login.GetAccountID() + for i, acc := range m.config.Accounts { + if acc.ID == existingID { + account.ID = existingID + m.config.Accounts[i] = account + break + } + } + } else { + m.config.AddAccount(account) + } + + if err := config.SaveConfig(m.config); err != nil { log.Printf("could not save config: %v", err) return m, tea.Quit } - m.config = cfg - m.current = tui.NewChoice(m.cachedComposer != nil) + + m.current = tui.NewChoice() return m, m.current.Init() case tui.GoToInboxMsg: - m.current = tui.NewStatus("Fetching emails...") - return m, tea.Batch(m.current.Init(), fetchEmails(m.config, initialEmailLimit, 0)) + if m.config == nil || !m.config.HasAccounts() { + 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 + + // 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) + + 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}) + return m, m.current.Init() case tui.EmailsFetchedMsg: - m.emails = msg.Emails - m.inbox = tui.NewInbox(m.emails) + // Single account fetch result + if m.emailsByAcct == nil { + m.emailsByAcct = make(map[string][]fetcher.Email) + } + m.emailsByAcct[msg.AccountID] = msg.Emails + + // Rebuild all emails + var allEmails []fetcher.Email + for _, emails := range m.emailsByAcct { + allEmails = append(allEmails, emails...) + } + + // Sort by date + 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 + if m.inbox == nil { + m.inbox = tui.NewInbox(m.emails, m.config.Accounts) + } else { + m.inbox.SetEmails(m.emails, m.config.Accounts) + } m.current = m.inbox m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() case tui.FetchMoreEmailsMsg: + if msg.AccountID == "" { + return m, nil // Don't fetch more for "ALL" view + } + account := m.config.GetAccountByID(msg.AccountID) + if account == nil { + return m, nil + } return m, tea.Batch( func() tea.Msg { return tui.FetchingMoreEmailsMsg{} }, - fetchEmails(m.config, paginationLimit, msg.Offset), + fetchEmails(account, paginationLimit, msg.Offset), ) case tui.EmailsAppendedMsg: + // Add new emails to the appropriate account + if m.emailsByAcct == nil { + m.emailsByAcct = make(map[string][]fetcher.Email) + } + m.emailsByAcct[msg.AccountID] = append(m.emailsByAcct[msg.AccountID], msg.Emails...) m.emails = append(m.emails, msg.Emails...) return m, nil case tui.GoToSendMsg: - // When composing a new email, we discard any previously cached draft. - m.cachedComposer = nil - m.current = tui.NewComposer(m.config.Email, msg.To, msg.Subject, msg.Body) + if m.config != nil && len(m.config.Accounts) > 0 { + firstAccount := m.config.GetFirstAccount() + composer := tui.NewComposerWithAccounts(m.config.Accounts, firstAccount.ID, msg.To, msg.Subject, msg.Body) + m.current = composer + } else { + m.current = tui.NewComposer("", msg.To, msg.Subject, msg.Body) + } 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: + 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) + } else { + m.current = tui.NewSettings(nil) + } + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + + case tui.GoToAddAccountMsg: m.current = tui.NewLogin() m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() + case tui.GoToChoiceMenuMsg: + m.current = tui.NewChoice() + return m, m.current.Init() + + case tui.DeleteAccountMsg: + if m.config != nil { + m.config.RemoveAccount(msg.AccountID) + if err := config.SaveConfig(m.config); err != nil { + log.Printf("could not save config: %v", err) + } + // Remove emails for this account + delete(m.emailsByAcct, msg.AccountID) + + // Rebuild all emails + var allEmails []fetcher.Email + for _, emails := range m.emailsByAcct { + allEmails = append(allEmails, emails...) + } + m.emails = allEmails + + // Go back to settings + m.current = tui.NewSettings(m.config.Accounts) + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + } + return m, m.current.Init() + case tui.ViewEmailMsg: - // Show a status message while fetching the email body + email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID) + if email == nil { + return m, nil + } m.current = tui.NewStatus("Fetching email content...") - // Pass the index directly to the command - return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, m.emails[msg.Index], msg.Index)) + return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, *email, msg.UID, msg.AccountID)) case tui.EmailBodyFetchedMsg: if msg.Err != nil { @@ -163,11 +403,19 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = m.inbox return m, nil } - // Use the index from the message to update the correct email - m.emails[msg.Index].Body = msg.Body - m.emails[msg.Index].Attachments = msg.Attachments - emailView := tui.NewEmailView(m.emails[msg.Index], msg.Index, m.width, m.height) + // Update the email in our stores + m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Body, msg.Attachments) + + email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID) + if email == nil { + m.current = m.inbox + return m, nil + } + + // Find the index for the email view (used for display purposes) + emailIndex := m.getEmailIndex(msg.UID, msg.AccountID) + emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height) m.current = emailView return m, m.current.Init() @@ -175,7 +423,18 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { to := msg.Email.From subject := "Re: " + msg.Email.Subject body := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> ")) - m.current = tui.NewComposer(m.config.Email, to, subject, body) + + if m.config != nil && len(m.config.Accounts) > 0 { + // Use the account that received the email + accountID := msg.Email.AccountID + if accountID == "" { + accountID = m.config.GetFirstAccount().ID + } + composer := tui.NewComposerWithAccounts(m.config.Accounts, accountID, to, subject, body) + m.current = composer + } else { + m.current = tui.NewComposer("", to, subject, body) + } m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() @@ -195,23 +454,69 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) case tui.SendEmailMsg: - m.cachedComposer = nil // Clear cache on successful send + // 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.current = tui.NewStatus("Sending email...") - return m, tea.Batch(m.current.Init(), sendEmail(m.config, msg)) + + // Get the account to send from + var account *config.Account + if msg.AccountID != "" && m.config != nil { + account = m.config.GetAccountByID(msg.AccountID) + } + if account == nil && m.config != nil { + 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: - m.current = tui.NewChoice(m.cachedComposer != nil) + m.current = tui.NewChoice() return m, m.current.Init() case tui.DeleteEmailMsg: m.previousModel = m.current m.current = tui.NewStatus("Deleting email...") - return m, tea.Batch(m.current.Init(), deleteEmailCmd(m.config, msg.UID)) + + account := m.config.GetAccountByID(msg.AccountID) + if account == nil { + m.current = m.inbox + return m, nil + } + + return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID)) case tui.ArchiveEmailMsg: m.previousModel = m.current m.current = tui.NewStatus("Archiving email...") - return m, tea.Batch(m.current.Init(), archiveEmailCmd(m.config, msg.UID)) + + account := m.config.GetAccountByID(msg.AccountID) + if account == nil { + m.current = m.inbox + return m, nil + } + + return m, tea.Batch(m.current.Init(), archiveEmailCmd(account, msg.UID, msg.AccountID)) case tui.EmailActionDoneMsg: if msg.Err != nil { @@ -219,14 +524,13 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = m.inbox return m, nil } - var updatedEmails []fetcher.Email - for _, email := range m.emails { - if email.UID != msg.UID { - updatedEmails = append(updatedEmails, email) - } + + // Remove email from stores + m.removeEmail(msg.UID, msg.AccountID) + + if m.inbox != nil { + m.inbox.RemoveEmail(msg.UID, msg.AccountID) } - m.emails = updatedEmails - m.inbox = tui.NewInbox(m.emails) m.current = m.inbox m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() @@ -234,8 +538,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.DownloadAttachmentMsg: m.previousModel = m.current m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename)) - // Use the new FetchAttachment function - return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(m.config, m.emails[msg.Index].UID, msg)) + + account := m.config.GetAccountByID(msg.AccountID) + if account == nil { + m.current = m.previousModel + return m, nil + } + + email := m.getEmailByIndex(msg.Index) + if email == nil { + m.current = m.previousModel + return m, nil + } + + return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, msg)) case tui.AttachmentDownloadedMsg: var statusMsg string @@ -260,22 +576,215 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m *mainModel) getEmailByIndex(index int) *fetcher.Email { + if index >= 0 && index < len(m.emails) { + return &m.emails[index] + } + return nil +} + +func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email { + for i := range m.emails { + if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { + return &m.emails[i] + } + } + return nil +} + +func (m *mainModel) getEmailIndex(uid uint32, accountID string) int { + for i := range m.emails { + if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { + return i + } + } + return -1 +} + +func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body string, attachments []fetcher.Attachment) { + // Update in all emails list + for i := range m.emails { + if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { + m.emails[i].Body = body + m.emails[i].Attachments = attachments + break + } + } + + // Also update in account-specific store + if emails, ok := m.emailsByAcct[accountID]; ok { + for i := range emails { + if emails[i].UID == uid { + emails[i].Body = body + emails[i].Attachments = attachments + break + } + } + } +} + +func (m *mainModel) removeEmail(uid uint32, accountID string) { + // Remove from all emails + var filtered []fetcher.Email + for _, e := range m.emails { + if !(e.UID == uid && e.AccountID == accountID) { + filtered = append(filtered, e) + } + } + m.emails = filtered + + // Remove from account-specific store + if emails, ok := m.emailsByAcct[accountID]; ok { + var filteredAcct []fetcher.Email + for _, e := range emails { + if e.UID != uid { + filteredAcct = append(filteredAcct, e) + } + } + m.emailsByAcct[accountID] = filteredAcct + } +} + func (m *mainModel) View() string { return m.current.View() } -func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, index int) tea.Cmd { +func fetchAllAccountsEmails(cfg *config.Config) tea.Cmd { return func() tea.Msg { - body, attachments, err := fetcher.FetchEmailBody(cfg, email.UID) + 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.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount} + } +} + +func fetchEmails(account *config.Account, limit, offset uint32) tea.Cmd { + return func() tea.Msg { + emails, err := fetcher.FetchEmails(account, limit, offset) if err != nil { - return tui.EmailBodyFetchedMsg{Index: index, Err: err} + return tui.FetchErr(err) + } + if offset == 0 { + return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID} + } + return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID} + } +} + +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) + if account == nil { + return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")} + } + + body, attachments, err := fetcher.FetchEmailBody(account, uid) + if err != nil { + return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err} } - // Return the fetched data along with the original index return tui.EmailBodyFetchedMsg{ - Index: index, + UID: uid, Body: body, Attachments: attachments, + AccountID: accountID, } } } @@ -289,8 +798,12 @@ func markdownToHTML(md []byte) []byte { return buf.Bytes() } -func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd { +func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { return func() tea.Msg { + if account == nil { + return tui.EmailResultMsg{Err: fmt.Errorf("no account configured")} + } + recipients := []string{msg.To} body := msg.Body images := make(map[string][]byte) @@ -323,7 +836,7 @@ func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd { } } - err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References) + err := sender.SendEmail(account, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References) if err != nil { log.Printf("Failed to send email: %v", err) return tui.EmailResultMsg{Err: err} @@ -332,36 +845,23 @@ func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd { } } -func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd { - return func() tea.Msg { - emails, err := fetcher.FetchEmails(cfg, limit, offset) - if err != nil { - return tui.FetchErr(err) - } - if offset == 0 { - return tui.EmailsFetchedMsg{Emails: emails} - } - return tui.EmailsAppendedMsg{Emails: emails} - } -} - -func deleteEmailCmd(cfg *config.Config, uid uint32) tea.Cmd { +func deleteEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd { return func() tea.Msg { - err := fetcher.DeleteEmail(cfg, uid) - return tui.EmailActionDoneMsg{UID: uid, Err: err} + err := fetcher.DeleteEmail(account, uid) + return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err} } } -func archiveEmailCmd(cfg *config.Config, uid uint32) tea.Cmd { +func archiveEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd { return func() tea.Msg { - err := fetcher.ArchiveEmail(cfg, uid) - return tui.EmailActionDoneMsg{UID: uid, Err: err} + err := fetcher.ArchiveEmail(account, uid) + return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err} } } -func downloadAttachmentCmd(cfg *config.Config, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd { +func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd { return func() tea.Msg { - data, err := fetcher.FetchAttachment(cfg, uid, msg.PartID) + data, err := fetcher.FetchAttachment(account, uid, msg.PartID) if err != nil { return tui.AttachmentDownloadedMsg{Err: err} } diff --git a/public/index.html b/public/index.html index f898b7d5f9c66dcecaf668bf6782fab90f1bde6f..9ba54892daa562ba8aa26687aec6108a10eefdbd 100644 --- a/public/index.html +++ b/public/index.html @@ -102,7 +102,12 @@ matcha — zsh
-
Matcha
+
What would you like to do?
@@ -115,6 +120,10 @@ Compose Email
+
+ + Drafts +
Settings @@ -197,6 +206,35 @@ with keyboard shortcuts. No mouse required.

+ +
+
👥
+

Multiple Accounts

+

+ Manage multiple email accounts in one place. Switch + between accounts seamlessly without leaving your + terminal. +

+
+ +
+
📇
+

Contacts

+

+ Save and manage your contacts locally. Quickly + compose emails to your saved contacts with + auto-complete support. +

+
+ +
+
+

Email Caching

+

+ Emails are cached locally for faster access. Enjoy + instant load times. +

+
@@ -379,16 +417,65 @@

Supported Providers

- 📧 + + + + + + Gmail
- ☁️ + + + + + iCloud
-
- 📬 - More coming soon... +
+ + + + + + + Custom
diff --git a/public/style.css b/public/style.css index cf167006bf929fc99516668b48e70a13d6c7ad47..dff86891fcbbb9cbbbbb4bd62169a225c59e29d6 100644 --- a/public/style.css +++ b/public/style.css @@ -404,6 +404,15 @@ main { margin-bottom: 1.5rem; } +.tui-ascii-logo { + color: var(--accent-primary); + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.2; + margin: 0 0 1.5rem 0; + white-space: pre; +} + .tui-prompt { color: var(--text-secondary); font-size: 0.95rem; @@ -424,7 +433,7 @@ main { } .tui-cursor { - color: #ff79c6; + color: var(--accent-primary); font-weight: 700; } @@ -438,7 +447,7 @@ main { } .tui-option.selected { - color: #ff79c6; + color: var(--accent-primary); } .tui-hint { @@ -757,7 +766,15 @@ kbd { } .provider-icon { - font-size: 1.25rem; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent-primary); +} + +.provider-icon svg { + width: 20px; + height: 20px; } /* Contact Section */ diff --git a/sender/sender.go b/sender/sender.go index 38bf1cc1627aa9c183ebeb7cc28edf8f6618db90..bfd419199a286941e57f28485ed243466466cbae 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -27,26 +27,19 @@ func generateMessageID(from string) string { } // SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments. -func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string) error { - var smtpServer string - var smtpPort int - - switch cfg.ServiceProvider { - case "gmail": - smtpServer = "smtp.gmail.com" - smtpPort = 587 - case "icloud": - smtpServer = "smtp.mail.me.com" - smtpPort = 587 - default: - return fmt.Errorf("unsupported or missing service_provider in config.json: %s", cfg.ServiceProvider) +func SendEmail(account *config.Account, to []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string) error { + smtpServer := account.GetSMTPServer() + smtpPort := account.GetSMTPPort() + + if smtpServer == "" { + return fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider) } - auth := smtp.PlainAuth("", cfg.Email, cfg.Password, smtpServer) + auth := smtp.PlainAuth("", account.Email, account.Password, smtpServer) - fromHeader := cfg.Email - if cfg.Name != "" { - fromHeader = fmt.Sprintf("%s <%s>", cfg.Name, cfg.Email) + fromHeader := account.Email + if account.Name != "" { + fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.Email) } // Main message buffer @@ -59,7 +52,7 @@ func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody str "To": to[0], "Subject": subject, "Date": time.Now().Format(time.RFC1123Z), - "Message-ID": generateMessageID(cfg.Email), + "Message-ID": generateMessageID(account.Email), "Content-Type": "multipart/mixed; boundary=" + mainWriter.Boundary(), } @@ -162,5 +155,5 @@ func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody str mainWriter.Close() // Finish the main message addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort) - return smtp.SendMail(addr, auth, cfg.Email, to, msg.Bytes()) + return smtp.SendMail(addr, auth, account.Email, to, msg.Bytes()) } diff --git a/tui/choice.go b/tui/choice.go index 1011ff71ec39362dbb5e03dcd1a92143e428e32f..47bea29e0e52e4fe1b2054594075252ae21a7a56 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -6,31 +6,44 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/floatpane/matcha/config" ) // Styles defined locally to avoid import issues. var ( docStyle = lipgloss.NewStyle().Margin(1, 2) titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFDF5")).Background(lipgloss.Color("#25A065")).Padding(0, 1) + logoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) listHeader = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).PaddingBottom(1) itemStyle = lipgloss.NewStyle().PaddingLeft(2) selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42")) ) +// ASCII logo for the start screen +const choiceLogo = ` + __ __ + ____ ___ ____ _/ /______/ /_ ____ _ + / __ '__ \/ __ '/ __/ ___/ __ \/ __ '/ + / / / / / / /_/ / /_/ /__/ / / / /_/ / +/_/ /_/ /_/\__,_/\__/\___/_/ /_/\__,_/ +` + type Choice struct { cursor int choices []string - hasCachedDraft bool + hasSavedDrafts bool } -func NewChoice(hasCachedDraft bool) Choice { - choices := []string{"View Inbox", "Compose Email", "Settings"} - if hasCachedDraft { - choices = append(choices, "Restore Draft") +func NewChoice() Choice { + hasSavedDrafts := config.HasDrafts() + choices := []string{"View Inbox", "Compose Email"} + if hasSavedDrafts { + choices = append(choices, "Drafts") } + choices = append(choices, "Settings") return Choice{ choices: choices, - hasCachedDraft: hasCachedDraft, + hasSavedDrafts: hasSavedDrafts, } } @@ -57,10 +70,10 @@ 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": - return m, func() tea.Msg { return RestoreDraftMsg{} } } } } @@ -70,7 +83,8 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Choice) View() string { var b strings.Builder - b.WriteString(titleStyle.Render("Matcha") + "\n\n") + b.WriteString(logoStyle.Render(choiceLogo)) + b.WriteString("\n") b.WriteString(listHeader.Render("What would you like to do?")) b.WriteString("\n\n") diff --git a/tui/composer.go b/tui/composer.go index 85278301606bbb5bbf23afc42e51a7f5c17dfa90..505a9822e33027f11fd52b505567b9fbd97e0dff 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -8,6 +8,14 @@ import ( "github.com/charmbracelet/bubbles/textinput" 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 @@ -20,7 +28,17 @@ var ( focusedButton = focusedStyle.Copy().Render("[ Send ]") blurredButton = blurredStyle.Copy().Render("[ Send ]") emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) - attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) // This was the missing style + attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) + fromSelectorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) +) + +const ( + focusFrom = iota + focusTo + focusSubject + focusBody + focusAttachment + focusSend ) // Composer model holds the state of the email composition UI. @@ -30,21 +48,39 @@ type Composer struct { subjectInput textinput.Model bodyInput textarea.Model attachmentPath string - fromAddr string width int height int confirmingExit bool + + // Multi-account support + 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{fromAddr: from} + m := &Composer{ + draftID: uuid.New().String(), + } m.toInput = textinput.New() m.toInput.Cursor.Style = cursorStyle m.toInput.Placeholder = "To" m.toInput.SetValue(to) - m.toInput.Focus() m.toInput.Prompt = "> " m.toInput.CharLimit = 256 @@ -63,6 +99,26 @@ func NewComposer(from, to, subject, body string) *Composer { m.bodyInput.SetHeight(10) m.bodyInput.SetCursor(0) + // Start focus on To field (From is selectable but not a text input) + m.focusIndex = focusTo + m.toInput.Focus() + + return m +} + +// NewComposerWithAccounts initializes a composer with multiple account support. +func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string) *Composer { + m := NewComposer("", to, subject, body) + m.accounts = accounts + + // Find the selected account index + for i, acc := range accounts { + if acc.ID == selectedAccountID { + m.selectedAccountIdx = i + break + } + } + return m } @@ -75,6 +131,24 @@ func (m *Composer) Init() tea.Cmd { return textinput.Blink } +func (m *Composer) getFromAddress() string { + if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) { + acc := m.accounts[m.selectedAccountIdx] + if acc.Name != "" { + return fmt.Sprintf("%s <%s>", acc.Name, acc.Email) + } + return acc.Email + } + return "" +} + +func (m *Composer) getSelectedAccount() *config.Account { + if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) { + return &m.accounts[m.selectedAccountIdx] + } + return nil +} + func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd @@ -97,6 +171,62 @@ 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() { + case "up", "k": + if m.selectedAccountIdx > 0 { + m.selectedAccountIdx-- + } + case "down", "j": + if m.selectedAccountIdx < len(m.accounts)-1 { + m.selectedAccountIdx++ + } + case "enter": + m.showAccountPicker = false + case "esc": + m.showAccountPicker = false + } + return m, nil + } + if m.confirmingExit { switch msg.String() { case "y", "Y": @@ -123,10 +253,17 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focusIndex++ } - if m.focusIndex > 4 { - m.focusIndex = 0 - } else if m.focusIndex < 0 { - m.focusIndex = 4 + 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 = minFocus + } else if m.focusIndex < minFocus { + m.focusIndex = maxFocus } m.toInput.Blur() @@ -134,27 +271,38 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.bodyInput.Blur() switch m.focusIndex { - case 0: + case focusTo: cmds = append(cmds, m.toInput.Focus()) - case 1: + case focusSubject: cmds = append(cmds, m.subjectInput.Focus()) - case 2: + case focusBody: cmds = append(cmds, m.bodyInput.Focus()) cmds = append(cmds, func() tea.Msg { return SetComposerCursorToStartMsg{} }) } return m, tea.Batch(cmds...) case tea.KeyEnter: - if m.focusIndex == 3 { + switch m.focusIndex { + case focusFrom: + if len(m.accounts) > 1 { + m.showAccountPicker = true + } + return m, nil + case focusAttachment: return m, func() tea.Msg { return GoToFilePickerMsg{} } - } - if m.focusIndex == 4 { + case focusSend: + acc := m.getSelectedAccount() + accountID := "" + if acc != nil { + accountID = acc.ID + } return m, func() tea.Msg { return SendEmailMsg{ To: m.toInput.Value(), Subject: m.subjectInput.Value(), Body: m.bodyInput.Value(), AttachmentPath: m.attachmentPath, + AccountID: accountID, } } } @@ -162,13 +310,27 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch m.focusIndex { - case 0: + case focusTo: m.toInput, cmd = m.toInput.Update(msg) cmds = append(cmds, cmd) - case 1: + + // 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) - case 2: + case focusBody: m.bodyInput, cmd = m.bodyInput.Update(msg) cmds = append(cmds, cmd) } @@ -180,35 +342,91 @@ func (m *Composer) View() string { var composerView strings.Builder var button string - if m.focusIndex == 4 { + if m.focusIndex == focusSend { button = focusedButton } else { button = blurredButton } + // From field with account selector + fromAddr := m.getFromAddress() + var fromField string + if len(m.accounts) > 1 { + if m.focusIndex == focusFrom { + fromField = focusedStyle.Render(fmt.Sprintf("> From: %s [Enter to switch]", fromAddr)) + } else { + fromField = blurredStyle.Render(fmt.Sprintf(" From: %s [switchable]", fromAddr)) + } + } else if fromAddr != "" { + fromField = " From: " + emailRecipientStyle.Render(fromAddr) + } else { + fromField = blurredStyle.Render(" From: (no account configured)") + } + var attachmentField string attachmentText := "None (Press Enter to select)" if m.attachmentPath != "" { attachmentText = m.attachmentPath } - if m.focusIndex == 3 { + if m.focusIndex == focusAttachment { attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText)) } else { 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", - "From: "+emailRecipientStyle.Render(m.fromAddr), - m.toInput.View(), + fromField, + 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 + if m.showAccountPicker { + var accountList strings.Builder + accountList.WriteString("Select Account:\n\n") + for i, acc := range m.accounts { + display := acc.Email + if acc.Name != "" { + display = fmt.Sprintf("%s (%s)", acc.Name, acc.Email) + } + if i == m.selectedAccountIdx { + accountList.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", display))) + } else { + accountList.WriteString(itemStyle.Render(fmt.Sprintf(" %s", display))) + } + accountList.WriteString("\n") + } + accountList.WriteString("\n") + accountList.WriteString(HelpStyle.Render("↑/↓: navigate • enter: select • esc: cancel")) + + dialog := DialogBoxStyle.Render(accountList.String()) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) + } + if m.confirmingExit { dialog := DialogBoxStyle.Render( lipgloss.JoinVertical(lipgloss.Center, @@ -221,3 +439,99 @@ func (m *Composer) View() string { return composerView.String() } + +// SetAccounts sets the available accounts for sending. +func (m *Composer) SetAccounts(accounts []config.Account) { + m.accounts = accounts + if m.selectedAccountIdx >= len(accounts) { + m.selectedAccountIdx = 0 + } +} + +// SetSelectedAccount sets the selected account by ID. +func (m *Composer) SetSelectedAccount(accountID string) { + for i, acc := range m.accounts { + if acc.ID == accountID { + m.selectedAccountIdx = i + return + } + } +} + +// GetSelectedAccountID returns the ID of the currently selected account. +func (m *Composer) GetSelectedAccountID() string { + if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) { + return m.accounts[m.selectedAccountIdx].ID + } + 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 7f07cab353fc07f131b0621c83473d50aaf4a5ab..257a1f10b9327ef980dc4f1c716e8542fb0c01ee 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -1,66 +1,74 @@ package tui import ( - "reflect" "testing" tea "github.com/charmbracelet/bubbletea" + "github.com/floatpane/matcha/config" ) // TestComposerUpdate verifies the state transitions in the email composer. func TestComposerUpdate(t *testing.T) { - // Initialize a new composer. - composer := NewComposer("test@example.com", "", "", "") + // Initialize a new composer with accounts. + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com", Name: "Test User"}, + } + composer := NewComposerWithAccounts(accounts, "account-1", "", "", "") t.Run("Focus cycling", func(t *testing.T) { - // Initial focus is on the 'To' input (index 0). - if composer.focusIndex != 0 { - t.Errorf("Initial focusIndex should be 0, got %d", composer.focusIndex) + // Initial focus is on the 'To' input (index 1, since From is 0). + // But NewComposer starts focus at focusTo which is 1. + if composer.focusIndex != focusTo { + t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, composer.focusIndex) } // Simulate pressing Tab to move to the 'Subject' field. model, _ := composer.Update(tea.KeyMsg{Type: tea.KeyTab}) composer = model.(*Composer) - if composer.focusIndex != 1 { - t.Errorf("After one Tab, focusIndex should be 1 (Subject), got %d", composer.focusIndex) + if composer.focusIndex != focusSubject { + t.Errorf("After one Tab, focusIndex should be %d (focusSubject), got %d", focusSubject, composer.focusIndex) } // Simulate pressing Tab again to move to the 'Body' field. model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) composer = model.(*Composer) - if composer.focusIndex != 2 { - t.Errorf("After two Tabs, focusIndex should be 2 (Body), got %d", composer.focusIndex) + if composer.focusIndex != focusBody { + t.Errorf("After two Tabs, focusIndex should be %d (focusBody), got %d", focusBody, composer.focusIndex) } // Simulate pressing Tab again to move to the 'Attachment' field. model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) composer = model.(*Composer) - if composer.focusIndex != 3 { - t.Errorf("After three Tabs, focusIndex should be 3 (Attachment), got %d", composer.focusIndex) + if composer.focusIndex != focusAttachment { + t.Errorf("After three Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex) } // Simulate pressing Tab again to move to the 'Send' button. model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) composer = model.(*Composer) - if composer.focusIndex != 4 { - t.Errorf("After four Tabs, focusIndex should be 4 (Send), got %d", composer.focusIndex) + if composer.focusIndex != focusSend { + t.Errorf("After four Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex) } - // Simulate one more Tab to wrap around to the 'To' 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 != 0 { - t.Errorf("After five Tabs, focusIndex should wrap to 0, got %d", 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) } }) t.Run("Send email message", func(t *testing.T) { + // Re-initialize composer for this test + composer = NewComposerWithAccounts(accounts, "account-1", "", "", "") + // Set values for the email fields. composer.toInput.SetValue("recipient@example.com") composer.subjectInput.SetValue("Test Subject") composer.bodyInput.SetValue("This is the body.") // Set focus to the Send button. - composer.focusIndex = 4 + composer.focusIndex = focusSend // Simulate pressing Enter to send the email. _, cmd := composer.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -76,14 +84,248 @@ func TestComposerUpdate(t *testing.T) { } // Verify the content of the message. - expectedMsg := SendEmailMsg{ - To: "recipient@example.com", - Subject: "Test Subject", - Body: "This is the body.", - AttachmentPath: "", // Expect empty attachment path in this test + if sendMsg.To != "recipient@example.com" { + t.Errorf("Expected To 'recipient@example.com', got %q", sendMsg.To) + } + if sendMsg.Subject != "Test Subject" { + t.Errorf("Expected Subject 'Test Subject', got %q", sendMsg.Subject) + } + if sendMsg.Body != "This is the body." { + t.Errorf("Expected Body 'This is the body.', got %q", sendMsg.Body) + } + if sendMsg.AccountID != "account-1" { + t.Errorf("Expected AccountID 'account-1', got %q", sendMsg.AccountID) + } + }) + + t.Run("Account picker with multiple accounts", func(t *testing.T) { + multiAccounts := []config.Account{ + {ID: "account-1", Email: "test1@example.com", Name: "User 1"}, + {ID: "account-2", Email: "test2@example.com", Name: "User 2"}, + } + multiComposer := NewComposerWithAccounts(multiAccounts, "account-1", "", "", "") + + // Move focus to From field + multiComposer.focusIndex = focusFrom + + // Press Enter to open account picker + model, _ := multiComposer.Update(tea.KeyMsg{Type: tea.KeyEnter}) + multiComposer = model.(*Composer) + + if !multiComposer.showAccountPicker { + t.Error("Expected account picker to be shown") } - if !reflect.DeepEqual(sendMsg, expectedMsg) { - t.Errorf("Mismatched SendEmailMsg.\nGot: %+v\nWant: %+v", sendMsg, expectedMsg) + + // Navigate down to select second account + model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyDown}) + multiComposer = model.(*Composer) + + if multiComposer.selectedAccountIdx != 1 { + t.Errorf("Expected selectedAccountIdx to be 1, got %d", multiComposer.selectedAccountIdx) + } + + // Press Enter to confirm selection + model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyEnter}) + multiComposer = model.(*Composer) + + if multiComposer.showAccountPicker { + t.Error("Expected account picker to be closed") + } + + // Verify the selected account + if multiComposer.GetSelectedAccountID() != "account-2" { + t.Errorf("Expected selected account ID 'account-2', got %q", multiComposer.GetSelectedAccountID()) } }) + + t.Run("Single account no picker", func(t *testing.T) { + singleAccounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + singleComposer := NewComposerWithAccounts(singleAccounts, "account-1", "", "", "") + + // Move focus to From field + singleComposer.focusIndex = focusFrom + + // Press Enter - should not open picker with single account + model, _ := singleComposer.Update(tea.KeyMsg{Type: tea.KeyEnter}) + singleComposer = model.(*Composer) + + if singleComposer.showAccountPicker { + 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. +func TestComposerGetFromAddress(t *testing.T) { + t.Run("With name", func(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com", Name: "Test User"}, + } + composer := NewComposerWithAccounts(accounts, "account-1", "", "", "") + + fromAddr := composer.getFromAddress() + expected := "Test User " + if fromAddr != expected { + t.Errorf("Expected from address %q, got %q", expected, fromAddr) + } + }) + + t.Run("Without name", func(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + composer := NewComposerWithAccounts(accounts, "account-1", "", "", "") + + fromAddr := composer.getFromAddress() + expected := "test@example.com" + if fromAddr != expected { + t.Errorf("Expected from address %q, got %q", expected, fromAddr) + } + }) + + t.Run("No accounts", func(t *testing.T) { + composer := NewComposer("", "", "", "") + + fromAddr := composer.getFromAddress() + if fromAddr != "" { + t.Errorf("Expected empty from address, got %q", fromAddr) + } + }) +} + +// TestComposerSetSelectedAccount verifies account selection. +func TestComposerSetSelectedAccount(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test1@example.com"}, + {ID: "account-2", Email: "test2@example.com"}, + {ID: "account-3", Email: "test3@example.com"}, + } + composer := NewComposerWithAccounts(accounts, "account-1", "", "", "") + + composer.SetSelectedAccount("account-3") + if composer.selectedAccountIdx != 2 { + t.Errorf("Expected selectedAccountIdx 2, got %d", composer.selectedAccountIdx) + } + if composer.GetSelectedAccountID() != "account-3" { + t.Errorf("Expected selected account ID 'account-3', got %q", composer.GetSelectedAccountID()) + } + + // Test non-existent account (should not change) + composer.SetSelectedAccount("non-existent") + if composer.selectedAccountIdx != 2 { + t.Errorf("Expected selectedAccountIdx to remain 2, got %d", composer.selectedAccountIdx) + } } 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/email_view.go b/tui/email_view.go index 908cd959adca0595f7631c65a641a1dcf3de3009..4a1d7bbc170b0758cd914971b0f309d6b5509925 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -22,6 +22,7 @@ type EmailView struct { emailIndex int attachmentCursor int focusOnAttachments bool + accountID string } func NewEmailView(email fetcher.Email, emailIndex, width, height int) *EmailView { @@ -46,6 +47,7 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int) *EmailView viewport: vp, email: email, emailIndex: emailIndex, + accountID: email.AccountID, } } @@ -82,12 +84,14 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.email.Attachments) > 0 { selected := m.email.Attachments[m.attachmentCursor] idx := m.emailIndex + accountID := m.accountID return m, func() tea.Msg { return DownloadAttachmentMsg{ - Index: idx, - Filename: selected.Filename, - PartID: selected.PartID, - Data: selected.Data, + Index: idx, + Filename: selected.Filename, + PartID: selected.PartID, + Data: selected.Data, + AccountID: accountID, } } } @@ -98,6 +102,18 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "r": return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} } + case "d": + accountID := m.accountID + uid := m.email.UID + return m, func() tea.Msg { + return DeleteEmailMsg{UID: uid, AccountID: accountID} + } + case "a": + accountID := m.accountID + uid := m.email.UID + return m, func() tea.Msg { + return ArchiveEmailMsg{UID: uid, AccountID: accountID} + } case "tab": if len(m.email.Attachments) > 0 { m.focusOnAttachments = true @@ -129,7 +145,7 @@ func (m *EmailView) View() string { if m.focusOnAttachments { help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body") } else { - help = helpStyle.Render("r: reply • tab: focus attachments • esc: back to inbox") + help = helpStyle.Render("r: reply • d: delete • a: archive • tab: focus attachments • esc: back to inbox") } var attachmentView string @@ -151,3 +167,13 @@ func (m *EmailView) View() string { return fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help) } + +// GetAccountID returns the account ID for this email +func (m *EmailView) GetAccountID() string { + return m.accountID +} + +// GetEmail returns the email being viewed +func (m *EmailView) GetEmail() fetcher.Email { + return m.email +} diff --git a/tui/inbox.go b/tui/inbox.go index f1ce8e7dd094f19e048308d870334e8845bdca6f..05f0d237f82e1ca112d19ca31876f5f4d3d11729 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -3,23 +3,30 @@ package tui import ( "fmt" "io" + "strings" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" ) var ( paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) inboxHelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) + tabStyle = lipgloss.NewStyle().Padding(0, 2) + activeTabStyle = lipgloss.NewStyle().Padding(0, 2).Foreground(lipgloss.Color("42")).Bold(true).Underline(true) + tabBarStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1) ) type item struct { title, desc string originalIndex int - uid uint32 // Added UID to item + uid uint32 + accountID string + accountEmail string } func (i item) Title() string { return i.title } @@ -39,6 +46,11 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list str := fmt.Sprintf("%d. %s", index+1, i.title) + // For "ALL" view, show account indicator + if i.accountEmail != "" { + str = fmt.Sprintf("%d. [%s] %s", index+1, truncateEmail(i.accountEmail), i.title) + } + fn := itemStyle.Render if index == m.Index() { fn = func(s ...string) string { @@ -49,25 +61,122 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list fmt.Fprint(w, fn(str)) } +// truncateEmail shortens an email for display +func truncateEmail(email string) string { + parts := strings.Split(email, "@") + if len(parts) >= 1 && len(parts[0]) > 8 { + return parts[0][:8] + "..." + } + if len(parts) >= 1 { + return parts[0] + } + return email +} + +// AccountTab represents a tab for an account +type AccountTab struct { + ID string + Label string + Email string +} + type Inbox struct { - list list.Model - isFetching bool - emailsCount int + list list.Model + isFetching bool + isRefreshing bool + emailsCount int + accounts []config.Account + emailsByAccount map[string][]fetcher.Email + allEmails []fetcher.Email + tabs []AccountTab + activeTabIndex int + width int + height int + currentAccountID string // Empty means "ALL" + emailCountByAcct map[string]int +} + +func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox { + // Build tabs: "ALL" + one per account + tabs := []AccountTab{{ID: "", Label: "ALL", Email: ""}} + for _, acc := range accounts { + label := acc.Email + if acc.Name != "" { + label = acc.Name + } + tabs = append(tabs, AccountTab{ID: acc.ID, Label: label, Email: acc.Email}) + } + + // Group emails by account + emailsByAccount := make(map[string][]fetcher.Email) + for _, email := range emails { + emailsByAccount[email.AccountID] = append(emailsByAccount[email.AccountID], email) + } + + // Track email counts per account + emailCountByAcct := make(map[string]int) + for accID, accEmails := range emailsByAccount { + emailCountByAcct[accID] = len(accEmails) + } + + inbox := &Inbox{ + accounts: accounts, + emailsByAccount: emailsByAccount, + allEmails: emails, + tabs: tabs, + activeTabIndex: 0, + currentAccountID: "", + emailCountByAcct: emailCountByAcct, + } + + inbox.updateList() + return inbox +} + +// NewInboxSingleAccount creates an inbox for a single account (legacy support) +func NewInboxSingleAccount(emails []fetcher.Email) *Inbox { + return NewInbox(emails, nil) } -func NewInbox(emails []fetcher.Email) *Inbox { - items := make([]list.Item, len(emails)) - for i, email := range emails { +func (m *Inbox) updateList() { + var displayEmails []fetcher.Email + var showAccountLabel bool + + if m.currentAccountID == "" { + // "ALL" view - show all emails sorted by date + displayEmails = m.allEmails + showAccountLabel = len(m.accounts) > 1 + } else { + // Specific account view + displayEmails = m.emailsByAccount[m.currentAccountID] + showAccountLabel = false + } + + items := make([]list.Item, len(displayEmails)) + for i, email := range displayEmails { + accountEmail := "" + if showAccountLabel { + // Find the account email for display + for _, acc := range m.accounts { + if acc.ID == email.AccountID { + accountEmail = acc.Email + break + } + } + } + items[i] = item{ title: email.Subject, desc: email.From, originalIndex: i, - uid: email.UID, // Store UID + uid: email.UID, + accountID: email.AccountID, + accountEmail: accountEmail, } } l := list.New(items, itemDelegate{}, 20, 14) - l.Title = "Inbox" + l.Title = m.getTitle() l.SetShowStatusBar(true) l.SetFilteringEnabled(true) l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) @@ -75,20 +184,50 @@ func NewInbox(emails []fetcher.Email) *Inbox { l.Styles.HelpStyle = inboxHelpStyle l.SetStatusBarItemName("email", "emails") l.AdditionalShortHelpKeys = func() []key.Binding { - return []key.Binding{ + bindings := []key.Binding{ key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "archive")), - key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl + c", "quit")), } + if len(m.tabs) > 1 { + bindings = append(bindings, + key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "prev tab")), + key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "next tab")), + ) + } + return bindings } l.KeyMap.Quit.SetEnabled(false) - return &Inbox{ - list: l, - isFetching: false, - emailsCount: len(emails), + if m.width > 0 { + l.SetWidth(m.width) } + + m.list = l + m.emailsCount = len(displayEmails) +} + +func (m *Inbox) getTitle() string { + var title string + if m.currentAccountID == "" { + 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 + } + } + } + if m.isRefreshing { + title += " (refreshing...)" + } + return title } func (m *Inbox) Init() tea.Cmd { @@ -104,29 +243,54 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } switch keypress := msg.String(); keypress { + case "left", "h": + if len(m.tabs) > 1 { + m.activeTabIndex-- + if m.activeTabIndex < 0 { + m.activeTabIndex = len(m.tabs) - 1 + } + m.currentAccountID = m.tabs[m.activeTabIndex].ID + m.updateList() + return m, nil + } + case "right", "l": + if len(m.tabs) > 1 { + m.activeTabIndex++ + if m.activeTabIndex >= len(m.tabs) { + m.activeTabIndex = 0 + } + m.currentAccountID = m.tabs[m.activeTabIndex].ID + m.updateList() + return m, nil + } case "d": selectedItem, ok := m.list.SelectedItem().(item) if ok { return m, func() tea.Msg { - return DeleteEmailMsg{UID: selectedItem.uid} + return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID} } } case "a": selectedItem, ok := m.list.SelectedItem().(item) if ok { return m, func() tea.Msg { - return ArchiveEmailMsg{UID: selectedItem.uid} + return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID} } } case "enter": selectedItem, ok := m.list.SelectedItem().(item) if ok { + idx := selectedItem.originalIndex + uid := selectedItem.uid + accountID := selectedItem.accountID return m, func() tea.Msg { - return ViewEmailMsg{Index: selectedItem.originalIndex} + return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID} } } } case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height m.list.SetWidth(msg.Width) return m, nil @@ -137,28 +301,67 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case EmailsAppendedMsg: m.isFetching = false - m.list.Title = "Inbox" - newItems := make([]list.Item, len(msg.Emails)) - for i, email := range msg.Emails { - newItems[i] = item{ - title: email.Subject, - desc: email.From, - originalIndex: m.emailsCount + i, - uid: email.UID, + m.list.Title = m.getTitle() + + // Add emails to the appropriate account + for _, email := range msg.Emails { + m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email) + m.allEmails = append(m.allEmails, email) + } + 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] + } } } - currentItems := m.list.Items() - allItems := append(currentItems, newItems...) - cmd := m.list.SetItems(allItems) - m.emailsCount += len(msg.Emails) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) + + 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 } if !m.isFetching && len(m.list.Items()) > 0 && m.list.Index() >= len(m.list.Items())-5 { - cmds = append(cmds, func() tea.Msg { - return FetchMoreEmailsMsg{Offset: uint32(m.emailsCount)} - }) + accountID := m.currentAccountID + offset := uint32(m.emailsCount) + if accountID == "" && len(m.accounts) > 0 { + // For "ALL" view, we'd need to fetch from all accounts + // For simplicity, don't auto-fetch more in ALL view + } else if accountID != "" { + cmds = append(cmds, func() tea.Msg { + return FetchMoreEmailsMsg{Offset: offset, AccountID: accountID} + }) + } } var cmd tea.Cmd @@ -168,5 +371,104 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Inbox) View() string { - return "\n" + m.list.View() + var b strings.Builder + + // Render tabs if there are multiple accounts + if len(m.tabs) > 1 { + var tabViews []string + for i, tab := range m.tabs { + label := tab.Label + if tab.ID == "" { + label = "ALL" + } + + if i == m.activeTabIndex { + tabViews = append(tabViews, activeTabStyle.Render(label)) + } else { + tabViews = append(tabViews, tabStyle.Render(label)) + } + } + tabBar := tabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...)) + b.WriteString(tabBar) + b.WriteString("\n") + } + + b.WriteString(m.list.View()) + return "\n" + b.String() +} + +// GetCurrentAccountID returns the currently selected account ID +func (m *Inbox) GetCurrentAccountID() string { + return m.currentAccountID +} + +// GetEmailAtIndex returns the email at the given index for the current view +func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email { + var displayEmails []fetcher.Email + if m.currentAccountID == "" { + displayEmails = m.allEmails + } else { + displayEmails = m.emailsByAccount[m.currentAccountID] + } + + if index >= 0 && index < len(displayEmails) { + return &displayEmails[index] + } + return nil +} + +// RemoveEmail removes an email by UID and account ID +func (m *Inbox) RemoveEmail(uid uint32, accountID string) { + // Remove from account-specific list + if emails, ok := m.emailsByAccount[accountID]; ok { + var filtered []fetcher.Email + for _, e := range emails { + if e.UID != uid { + filtered = append(filtered, e) + } + } + m.emailsByAccount[accountID] = filtered + } + + // Remove from all emails list + var filteredAll []fetcher.Email + for _, e := range m.allEmails { + if !(e.UID == uid && e.AccountID == accountID) { + filteredAll = append(filteredAll, e) + } + } + m.allEmails = filteredAll + + m.updateList() +} + +// SetEmails updates all emails (used after fetch) +func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) { + m.accounts = accounts + m.allEmails = emails + + // Rebuild tabs + tabs := []AccountTab{{ID: "", Label: "ALL", Email: ""}} + for _, acc := range accounts { + label := acc.Email + if acc.Name != "" { + label = acc.Name + } + tabs = append(tabs, AccountTab{ID: acc.ID, Label: label, Email: acc.Email}) + } + m.tabs = tabs + + // Re-group emails by account + m.emailsByAccount = make(map[string][]fetcher.Email) + for _, email := range emails { + m.emailsByAccount[email.AccountID] = append(m.emailsByAccount[email.AccountID], email) + } + + // Update email counts + m.emailCountByAcct = make(map[string]int) + for accID, accEmails := range m.emailsByAccount { + m.emailCountByAcct[accID] = len(accEmails) + } + + m.updateList() } diff --git a/tui/inbox_test.go b/tui/inbox_test.go index 0cf79058a86e1575db83ce2ece39eb0c0d738f3a..765d75e9940a61c5421ab6f733dccc29405692e7 100644 --- a/tui/inbox_test.go +++ b/tui/inbox_test.go @@ -5,19 +5,26 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" ) // TestInboxUpdate verifies the state transitions in the inbox view. func TestInboxUpdate(t *testing.T) { + // Create sample accounts + accounts := []config.Account{ + {ID: "account-1", Email: "test1@example.com", Name: "Test User 1"}, + {ID: "account-2", Email: "test2@example.com", Name: "Test User 2"}, + } + // Create a sample list of emails. sampleEmails := []fetcher.Email{ - {From: "a@example.com", Subject: "Email 1", Date: time.Now()}, - {From: "b@example.com", Subject: "Email 2", Date: time.Now().Add(-time.Hour)}, - {From: "c@example.com", Subject: "Email 3", Date: time.Now().Add(-2 * time.Hour)}, + {UID: 1, From: "a@example.com", Subject: "Email 1", Date: time.Now(), AccountID: "account-1"}, + {UID: 2, From: "b@example.com", Subject: "Email 2", Date: time.Now().Add(-time.Hour), AccountID: "account-1"}, + {UID: 3, From: "c@example.com", Subject: "Email 3", Date: time.Now().Add(-2 * time.Hour), AccountID: "account-2"}, } - inbox := NewInbox(sampleEmails) + inbox := NewInbox(sampleEmails, accounts) t.Run("Select email to view", func(t *testing.T) { // By default, the first item is selected (index 0). @@ -42,5 +49,202 @@ func TestInboxUpdate(t *testing.T) { if viewMsg.Index != expectedIndex { t.Errorf("Expected index %d, but got %d", expectedIndex, viewMsg.Index) } + + // Verify UID and AccountID are passed correctly + expectedUID := uint32(2) // Second email has UID 2 + if viewMsg.UID != expectedUID { + t.Errorf("Expected UID %d, but got %d", expectedUID, viewMsg.UID) + } + + expectedAccountID := "account-1" // Second email belongs to account-1 + if viewMsg.AccountID != expectedAccountID { + t.Errorf("Expected AccountID %q, but got %q", expectedAccountID, viewMsg.AccountID) + } }) } + +// TestInboxMultiAccountTabs verifies that tabs are created for multiple accounts. +func TestInboxMultiAccountTabs(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test1@example.com", Name: "User 1"}, + {ID: "account-2", Email: "test2@example.com", Name: "User 2"}, + } + + emails := []fetcher.Email{ + {UID: 1, From: "sender@example.com", Subject: "Test", AccountID: "account-1"}, + } + + inbox := NewInbox(emails, accounts) + + // Should have 3 tabs: ALL + 2 accounts + if len(inbox.tabs) != 3 { + t.Errorf("Expected 3 tabs, got %d", len(inbox.tabs)) + } + + // First tab should be "ALL" + if inbox.tabs[0].ID != "" { + t.Errorf("Expected first tab ID to be empty (ALL), got %q", inbox.tabs[0].ID) + } + if inbox.tabs[0].Label != "ALL" { + t.Errorf("Expected first tab label to be 'ALL', got %q", inbox.tabs[0].Label) + } +} + +// TestInboxSingleAccount verifies behavior with a single account. +func TestInboxSingleAccount(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + emails := []fetcher.Email{ + {UID: 1, From: "sender@example.com", Subject: "Test", AccountID: "account-1"}, + } + + inbox := NewInbox(emails, accounts) + + // Should have 2 tabs: ALL + 1 account + if len(inbox.tabs) != 2 { + t.Errorf("Expected 2 tabs, got %d", len(inbox.tabs)) + } +} + +// TestInboxNoAccounts verifies behavior with no accounts (legacy/edge case). +func TestInboxNoAccounts(t *testing.T) { + emails := []fetcher.Email{ + {UID: 1, From: "sender@example.com", Subject: "Test"}, + } + + inbox := NewInbox(emails, nil) + + // Should have 1 tab: ALL only + if len(inbox.tabs) != 1 { + t.Errorf("Expected 1 tab, got %d", len(inbox.tabs)) + } +} + +// TestInboxDeleteEmailMsg verifies that delete messages include account ID. +func TestInboxDeleteEmailMsg(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + emails := []fetcher.Email{ + {UID: 123, From: "sender@example.com", Subject: "Test", AccountID: "account-1"}, + } + + inbox := NewInbox(emails, accounts) + + // Simulate pressing 'd' to delete + _, cmd := inbox.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) + if cmd == nil { + t.Fatal("Expected a command, but got nil.") + } + + msg := cmd() + deleteMsg, ok := msg.(DeleteEmailMsg) + if !ok { + t.Fatalf("Expected a DeleteEmailMsg, but got %T", msg) + } + + if deleteMsg.UID != 123 { + t.Errorf("Expected UID 123, got %d", deleteMsg.UID) + } + + if deleteMsg.AccountID != "account-1" { + t.Errorf("Expected AccountID 'account-1', got %q", deleteMsg.AccountID) + } +} + +// TestInboxArchiveEmailMsg verifies that archive messages include account ID. +func TestInboxArchiveEmailMsg(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + emails := []fetcher.Email{ + {UID: 456, From: "sender@example.com", Subject: "Test", AccountID: "account-1"}, + } + + inbox := NewInbox(emails, accounts) + + // Simulate pressing 'a' to archive + _, cmd := inbox.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + if cmd == nil { + t.Fatal("Expected a command, but got nil.") + } + + msg := cmd() + archiveMsg, ok := msg.(ArchiveEmailMsg) + if !ok { + t.Fatalf("Expected an ArchiveEmailMsg, but got %T", msg) + } + + if archiveMsg.UID != 456 { + t.Errorf("Expected UID 456, got %d", archiveMsg.UID) + } + + if archiveMsg.AccountID != "account-1" { + t.Errorf("Expected AccountID 'account-1', got %q", archiveMsg.AccountID) + } +} + +// TestInboxRemoveEmail verifies that emails can be removed from the inbox. +func TestInboxRemoveEmail(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + emails := []fetcher.Email{ + {UID: 1, From: "a@example.com", Subject: "Email 1", AccountID: "account-1"}, + {UID: 2, From: "b@example.com", Subject: "Email 2", AccountID: "account-1"}, + } + + inbox := NewInbox(emails, accounts) + + // Remove the first email + inbox.RemoveEmail(1, "account-1") + + // Check that only one email remains + if len(inbox.allEmails) != 1 { + t.Errorf("Expected 1 email after removal, got %d", len(inbox.allEmails)) + } + + if inbox.allEmails[0].UID != 2 { + t.Errorf("Expected remaining email UID to be 2, got %d", inbox.allEmails[0].UID) + } +} + +// TestInboxGetEmailAtIndex verifies retrieving emails by index. +func TestInboxGetEmailAtIndex(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "test@example.com"}, + } + + emails := []fetcher.Email{ + {UID: 1, From: "a@example.com", Subject: "Email 1", AccountID: "account-1"}, + {UID: 2, From: "b@example.com", Subject: "Email 2", AccountID: "account-1"}, + } + + inbox := NewInbox(emails, accounts) + + // Get email at index 0 + email := inbox.GetEmailAtIndex(0) + if email == nil { + t.Fatal("Expected email at index 0, got nil") + } + if email.UID != 1 { + t.Errorf("Expected UID 1 at index 0, got %d", email.UID) + } + + // Get email at invalid index + email = inbox.GetEmailAtIndex(999) + if email != nil { + t.Error("Expected nil for invalid index, got non-nil") + } + + // Get email at negative index + email = inbox.GetEmailAtIndex(-1) + if email != nil { + t.Error("Expected nil for negative index, got non-nil") + } +} diff --git a/tui/login.go b/tui/login.go index eb205347a4557f43902afc5b191b4d1cb1c6b619..c7e473d0f01c161f61de710c7555964316cfc1f0 100644 --- a/tui/login.go +++ b/tui/login.go @@ -1,44 +1,75 @@ package tui import ( + "strconv" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -// Login holds the state for the login form. +// Login holds the state for the login/add account form. type Login struct { focusIndex int inputs []textinput.Model + showCustom bool // Show custom server fields + isEditMode bool // Whether we're editing an existing account + accountID string + width int + height int } -// NewLogin creates a new login model. +const ( + inputProvider = iota + inputName + inputEmail + inputPassword + inputIMAPServer + inputIMAPPort + inputSMTPServer + inputSMTPPort + inputCount +) + +// NewLogin creates a new login model for adding accounts. func NewLogin() *Login { m := &Login{ - inputs: make([]textinput.Model, 4), // Increased to 4 for provider, name, email, and password + inputs: make([]textinput.Model, inputCount), } var t textinput.Model for i := range m.inputs { t = textinput.New() t.Cursor.Style = focusedStyle - t.CharLimit = 64 + t.CharLimit = 128 switch i { - case 0: - t.Placeholder = "Provider (gmail or icloud)" + case inputProvider: + t.Placeholder = "Provider (gmail, icloud, or custom)" t.Focus() t.Prompt = "☁️ > " - case 1: - t.Placeholder = "Name" + case inputName: + t.Placeholder = "Display Name" t.Prompt = "👤 > " - case 2: - t.Placeholder = "Email" + case inputEmail: + t.Placeholder = "Email Address" t.Prompt = "✉️ > " - case 3: - t.Placeholder = "Password" + case inputPassword: + t.Placeholder = "Password / App Password" t.EchoMode = textinput.EchoPassword t.Prompt = "🔑 > " + case inputIMAPServer: + t.Placeholder = "IMAP Server (e.g., imap.example.com)" + t.Prompt = "📥 > " + case inputIMAPPort: + t.Placeholder = "IMAP Port (default: 993)" + t.Prompt = "🔢 > " + case inputSMTPServer: + t.Placeholder = "SMTP Server (e.g., smtp.example.com)" + t.Prompt = "📤 > " + case inputSMTPPort: + t.Placeholder = "SMTP Port (default: 587)" + t.Prompt = "🔢 > " } m.inputs[i] = t } @@ -55,39 +86,92 @@ func (m *Login) Init() tea.Cmd { func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - // When the window is resized, update the width of the inputs. + m.width = msg.Width + m.height = msg.Height for i := range m.inputs { - m.inputs[i].Width = msg.Width - 6 // Subtract for padding and prompt + m.inputs[i].Width = msg.Width - 6 } case tea.KeyMsg: switch msg.Type { - // On Enter, if we are on the last field, submit the credentials. + case tea.KeyEsc: + return m, func() tea.Msg { return GoToChoiceMenuMsg{} } + case tea.KeyEnter: - if m.focusIndex == len(m.inputs)-1 { + // Check if provider is "custom" to show/hide custom fields + provider := m.inputs[inputProvider].Value() + if provider == "custom" { + m.showCustom = true + } else { + m.showCustom = false + } + + lastFieldIndex := inputPassword + if m.showCustom { + lastFieldIndex = inputSMTPPort + } + + if m.focusIndex == lastFieldIndex { + // Submit the form + imapPort := 993 + smtpPort := 587 + if m.inputs[inputIMAPPort].Value() != "" { + if p, err := strconv.Atoi(m.inputs[inputIMAPPort].Value()); err == nil { + imapPort = p + } + } + if m.inputs[inputSMTPPort].Value() != "" { + if p, err := strconv.Atoi(m.inputs[inputSMTPPort].Value()); err == nil { + smtpPort = p + } + } + return m, func() tea.Msg { return Credentials{ - Provider: m.inputs[0].Value(), - Name: m.inputs[1].Value(), - Email: m.inputs[2].Value(), - Password: m.inputs[3].Value(), + Provider: m.inputs[inputProvider].Value(), + Name: m.inputs[inputName].Value(), + Email: m.inputs[inputEmail].Value(), + Password: m.inputs[inputPassword].Value(), + IMAPServer: m.inputs[inputIMAPServer].Value(), + IMAPPort: imapPort, + SMTPServer: m.inputs[inputSMTPServer].Value(), + SMTPPort: smtpPort, } } } fallthrough - // Cycle focus between inputs. + case tea.KeyTab, tea.KeyShiftTab, tea.KeyUp, tea.KeyDown: s := msg.String() + + // Check provider to update showCustom + provider := m.inputs[inputProvider].Value() + m.showCustom = provider == "custom" + + maxIndex := inputPassword + if m.showCustom { + maxIndex = inputSMTPPort + } + if s == "up" || s == "shift+tab" { m.focusIndex-- } else { m.focusIndex++ } - if m.focusIndex >= len(m.inputs) { + if m.focusIndex > maxIndex { m.focusIndex = 0 } else if m.focusIndex < 0 { - m.focusIndex = len(m.inputs) - 1 + m.focusIndex = maxIndex + } + + // Skip custom fields if not showing them + if !m.showCustom && m.focusIndex > inputPassword { + if s == "up" || s == "shift+tab" { + m.focusIndex = inputPassword + } else { + m.focusIndex = 0 + } } cmds := make([]tea.Cmd, len(m.inputs)) @@ -102,23 +186,84 @@ func (m *Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Update the focused input field. + // Update the focused input field var cmds = make([]tea.Cmd, len(m.inputs)) for i := range m.inputs { m.inputs[i], cmds[i] = m.inputs[i].Update(msg) } + + // Check if provider changed + provider := m.inputs[inputProvider].Value() + m.showCustom = provider == "custom" + return m, tea.Batch(cmds...) } // View renders the login form. func (m *Login) View() string { - return lipgloss.JoinVertical(lipgloss.Left, - titleStyle.Render("Account Settings"), - "Update your credentials.", - m.inputs[0].View(), - m.inputs[1].View(), - m.inputs[2].View(), - m.inputs[3].View(), - helpStyle.Render("\nenter: save • tab: next field • esc: back to menu"), - ) + title := "Add Account" + if m.isEditMode { + title = "Edit Account" + } + + customHint := "" + if m.inputs[inputProvider].Value() == "custom" || m.showCustom { + customHint = "\n" + accountEmailStyle.Render("Custom provider selected - configure server settings below") + } + + views := []string{ + titleStyle.Render(title), + "Enter your email account credentials.", + customHint, + m.inputs[inputProvider].View(), + m.inputs[inputName].View(), + m.inputs[inputEmail].View(), + m.inputs[inputPassword].View(), + } + + if m.showCustom { + views = append(views, + "", + listHeader.Render("Custom Server Settings:"), + m.inputs[inputIMAPServer].View(), + m.inputs[inputIMAPPort].View(), + m.inputs[inputSMTPServer].View(), + m.inputs[inputSMTPPort].View(), + ) + } + + views = append(views, helpStyle.Render("\nenter: save • tab: next field • esc: back to menu")) + + return lipgloss.JoinVertical(lipgloss.Left, views...) +} + +// SetEditMode sets the login form to edit an existing account. +func (m *Login) SetEditMode(accountID, provider, name, email, imapServer string, imapPort int, smtpServer string, smtpPort int) { + m.isEditMode = true + m.accountID = accountID + m.inputs[inputProvider].SetValue(provider) + m.inputs[inputName].SetValue(name) + m.inputs[inputEmail].SetValue(email) + m.showCustom = provider == "custom" + + if m.showCustom { + m.inputs[inputIMAPServer].SetValue(imapServer) + if imapPort != 0 { + m.inputs[inputIMAPPort].SetValue(strconv.Itoa(imapPort)) + } + m.inputs[inputSMTPServer].SetValue(smtpServer) + if smtpPort != 0 { + m.inputs[inputSMTPPort].SetValue(strconv.Itoa(smtpPort)) + } + } +} + +// GetAccountID returns the account ID being edited (if in edit mode). +func (m *Login) GetAccountID() string { + return m.accountID +} + +// IsEditMode returns whether the form is in edit mode. +func (m *Login) IsEditMode() bool { + return m.isEditMode } diff --git a/tui/messages.go b/tui/messages.go index 92a50d6813b3bc85c2de58dd06e61c04efd05e46..a659a572a38e00e3d72af5ab89fe7cd351655be6 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -1,9 +1,14 @@ package tui -import "github.com/floatpane/matcha/fetcher" +import ( + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/fetcher" +) type ViewEmailMsg struct { - Index int + Index int + UID uint32 + AccountID string } type SendEmailMsg struct { @@ -13,13 +18,18 @@ type SendEmailMsg struct { AttachmentPath string InReplyTo string References []string + AccountID string // ID of the account to send from } type Credentials struct { - Provider string - Name string - Email string - Password string + Provider string + Name string + Email string + Password string + IMAPServer string + IMAPPort int + SMTPServer string + SMTPPort int } type ChooseServiceMsg struct { @@ -33,7 +43,8 @@ type EmailResultMsg struct { type ClearStatusMsg struct{} type EmailsFetchedMsg struct { - Emails []fetcher.Email + Emails []fetcher.Email + AccountID string } type FetchErr error @@ -49,13 +60,15 @@ type GoToSendMsg struct { type GoToSettingsMsg struct{} type FetchMoreEmailsMsg struct { - Offset uint32 + Offset uint32 + AccountID string } type FetchingMoreEmailsMsg struct{} type EmailsAppendedMsg struct { - Emails []fetcher.Email + Emails []fetcher.Email + AccountID string } type ReplyToEmailMsg struct { @@ -73,25 +86,29 @@ type FileSelectedMsg struct { type CancelFilePickerMsg struct{} type DeleteEmailMsg struct { - UID uint32 + UID uint32 + AccountID string } type ArchiveEmailMsg struct { - UID uint32 + UID uint32 + AccountID string } type EmailActionDoneMsg struct { - UID uint32 - Err error + UID uint32 + AccountID string + Err error } type GoToChoiceMenuMsg struct{} type DownloadAttachmentMsg struct { - Index int - Filename string - PartID string - Data []byte + Index int + Filename string + PartID string + Data []byte + AccountID string } type AttachmentDownloadedMsg struct { @@ -110,12 +127,110 @@ type DiscardDraftMsg struct { ComposerState *Composer } -// RestoreDraftMsg signals that the cached draft should be restored. -type RestoreDraftMsg struct{} - type EmailBodyFetchedMsg struct { - Index int + UID uint32 Body string Attachments []fetcher.Attachment Err error + AccountID string +} + +// --- Multi-Account Messages --- + +// GoToAddAccountMsg signals navigation to the add account screen. +type GoToAddAccountMsg struct{} + +// AddAccountMsg signals that a new account should be added. +type AddAccountMsg struct { + Credentials Credentials +} + +// AccountAddedMsg signals that an account was successfully added. +type AccountAddedMsg struct { + AccountID string + Err error +} + +// DeleteAccountMsg signals that an account should be deleted. +type DeleteAccountMsg struct { + AccountID string +} + +// AccountDeletedMsg signals that an account was successfully deleted. +type AccountDeletedMsg struct { + AccountID string + Err error +} + +// SwitchAccountMsg signals switching to view a specific account's inbox. +type SwitchAccountMsg struct { + AccountID string // Empty string means "ALL" accounts +} + +// AllEmailsFetchedMsg signals that emails from all accounts have been fetched. +type AllEmailsFetchedMsg struct { + EmailsByAccount map[string][]fetcher.Email +} + +// SwitchFromAccountMsg signals changing the "From" account in composer. +type SwitchFromAccountMsg struct { + AccountID string +} + +// 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 } diff --git a/tui/settings.go b/tui/settings.go new file mode 100644 index 0000000000000000000000000000000000000000..aef5ccac54b1aec30db0e4492e84ed73b5ff0b7c --- /dev/null +++ b/tui/settings.go @@ -0,0 +1,160 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/floatpane/matcha/config" +) + +var ( + accountItemStyle = lipgloss.NewStyle().PaddingLeft(2) + selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42")) + accountEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) +) + +// Settings displays the account management screen. +type Settings struct { + accounts []config.Account + cursor int + confirmingDelete bool + width int + height int +} + +// NewSettings creates a new settings model. +func NewSettings(accounts []config.Account) *Settings { + return &Settings{ + accounts: accounts, + cursor: 0, + } +} + +// Init initializes the settings model. +func (m *Settings) Init() tea.Cmd { + return nil +} + +// Update handles messages for the settings model. +func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + if m.confirmingDelete { + switch msg.String() { + case "y", "Y": + if m.cursor < len(m.accounts) { + accountID := m.accounts[m.cursor].ID + m.confirmingDelete = false + return m, func() tea.Msg { + return DeleteAccountMsg{AccountID: accountID} + } + } + case "n", "N", "esc": + m.confirmingDelete = false + return m, nil + } + return m, nil + } + + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + // +1 for "Add Account" option + if m.cursor < len(m.accounts) { + m.cursor++ + } + case "d": + // Delete selected account (not the "Add Account" option) + if m.cursor < len(m.accounts) && len(m.accounts) > 0 { + m.confirmingDelete = true + } + case "enter": + // If cursor is on "Add Account" + if m.cursor == len(m.accounts) { + return m, func() tea.Msg { return GoToAddAccountMsg{} } + } + case "esc": + return m, func() tea.Msg { return GoToChoiceMenuMsg{} } + } + } + return m, nil +} + +// View renders the settings screen. +func (m *Settings) View() string { + var b strings.Builder + + b.WriteString(titleStyle.Render("Account Settings") + "\n\n") + b.WriteString(listHeader.Render("Your email accounts:")) + b.WriteString("\n\n") + + if len(m.accounts) == 0 { + b.WriteString(accountEmailStyle.Render(" No accounts configured.\n")) + b.WriteString("\n") + } + + for i, account := range m.accounts { + displayName := account.Email + if account.Name != "" { + displayName = fmt.Sprintf("%s (%s)", account.Name, account.Email) + } + + providerInfo := account.ServiceProvider + if account.ServiceProvider == "custom" { + providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer) + } + + line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo)) + + if m.cursor == i { + b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line))) + } else { + b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line))) + } + b.WriteString("\n") + } + + // Add Account option + addAccountText := "Add New Account" + if m.cursor == len(m.accounts) { + b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText))) + } else { + b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addAccountText))) + } + b.WriteString("\n\n") + + b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select • d: delete account • esc: back")) + + if m.confirmingDelete { + accountName := m.accounts[m.cursor].Email + dialog := DialogBoxStyle.Render( + lipgloss.JoinVertical(lipgloss.Center, + dangerStyle.Render("Delete account?"), + accountEmailStyle.Render(accountName), + HelpStyle.Render("\n(y/n)"), + ), + ) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) + } + + return docStyle.Render(b.String()) +} + +// UpdateAccounts updates the list of accounts. +func (m *Settings) UpdateAccounts(accounts []config.Account) { + m.accounts = accounts + if m.cursor >= len(accounts) { + m.cursor = len(accounts) + } +} diff --git a/tui/styles.go b/tui/styles.go index 530c499823cd86d2de4535e1be4cf763fc376a8d..6a82ceeed48429cf3df4c03a25f04042a74e0f3e 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -10,7 +10,7 @@ import ( // ASCII logo for Matcha displayed during loading screens const asciiLogo = ` - __ __ + __ __ ____ ___ ____ _/ /______/ /_ ____ _ / __ '__ \/ __ '/ __/ ___/ __ \/ __ '/ / / / / / / /_/ / /_/ /__/ / / / /_/ /