feat: multiple account, custom smtp & imap

drew created

Change summary

config/config.go        | 170 +++++++++++++++-
config/config_test.go   | 208 +++++++++++++++++++-
fetcher/fetcher.go      |  57 ++---
fetcher/fetcher_test.go |  60 +++++
main.go                 | 430 ++++++++++++++++++++++++++++++++++++------
matcha                  |   0 
sender/sender.go        |  31 +-
tui/composer.go         | 185 ++++++++++++++++--
tui/composer_test.go    | 180 +++++++++++++++--
tui/email_view.go       |  36 +++
tui/inbox.go            | 330 +++++++++++++++++++++++++++++---
tui/inbox_test.go       | 212 ++++++++++++++++++++
tui/login.go            | 211 +++++++++++++++++---
tui/messages.go         |  94 +++++++-
tui/settings.go         | 160 ++++++++++++++++
15 files changed, 2,072 insertions(+), 292 deletions(-)

Detailed changes

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
 }

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())
+	}
+}

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)
 }

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)
 }

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}
 		}

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())
 }

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 ""
+}

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 <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)
+	}
 }

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
+}

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()
 }

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")
+	}
+}

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
 }

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{}

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)
+	}
+}