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/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..9cd77ab7a0c07dcddd2919fa598ca27105b88065 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "time" tea "github.com/charmbracelet/bubbletea" @@ -29,9 +30,10 @@ const ( type mainModel struct { current tea.Model previousModel tea.Model - cachedComposer *tui.Composer // To cache a discarded draft + cachedComposer *tui.Composer config *config.Config emails []fetcher.Email + emailsByAcct map[string][]fetcher.Email inbox *tui.Inbox width int height int @@ -39,10 +41,12 @@ type mainModel struct { } 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) @@ -92,70 +96,206 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.DiscardDraftMsg: m.cachedComposer = msg.ComposerState - m.current = tui.NewChoice(true) // Now there is a cached draft + m.current = tui.NewChoice(true) 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 + m.cachedComposer = nil 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 m.config == nil { + m.config = &config.Config{} } - if err := config.SaveConfig(cfg); err != nil { + + // 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) 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() + } + m.current = tui.NewStatus("Fetching emails from all accounts...") + return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config)) + + 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 + 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.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(m.cachedComposer != nil) + 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 +303,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 +323,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,9 +354,19 @@ 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 + m.cachedComposer = nil 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() + } + + return m, tea.Batch(m.current.Init(), sendEmail(account, msg)) case tui.EmailResultMsg: m.current = tui.NewChoice(m.cachedComposer != nil) @@ -206,12 +375,26 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 +402,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 +416,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 +454,135 @@ 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 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 +596,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 +634,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 +643,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/matcha b/matcha new file mode 100755 index 0000000000000000000000000000000000000000..8ccb5c7ad06a5c750287ffd3eb3e8c39f1f43715 Binary files /dev/null and b/matcha differ 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/composer.go b/tui/composer.go index 85278301606bbb5bbf23afc42e51a7f5c17dfa90..f2d7c96568535e0c2deced8a36a394ea9b77325e 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/floatpane/matcha/config" ) // Styles for the UI @@ -20,7 +21,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 +41,24 @@ 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 } // NewComposer initializes a new composer model. func NewComposer(from, to, subject, body string) *Composer { - m := &Composer{fromAddr: from} + m := &Composer{} 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 +77,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 +109,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 +149,25 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + // 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 +194,11 @@ 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 + if m.focusIndex > maxFocus { + m.focusIndex = focusFrom + } else if m.focusIndex < focusFrom { + m.focusIndex = maxFocus } m.toInput.Blur() @@ -134,27 +206,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 +245,13 @@ 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: + 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,19 +263,32 @@ 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 (press Enter to change)", fromAddr)) + } else { + fromField = blurredStyle.Render(fmt.Sprintf(" From: %s", fromAddr)) + } + } else { + fromField = "From: " + emailRecipientStyle.Render(fromAddr) + } + 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)) @@ -200,7 +296,7 @@ func (m *Composer) View() string { composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left, "Compose New Email", - "From: "+emailRecipientStyle.Render(m.fromAddr), + fromField, m.toInput.View(), m.subjectInput.View(), m.bodyInput.View(), @@ -209,6 +305,29 @@ func (m *Composer) View() string { helpStyle.Render("Markdown/HTML • tab: next field • esc: back to menu"), )) + // 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 +340,29 @@ 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 "" +} diff --git a/tui/composer_test.go b/tui/composer_test.go index 7f07cab353fc07f131b0621c83473d50aaf4a5ab..f768054cedfe4bff36cb75e39e3301c5c2db020a 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -1,66 +1,73 @@ 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 to the 'From' field. 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 != focusFrom { + t.Errorf("After five Tabs, focusIndex should wrap to %d (focusFrom), got %d", focusFrom, 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 +83,137 @@ 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") + } + + // 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"}, } - if !reflect.DeepEqual(sendMsg, expectedMsg) { - t.Errorf("Mismatched SendEmailMsg.\nGot: %+v\nWant: %+v", sendMsg, expectedMsg) + 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") + } + }) +} + +// 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/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..4156cf73f0b54bb68c9601f17b79b1985ba6c0f3 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,121 @@ 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 + 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 +183,42 @@ 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 { + if m.currentAccountID == "" { + return "Inbox - All Accounts" + } + for _, acc := range m.accounts { + if acc.ID == m.currentAccountID { + if acc.Name != "" { + return fmt.Sprintf("Inbox - %s", acc.Name) + } + return fmt.Sprintf("Inbox - %s", acc.Email) + } } + return "Inbox" } func (m *Inbox) Init() tea.Cmd { @@ -104,29 +234,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 +292,30 @@ 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) } - 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.emailCountByAcct[msg.AccountID] = len(m.emailsByAccount[msg.AccountID]) + + 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 +325,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..a327a1176a8643192c7194d9d62e958fa4411e9a 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -3,7 +3,9 @@ package tui import "github.com/floatpane/matcha/fetcher" type ViewEmailMsg struct { - Index int + Index int + UID uint32 + AccountID string } type SendEmailMsg struct { @@ -13,13 +15,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 +40,8 @@ type EmailResultMsg struct { type ClearStatusMsg struct{} type EmailsFetchedMsg struct { - Emails []fetcher.Email + Emails []fetcher.Email + AccountID string } type FetchErr error @@ -49,13 +57,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 +83,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 { @@ -114,8 +128,54 @@ type DiscardDraftMsg struct { 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{} 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) + } +}