Detailed changes
@@ -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
}
@@ -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())
+ }
+}
@@ -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)
}
@@ -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)
}
@@ -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}
}
@@ -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())
}
@@ -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 ""
+}
@@ -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 <test@example.com>"
+ 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)
+ }
}
@@ -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
+}
@@ -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()
}
@@ -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")
+ }
+}
@@ -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
}
@@ -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{}
@@ -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)
+ }
+}