chore: refactor settings.go and improve UI (#683)

Drew Smirnoff created

Change summary

main.go                    |    3 
tui/login.go               |   22 
tui/messages.go            |    2 
tui/settings.go            | 1118 ++++++---------------------------------
tui/settings_accounts.go   |  181 ++++++
tui/settings_crypto.go     |  179 ++++++
tui/settings_encryption.go |  149 +++++
tui/settings_general.go    |  119 ++++
tui/settings_lists.go      |  112 ++++
tui/settings_theme.go      |  131 ++++
tui/theme.go               |    4 
tui/trash_archive.go       |  154 -----
tui/trash_archive_test.go  |  340 ------------
13 files changed, 1,070 insertions(+), 1,444 deletions(-)

Detailed changes

main.go 🔗

@@ -278,6 +278,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				SendAsEmail:     msg.SendAsEmail,
 				AuthMethod:      msg.AuthMethod,
 				Protocol:        msg.Protocol,
+				Insecure:        msg.Insecure,
 				JMAPEndpoint:    msg.JMAPEndpoint,
 				POP3Server:      msg.POP3Server,
 				POP3Port:        msg.POP3Port,
@@ -879,7 +880,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			hideTips = m.config.HideTips
 		}
 		login := tui.NewLogin(hideTips)
-		login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port)
+		login.SetEditMode(msg.AccountID, msg.Protocol, msg.Provider, msg.Name, msg.Email, msg.FetchEmail, msg.SendAsEmail, msg.IMAPServer, msg.IMAPPort, msg.SMTPServer, msg.SMTPPort, msg.Insecure, msg.JMAPEndpoint, msg.POP3Server, msg.POP3Port)
 		m.current = login
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()

tui/login.go 🔗

@@ -34,6 +34,7 @@ const (
 	inputIMAPPort
 	inputSMTPServer
 	inputSMTPPort
+	inputInsecure
 	inputJMAPEndpoint // JMAP session URL
 	inputPOP3Server
 	inputPOP3Port
@@ -93,6 +94,9 @@ func NewLogin(hideTips bool) *Login {
 		case inputSMTPPort:
 			t.Placeholder = "SMTP Port (default: 587)"
 			t.Prompt = "🔢 > "
+		case inputInsecure:
+			t.Placeholder = "Insecure (true/false) - Skip TLS verification"
+			t.Prompt = "🔓 > "
 		case inputJMAPEndpoint:
 			t.Placeholder = "JMAP Session URL (e.g., https://api.fastmail.com/jmap/session)"
 			t.Prompt = "🔗 > "
@@ -139,7 +143,7 @@ func (m *Login) visibleFields() []int {
 	case "pop3":
 		// POP3: custom server fields + SMTP for sending
 		fields = append(fields, inputName, inputEmail, inputFetchEmail, inputSendAsEmail, inputPassword,
-			inputPOP3Server, inputPOP3Port, inputSMTPServer, inputSMTPPort)
+			inputPOP3Server, inputPOP3Port, inputSMTPServer, inputSMTPPort, inputInsecure)
 	default:
 		// IMAP (default): existing flow
 		fields = append(fields, inputProvider, inputName, inputEmail, inputFetchEmail, inputSendAsEmail)
@@ -150,7 +154,7 @@ func (m *Login) visibleFields() []int {
 			fields = append(fields, inputPassword)
 		}
 		if m.showCustom {
-			fields = append(fields, inputIMAPServer, inputIMAPPort, inputSMTPServer, inputSMTPPort)
+			fields = append(fields, inputIMAPServer, inputIMAPPort, inputSMTPServer, inputSMTPPort, inputInsecure)
 		}
 	}
 
@@ -268,6 +272,8 @@ func (m *Login) submitForm() func() tea.Msg {
 
 	proto := m.protocol()
 
+	insecure := m.inputs[inputInsecure].Value() == "true"
+
 	return func() tea.Msg {
 		return Credentials{
 			Protocol:     proto,
@@ -281,6 +287,7 @@ func (m *Login) submitForm() func() tea.Msg {
 			IMAPPort:     imapPort,
 			SMTPServer:   m.inputs[inputSMTPServer].Value(),
 			SMTPPort:     smtpPort,
+			Insecure:     insecure,
 			AuthMethod:   authMethod,
 			JMAPEndpoint: m.inputs[inputJMAPEndpoint].Value(),
 			POP3Server:   m.inputs[inputPOP3Server].Value(),
@@ -324,6 +331,8 @@ func (m *Login) View() tea.View {
 		tip = "The server address for sending emails."
 	case inputSMTPPort:
 		tip = "The port for the SMTP server (usually 587 for TLS)."
+	case inputInsecure:
+		tip = "Type 'true' to disable TLS certificate verification (not recommended)."
 	case inputJMAPEndpoint:
 		tip = "The JMAP session resource URL (e.g., https://api.fastmail.com/jmap/session)."
 	case inputPOP3Server:
@@ -366,6 +375,7 @@ func (m *Login) View() tea.View {
 			listHeader.Render("SMTP Settings (for sending):"),
 			m.inputs[inputSMTPServer].View(),
 			m.inputs[inputSMTPPort].View(),
+			m.inputs[inputInsecure].View(),
 		)
 	default:
 		// IMAP flow
@@ -398,6 +408,7 @@ func (m *Login) View() tea.View {
 				m.inputs[inputIMAPPort].View(),
 				m.inputs[inputSMTPServer].View(),
 				m.inputs[inputSMTPPort].View(),
+				m.inputs[inputInsecure].View(),
 			)
 		}
 	}
@@ -412,7 +423,7 @@ func (m *Login) View() tea.View {
 }
 
 // SetEditMode sets the login form to edit an existing account.
-func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, sendAsEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, jmapEndpoint, pop3Server string, pop3Port int) {
+func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEmail, sendAsEmail, imapServer string, imapPort int, smtpServer string, smtpPort int, insecure bool, jmapEndpoint, pop3Server string, pop3Port int) {
 	m.isEditMode = true
 	m.accountID = accountID
 
@@ -429,6 +440,11 @@ func (m *Login) SetEditMode(accountID, protocol, provider, name, email, fetchEma
 
 	if m.showCustom {
 		m.inputs[inputIMAPServer].SetValue(imapServer)
+		if insecure {
+			m.inputs[inputInsecure].SetValue("true")
+		} else {
+			m.inputs[inputInsecure].SetValue("false")
+		}
 		if imapPort != 0 {
 			m.inputs[inputIMAPPort].SetValue(strconv.Itoa(imapPort))
 		}

tui/messages.go 🔗

@@ -51,6 +51,7 @@ type Credentials struct {
 	IMAPPort     int
 	SMTPServer   string
 	SMTPPort     int
+	Insecure     bool
 	AuthMethod   string // "password" or "oauth2"
 	Protocol     string // "imap" (default), "jmap", or "pop3"
 	JMAPEndpoint string // JMAP session URL
@@ -245,6 +246,7 @@ type GoToEditAccountMsg struct {
 	IMAPPort     int
 	SMTPServer   string
 	SMTPPort     int
+	Insecure     bool
 	Protocol     string
 	JMAPEndpoint string
 	POP3Server   string

tui/settings.go 🔗

@@ -1,7 +1,6 @@
 package tui
 
 import (
-	"fmt"
 	"strings"
 
 	"charm.land/bubbles/v2/textinput"
@@ -13,41 +12,55 @@ import (
 
 var (
 	accountItemStyle         = lipgloss.NewStyle().PaddingLeft(2)
-	selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42"))
+	selectedAccountItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42")).Bold(true)
 	accountEmailStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 	dangerStyle              = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
 
-	settingsFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+	settingsFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 	settingsBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 )
 
-type SettingsState int
+type SettingsPane int
 
 const (
-	SettingsMain SettingsState = iota
-	SettingsAccounts
-	SettingsMailingLists
-	SettingsSMIMEConfig
-	SettingsTheme
-	SettingsEncryption
+	PaneMenu SettingsPane = iota
+	PaneContent
+)
+
+type SettingsCategory int
+
+const (
+	CategoryGeneral SettingsCategory = iota
+	CategoryAccounts
+	CategoryTheme
+	CategoryMailingLists
+	CategoryEncryption
 )
 
-// Settings displays the settings screen.
 type Settings struct {
-	cfg              *config.Config
-	state            SettingsState
-	cursor           int
+	cfg    *config.Config
+	width  int
+	height int
+
+	activePane     SettingsPane
+	activeCategory SettingsCategory
+
+	// Menu state
+	menuCursor int
+
+	// Sub-components states
+	generalCursor    int
+	accountsCursor   int
+	themeCursor      int
+	listsCursor      int
 	confirmingDelete bool
-	width            int
-	height           int
 
 	// S/MIME Config fields
-	editingAccountIdx int
-	focusIndex        int
-	smimeCertInput    textinput.Model
-	smimeKeyInput     textinput.Model
-
-	// PGP Config fields
+	isCryptoConfig     bool
+	editingAccountIdx  int
+	cryptoFocusIndex   int
+	smimeCertInput     textinput.Model
+	smimeKeyInput      textinput.Model
 	pgpPublicKeyInput  textinput.Model
 	pgpPrivateKeyInput textinput.Model
 	pgpKeySource       string // "file" or "yubikey"
@@ -58,11 +71,10 @@ type Settings struct {
 	encConfirmInput   textinput.Model
 	encFocusIndex     int
 	encError          string
-	encEnabling       bool // true when enabling encryption in progress
-	confirmingDisable bool // true when confirming disable
+	encEnabling       bool
+	confirmingDisable bool
 }
 
-// NewSettings creates a new settings model.
 func NewSettings(cfg *config.Config) *Settings {
 	if cfg == nil {
 		cfg = &config.Config{}
@@ -70,75 +82,38 @@ func NewSettings(cfg *config.Config) *Settings {
 
 	tiStyles := ThemedTextInputStyles()
 
-	certInput := textinput.New()
-	certInput.Placeholder = "/path/to/cert.pem"
-	certInput.Prompt = "> "
-	certInput.CharLimit = 256
-	certInput.SetStyles(tiStyles)
-
-	keyInput := textinput.New()
-	keyInput.Placeholder = "/path/to/private_key.pem"
-	keyInput.Prompt = "> "
-	keyInput.CharLimit = 256
-	keyInput.SetStyles(tiStyles)
-
-	pgpPubInput := textinput.New()
-	pgpPubInput.Placeholder = "/path/to/public_key.asc"
-	pgpPubInput.Prompt = "> "
-	pgpPubInput.CharLimit = 256
-	pgpPubInput.SetStyles(tiStyles)
-
-	pgpPrivInput := textinput.New()
-	pgpPrivInput.Placeholder = "/path/to/private_key.asc"
-	pgpPrivInput.Prompt = "> "
-	pgpPrivInput.CharLimit = 256
-	pgpPrivInput.SetStyles(tiStyles)
-
-	pgpPINInput := textinput.New()
-	pgpPINInput.Placeholder = "YubiKey PIN (6-8 digits)"
-	pgpPINInput.Prompt = "> "
-	pgpPINInput.CharLimit = 16
-	pgpPINInput.EchoMode = textinput.EchoPassword
-	pgpPINInput.EchoCharacter = '*'
-	pgpPINInput.SetStyles(tiStyles)
-
-	encPassInput := textinput.New()
-	encPassInput.Placeholder = "Password"
-	encPassInput.Prompt = "> "
-	encPassInput.CharLimit = 256
-	encPassInput.EchoMode = textinput.EchoPassword
-	encPassInput.EchoCharacter = '*'
-	encPassInput.SetStyles(tiStyles)
-
-	encConfInput := textinput.New()
-	encConfInput.Placeholder = "Confirm Password"
-	encConfInput.Prompt = "> "
-	encConfInput.CharLimit = 256
-	encConfInput.EchoMode = textinput.EchoPassword
-	encConfInput.EchoCharacter = '*'
-	encConfInput.SetStyles(tiStyles)
+	newInput := func(placeholder, prompt string, isPassword bool) textinput.Model {
+		t := textinput.New()
+		t.Placeholder = placeholder
+		t.Prompt = prompt
+		t.CharLimit = 256
+		t.SetStyles(tiStyles)
+		if isPassword {
+			t.EchoMode = textinput.EchoPassword
+			t.EchoCharacter = '*'
+		}
+		return t
+	}
 
 	return &Settings{
 		cfg:                cfg,
-		state:              SettingsMain,
-		cursor:             0,
-		smimeCertInput:     certInput,
-		smimeKeyInput:      keyInput,
-		pgpPublicKeyInput:  pgpPubInput,
-		pgpPrivateKeyInput: pgpPrivInput,
-		pgpKeySource:       "file", // Default to file-based keys
-		pgpPINInput:        pgpPINInput,
-		encPasswordInput:   encPassInput,
-		encConfirmInput:    encConfInput,
+		activePane:         PaneMenu,
+		activeCategory:     CategoryGeneral,
+		smimeCertInput:     newInput("/path/to/cert.pem", "> ", false),
+		smimeKeyInput:      newInput("/path/to/private_key.pem", "> ", false),
+		pgpPublicKeyInput:  newInput("/path/to/public_key.asc", "> ", false),
+		pgpPrivateKeyInput: newInput("/path/to/private_key.asc", "> ", false),
+		pgpPINInput:        newInput("YubiKey PIN (6-8 digits)", "> ", true),
+		pgpKeySource:       "file",
+		encPasswordInput:   newInput("Password", "> ", true),
+		encConfirmInput:    newInput("Confirm Password", "> ", true),
 	}
 }
 
-// Init initializes the settings model.
 func (m *Settings) Init() tea.Cmd {
 	return textinput.Blink
 }
 
-// Update handles messages for the settings model.
 func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
@@ -147,29 +122,43 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
 		m.height = msg.Height
-		m.smimeCertInput.SetWidth(m.width - 6)
-		m.smimeKeyInput.SetWidth(m.width - 6)
-		m.pgpPublicKeyInput.SetWidth(m.width - 6)
-		m.pgpPrivateKeyInput.SetWidth(m.width - 6)
-		m.pgpPINInput.SetWidth(m.width - 6)
+		inputWidth := (m.width - 30) - 6 // left pane is 30
+		if inputWidth < 20 {
+			inputWidth = 20
+		}
+		m.smimeCertInput.SetWidth(inputWidth)
+		m.smimeKeyInput.SetWidth(inputWidth)
+		m.pgpPublicKeyInput.SetWidth(inputWidth)
+		m.pgpPrivateKeyInput.SetWidth(inputWidth)
+		m.pgpPINInput.SetWidth(inputWidth)
 		return m, nil
 
 	case tea.KeyPressMsg:
-		if m.state == SettingsMain {
-			return m.updateMain(msg)
-		} else if m.state == SettingsTheme {
-			return m.updateTheme(msg)
-		} else if m.state == SettingsMailingLists {
-			return m.updateMailingLists(msg)
-		} else if m.state == SettingsSMIMEConfig {
-			var m2 *Settings
-			m2, cmd = m.updateSMIMEConfig(msg)
-			cmds = append(cmds, cmd)
-			return m2, tea.Batch(cmds...)
-		} else if m.state == SettingsEncryption {
-			return m.updateEncryption(msg)
+		// Global shortcut to return to menu from content pane
+		if m.activePane == PaneContent && msg.String() == "esc" {
+			// unless we are in crypto config or encryption editing which have their own esc logic
+			if !(m.activeCategory == CategoryAccounts && m.isCryptoConfig) &&
+				!(m.activeCategory == CategoryEncryption && m.encFocusIndex > -1) {
+				m.activePane = PaneMenu
+				return m, nil
+			}
+		}
+
+		if m.activePane == PaneMenu {
+			return m.updateMenu(msg)
 		} else {
-			return m.updateAccounts(msg)
+			switch m.activeCategory {
+			case CategoryGeneral:
+				return m.updateGeneral(msg)
+			case CategoryAccounts:
+				return m.updateAccounts(msg)
+			case CategoryTheme:
+				return m.updateTheme(msg)
+			case CategoryMailingLists:
+				return m.updateMailingLists(msg)
+			case CategoryEncryption:
+				return m.updateEncryption(msg)
+			}
 		}
 
 	case SecureModeEnabledMsg:
@@ -178,8 +167,7 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.encError = msg.Err.Error()
 			return m, nil
 		}
-		m.state = SettingsMain
-		m.cursor = 7
+		m.activePane = PaneMenu
 		return m, nil
 
 	case SecureModeDisabledMsg:
@@ -188,83 +176,60 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, nil
 		}
 		m.confirmingDisable = false
-		m.state = SettingsMain
-		m.cursor = 7
+		m.activePane = PaneMenu
 		return m, nil
 	}
 
-	if m.state == SettingsEncryption {
-		m.encPasswordInput, cmd = m.encPasswordInput.Update(msg)
-		cmds = append(cmds, cmd)
-		m.encConfirmInput, cmd = m.encConfirmInput.Update(msg)
-		cmds = append(cmds, cmd)
-	}
-
-	if m.state == SettingsSMIMEConfig {
-		m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
-		cmds = append(cmds, cmd)
-		m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
-		cmds = append(cmds, cmd)
-		m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
-		cmds = append(cmds, cmd)
-		m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
-		cmds = append(cmds, cmd)
-		m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
-		cmds = append(cmds, cmd)
+	// Update text inputs if active
+	if m.activePane == PaneContent {
+		if m.activeCategory == CategoryEncryption {
+			m.encPasswordInput, cmd = m.encPasswordInput.Update(msg)
+			cmds = append(cmds, cmd)
+			m.encConfirmInput, cmd = m.encConfirmInput.Update(msg)
+			cmds = append(cmds, cmd)
+		} else if m.activeCategory == CategoryAccounts && m.isCryptoConfig {
+			m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
+			cmds = append(cmds, cmd)
+			m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
+			cmds = append(cmds, cmd)
+			m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
+			cmds = append(cmds, cmd)
+			m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
+			cmds = append(cmds, cmd)
+			m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
+			cmds = append(cmds, cmd)
+		}
 	}
 
 	return m, tea.Batch(cmds...)
 }
 
-func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+func (m *Settings) updateMenu(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 	switch msg.String() {
 	case "up", "k":
-		if m.cursor > 0 {
-			m.cursor--
+		if m.menuCursor > 0 {
+			m.menuCursor--
 		}
 	case "down", "j":
-		// Options: 0: Email Accounts, 1: Theme, 2: Image Display, 3: Edit Signature, 4: Contextual Tips, 5: Desktop Notifications, 6: Mailing Lists, 7: Encryption
-		if m.cursor < 7 {
-			m.cursor++
+		if m.menuCursor < 4 {
+			m.menuCursor++
 		}
-	case "enter":
-		switch m.cursor {
-		case 0: // Email Accounts
-			m.state = SettingsAccounts
-			m.cursor = 0
-			return m, nil
-		case 1: // Theme
-			m.state = SettingsTheme
-			// Position cursor on the currently active theme
+	case "right", "l", "enter":
+		m.activeCategory = SettingsCategory(m.menuCursor)
+		m.activePane = PaneContent
+
+		// Reset states
+		m.confirmingDelete = false
+		if m.activeCategory == CategoryTheme {
+			// Find current theme index
 			themes := theme.AllThemes()
-			m.cursor = 0
 			for i, t := range themes {
 				if t.Name == theme.ActiveTheme.Name {
-					m.cursor = i
+					m.themeCursor = i
 					break
 				}
 			}
-			return m, nil
-		case 2: // Image Display
-			m.cfg.DisableImages = !m.cfg.DisableImages
-			_ = config.SaveConfig(m.cfg)
-			return m, nil
-		case 3: // Edit Signature
-			return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
-		case 4: // Contextual Tips
-			m.cfg.HideTips = !m.cfg.HideTips
-			_ = config.SaveConfig(m.cfg)
-			return m, nil
-		case 5: // Desktop Notifications
-			m.cfg.DisableNotifications = !m.cfg.DisableNotifications
-			_ = config.SaveConfig(m.cfg)
-			return m, nil
-		case 6: // Mailing Lists
-			m.state = SettingsMailingLists
-			m.cursor = 0
-			return m, nil
-		case 7: // Encryption
-			m.state = SettingsEncryption
+		} else if m.activeCategory == CategoryEncryption {
 			m.encError = ""
 			m.encPasswordInput.SetValue("")
 			m.encConfirmInput.SetValue("")
@@ -274,809 +239,78 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
 			if !config.IsSecureModeEnabled() {
 				m.encPasswordInput.Focus()
 				m.encConfirmInput.Blur()
-				return m, textinput.Blink
-			}
-			return m, nil
-		}
-	case "esc":
-		return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
-	}
-	return m, nil
-}
-
-func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	if m.confirmingDelete {
-		switch msg.String() {
-		case "y", "Y":
-			if m.cursor < len(m.cfg.Accounts) {
-				accountID := m.cfg.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.cfg.Accounts) {
-			m.cursor++
-		}
-	case "d":
-		// Delete selected account (not the "Add Account" option)
-		if m.cursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 {
-			m.confirmingDelete = true
-		}
-	case "e":
-		// Edit selected account
-		if m.cursor < len(m.cfg.Accounts) {
-			acc := m.cfg.Accounts[m.cursor]
-			return m, func() tea.Msg {
-				return GoToEditAccountMsg{
-					AccountID:    acc.ID,
-					Provider:     acc.ServiceProvider,
-					Name:         acc.Name,
-					Email:        acc.Email,
-					FetchEmail:   acc.FetchEmail,
-					SendAsEmail:  acc.SendAsEmail,
-					IMAPServer:   acc.IMAPServer,
-					IMAPPort:     acc.IMAPPort,
-					SMTPServer:   acc.SMTPServer,
-					SMTPPort:     acc.SMTPPort,
-					Protocol:     acc.Protocol,
-					JMAPEndpoint: acc.JMAPEndpoint,
-					POP3Server:   acc.POP3Server,
-					POP3Port:     acc.POP3Port,
-				}
-			}
-		}
-	case "enter":
-		// If cursor is on "Add Account"
-		if m.cursor == len(m.cfg.Accounts) {
-			return m, func() tea.Msg { return GoToAddAccountMsg{} }
-		} else if m.cursor < len(m.cfg.Accounts) {
-			m.editingAccountIdx = m.cursor
-			m.state = SettingsSMIMEConfig
-			m.smimeCertInput.SetValue(m.cfg.Accounts[m.cursor].SMIMECert)
-			m.smimeKeyInput.SetValue(m.cfg.Accounts[m.cursor].SMIMEKey)
-			m.pgpPublicKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPublicKey)
-			m.pgpPrivateKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPrivateKey)
-			// Initialize PGP key source
-			if m.cfg.Accounts[m.cursor].PGPKeySource == "" {
-				m.pgpKeySource = "file"
-			} else {
-				m.pgpKeySource = m.cfg.Accounts[m.cursor].PGPKeySource
-			}
-			m.pgpPINInput.SetValue(m.cfg.Accounts[m.cursor].PGPPIN)
-			m.focusIndex = 0
-			m.smimeCertInput.Focus()
-			m.smimeKeyInput.Blur()
-			m.pgpPublicKeyInput.Blur()
-			m.pgpPrivateKeyInput.Blur()
-			m.pgpPINInput.Blur()
-			return m, textinput.Blink
-		}
+		return m, textinput.Blink
 	case "esc":
-		m.state = SettingsMain
-		m.cursor = 0
-		return m, nil
+		return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
 	}
+	m.activeCategory = SettingsCategory(m.menuCursor)
 	return m, nil
 }
 
-// Focus indices for the crypto config screen:
-// 0: S/MIME Certificate Path
-// 1: S/MIME Private Key Path
-// 2: S/MIME Sign By Default toggle
-// 3: PGP Public Key Path
-// 4: PGP Private Key Path
-// 5: PGP Key Source toggle (file/yubikey)
-// 6: PGP PIN input (only shown if yubikey)
-// 7: PGP Sign By Default toggle
-// 8: Save button
-// 9: Cancel button
-const cryptoConfigMaxFocus = 9
-
-func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) {
-	var cmds []tea.Cmd
-	var cmd tea.Cmd
-	key := msg.Key()
-	isEnter := key.Code == tea.KeyEnter || key.Code == tea.KeyReturn || key.Code == tea.KeyKpEnter
-	isSpace := key.Code == tea.KeySpace
-
-	setFocus := func(next int) tea.Cmd {
-		m.focusIndex = next
-
-		m.smimeCertInput.Blur()
-		m.smimeKeyInput.Blur()
-		m.pgpPublicKeyInput.Blur()
-		m.pgpPrivateKeyInput.Blur()
-		m.pgpPINInput.Blur()
-
-		switch m.focusIndex {
-		case 0:
-			return m.smimeCertInput.Focus()
-		case 1:
-			return m.smimeKeyInput.Focus()
-		case 3:
-			return m.pgpPublicKeyInput.Focus()
-		case 4:
-			return m.pgpPrivateKeyInput.Focus()
-		case 6:
-			return m.pgpPINInput.Focus()
-		default:
-			return nil
-		}
-	}
-
-	switch msg.String() {
-	case "esc":
-		m.state = SettingsAccounts
-		return m, nil
-	case "tab", "shift+tab", "up", "down":
-		if msg.String() == "shift+tab" || msg.String() == "up" {
-			m.focusIndex--
-			if m.focusIndex < 0 {
-				m.focusIndex = cryptoConfigMaxFocus
-			}
-		} else {
-			m.focusIndex++
-			if m.focusIndex > cryptoConfigMaxFocus {
-				m.focusIndex = 0
-			}
-		}
-
-		// Skip Yubikey PIN field when key source is "file"
-		if m.focusIndex == 6 && m.pgpKeySource != "yubikey" {
-			if msg.String() == "shift+tab" || msg.String() == "up" {
-				m.focusIndex = 5
-			} else {
-				m.focusIndex = 7
-			}
-		}
-
-		cmds = append(cmds, setFocus(m.focusIndex))
-		return m, tea.Batch(cmds...)
-	}
-
-	if isEnter {
-		switch m.focusIndex {
-		case 0: // S/MIME cert - enter advances to next field
-			cmds = append(cmds, setFocus(1))
-			return m, tea.Batch(cmds...)
-		case 1: // S/MIME key - enter advances
-			cmds = append(cmds, setFocus(2))
-			return m, tea.Batch(cmds...)
-		case 2: // S/MIME sign toggle - enter advances
-			cmds = append(cmds, setFocus(3))
-			return m, tea.Batch(cmds...)
-		case 3: // PGP public key - enter advances
-			cmds = append(cmds, setFocus(4))
-			return m, tea.Batch(cmds...)
-		case 4: // PGP private key - enter advances
-			cmds = append(cmds, setFocus(5))
-			return m, tea.Batch(cmds...)
-		case 5: // PGP key source - enter advances
-			nextFocus := 7
-			if m.pgpKeySource == "yubikey" {
-				nextFocus = 6
-			}
-			cmds = append(cmds, setFocus(nextFocus))
-			return m, tea.Batch(cmds...)
-		case 6: // PGP PIN input - enter advances
-			cmds = append(cmds, setFocus(7))
-			return m, tea.Batch(cmds...)
-		case 7: // PGP sign toggle - enter advances
-			cmds = append(cmds, setFocus(8))
-			return m, tea.Batch(cmds...)
-		case 8: // Save
-			m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value()
-			m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value()
-			m.cfg.Accounts[m.editingAccountIdx].PGPPublicKey = m.pgpPublicKeyInput.Value()
-			m.cfg.Accounts[m.editingAccountIdx].PGPPrivateKey = m.pgpPrivateKeyInput.Value()
-			m.cfg.Accounts[m.editingAccountIdx].PGPKeySource = m.pgpKeySource
-			m.cfg.Accounts[m.editingAccountIdx].PGPPIN = m.pgpPINInput.Value()
-			_ = config.SaveConfig(m.cfg)
-			m.state = SettingsAccounts
-			return m, nil
-		case 9: // Cancel
-			m.state = SettingsAccounts
-			return m, nil
-		}
-	}
-
-	if isSpace {
-		switch m.focusIndex {
-		case 2: // S/MIME sign toggle
-			m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault
-			return m, nil
-		case 5: // PGP key source toggle (file/yubikey)
-			if m.pgpKeySource == "file" {
-				m.pgpKeySource = "yubikey"
+func (m *Settings) View() tea.View {
+	// Left pane
+	var left strings.Builder
+	left.WriteString(titleStyle.Render("Settings") + "\n\n")
+
+	categories := []string{"General", "Accounts", "Theme", "Mailing Lists", "App Encryption"}
+	for i, c := range categories {
+		cursor := "  "
+		if m.menuCursor == i {
+			if m.activePane == PaneMenu {
+				cursor = "> "
 			} else {
-				m.pgpKeySource = "file"
-			}
-			return m, nil
-		case 7: // PGP sign toggle
-			m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault
-			return m, nil
-		}
-	}
-
-	switch m.focusIndex {
-	case 0:
-		m.smimeCertInput, cmd = m.smimeCertInput.Update(msg)
-		cmds = append(cmds, cmd)
-	case 1:
-		m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg)
-		cmds = append(cmds, cmd)
-	case 3:
-		m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg)
-		cmds = append(cmds, cmd)
-	case 4:
-		m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg)
-		cmds = append(cmds, cmd)
-	case 6:
-		m.pgpPINInput, cmd = m.pgpPINInput.Update(msg)
-		cmds = append(cmds, cmd)
-	}
-
-	return m, tea.Batch(cmds...)
-}
-
-func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	if m.confirmingDelete {
-		switch msg.String() {
-		case "y", "Y":
-			if m.cursor < len(m.cfg.MailingLists) {
-				m.cfg.MailingLists = append(m.cfg.MailingLists[:m.cursor], m.cfg.MailingLists[m.cursor+1:]...)
-				_ = config.SaveConfig(m.cfg)
-				if m.cursor >= len(m.cfg.MailingLists) && m.cursor > 0 {
-					m.cursor--
-				}
-				m.confirmingDelete = false
+				cursor = "• "
 			}
-		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":
-		if m.cursor < len(m.cfg.MailingLists) {
-			m.cursor++
-		}
-	case "d":
-		if m.cursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 {
-			m.confirmingDelete = true
-		}
-	case "e":
-		// Edit selected mailing list
-		if m.cursor < len(m.cfg.MailingLists) {
-			list := m.cfg.MailingLists[m.cursor]
-			idx := m.cursor
-			return m, func() tea.Msg {
-				return GoToEditMailingListMsg{
-					Index:     idx,
-					Name:      list.Name,
-					Addresses: strings.Join(list.Addresses, ", "),
-				}
-			}
-		}
-	case "enter":
-		if m.cursor == len(m.cfg.MailingLists) {
-			return m, func() tea.Msg { return GoToAddMailingListMsg{} }
+		style := accountItemStyle
+		if m.menuCursor == i {
+			style = selectedAccountItemStyle
 		}
-	case "esc":
-		m.state = SettingsMain
-		m.cursor = 0
-		return m, nil
-	}
-	return m, nil
-}
 
-// View renders the settings screen.
-func (m *Settings) View() tea.View {
-	if m.state == SettingsMain {
-		return tea.NewView(m.viewMain())
-	} else if m.state == SettingsTheme {
-		return tea.NewView(m.viewTheme())
-	} else if m.state == SettingsMailingLists {
-		return tea.NewView(m.viewMailingLists())
-	} else if m.state == SettingsSMIMEConfig {
-		return tea.NewView(m.viewSMIMEConfig())
-	} else if m.state == SettingsEncryption {
-		return tea.NewView(m.viewEncryption())
+		left.WriteString(style.Render(cursor+c) + "\n")
 	}
-	return tea.NewView(m.viewAccounts())
-}
 
-func (m *Settings) viewMain() string {
-	var b strings.Builder
-
-	b.WriteString(titleStyle.Render("Settings") + "\n\n")
-
-	// Option 0: Email Accounts
-	if m.cursor == 0 {
-		b.WriteString(selectedAccountItemStyle.Render("> Email Accounts"))
-	} else {
-		b.WriteString(accountItemStyle.Render("  Email Accounts"))
-	}
-	b.WriteString("\n")
+	leftPanel := lipgloss.NewStyle().
+		Width(30).
+		PaddingRight(2).
+		Border(lipgloss.NormalBorder(), false, true, false, false).
+		BorderForeground(theme.ActiveTheme.Secondary).
+		Render(left.String())
 
-	// Option 1: Theme
-	themeText := fmt.Sprintf("Theme: %s", theme.ActiveTheme.Name)
-	if m.cursor == 1 {
-		b.WriteString(selectedAccountItemStyle.Render("> " + themeText))
-	} else {
-		b.WriteString(accountItemStyle.Render("  " + themeText))
+	// Right pane
+	var right string
+	switch m.activeCategory {
+	case CategoryGeneral:
+		right = m.viewGeneral()
+	case CategoryAccounts:
+		right = m.viewAccounts()
+	case CategoryTheme:
+		right = m.viewTheme()
+	case CategoryMailingLists:
+		right = m.viewMailingLists()
+	case CategoryEncryption:
+		right = m.viewEncryption()
 	}
-	b.WriteString("\n")
 
-	// Option 2: Image Display
-	status := "ON"
-	if m.cfg.DisableImages {
-		status = "OFF"
-	}
-	text := fmt.Sprintf("Image Display: %s", status)
-	if m.cursor == 2 {
-		b.WriteString(selectedAccountItemStyle.Render("> " + text))
-	} else {
-		b.WriteString(accountItemStyle.Render("  " + text))
-	}
-	b.WriteString("\n")
+	rightPanel := lipgloss.NewStyle().
+		PaddingLeft(2).
+		Width(m.width - 34). // 30 (left) + 2 (border) + 2 (padding)
+		Render(right)
 
-	// Option 3: Edit Signature
-	sigText := "Edit Signature"
-	if config.HasSignature() {
-		sigText = "Edit Signature (configured)"
-	}
-	if m.cursor == 3 {
-		b.WriteString(selectedAccountItemStyle.Render("> " + sigText))
-	} else {
-		b.WriteString(accountItemStyle.Render("  " + sigText))
-	}
-	b.WriteString("\n")
+	content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel)
 
-	// Option 4: Contextual Tips
-	tipsStatus := "ON"
-	if m.cfg.HideTips {
-		tipsStatus = "OFF"
-	}
-	tipsText := fmt.Sprintf("Contextual Tips: %s", tipsStatus)
-	if m.cursor == 4 {
-		b.WriteString(selectedAccountItemStyle.Render("> " + tipsText))
-	} else {
-		b.WriteString(accountItemStyle.Render("  " + tipsText))
+	helpText := "esc: back to menu"
+	if m.activePane == PaneMenu {
+		helpText = "↑/↓: navigate • right/enter: select • esc: go back"
 	}
-	b.WriteString("\n")
-
-	// Option 5: Desktop Notifications
-	notifStatus := "ON"
-	if m.cfg.DisableNotifications {
-		notifStatus = "OFF"
-	}
-	notifText := fmt.Sprintf("Desktop Notifications: %s", notifStatus)
-	if m.cursor == 5 {
-		b.WriteString(selectedAccountItemStyle.Render("> " + notifText))
-	} else {
-		b.WriteString(accountItemStyle.Render("  " + notifText))
-	}
-	b.WriteString("\n")
-
-	// Option 6: Mailing Lists
-	mailingListsText := "Mailing Lists"
-	if m.cursor == 6 {
-		b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
-	} else {
-		b.WriteString(accountItemStyle.Render("  " + mailingListsText))
-	}
-	b.WriteString("\n")
-
-	// Option 7: Encryption
-	encStatus := "OFF"
-	if config.IsSecureModeEnabled() {
-		encStatus = "ON"
-	}
-	encText := fmt.Sprintf("Encryption: %s", encStatus)
-	if m.cursor == 7 {
-		b.WriteString(selectedAccountItemStyle.Render("> " + encText))
-	} else {
-		b.WriteString(accountItemStyle.Render("  " + encText))
-	}
-	b.WriteString("\n\n")
-
-	if !m.cfg.HideTips {
-		tip := ""
-		switch m.cursor {
-		case 0:
-			tip = "Manage your connected email accounts."
-		case 1:
-			tip = "Choose a color theme for the application."
-		case 2:
-			tip = "Toggle displaying images in emails."
-		case 3:
-			tip = "Configure the signature appended to your outgoing emails."
-		case 4:
-			tip = "Toggle displaying helpful contextual tips like this one."
-		case 5:
-			tip = "Toggle desktop notifications when new mail arrives."
-		case 6:
-			tip = "Manage groups of email addresses to quickly send to multiple people."
-		case 7:
-			tip = "Encrypt all data with a password. You'll need to enter it each time you open matcha."
-		}
-		if tip != "" {
-			b.WriteString(TipStyle.Render("Tip: "+tip) + "\n\n")
-		}
-	}
-
-	mainContent := b.String()
-	helpView := helpStyle.Render("↑/↓: navigate • enter: select/toggle • esc: back")
+	helpView := helpStyle.Render(helpText)
 
 	if m.height > 0 {
-		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
-		gap := m.height - currentHeight
-		if gap > 0 {
-			mainContent += strings.Repeat("\n", gap)
-		}
-	} else {
-		mainContent += "\n\n"
-	}
-
-	return docStyle.Render(mainContent + helpView)
-}
-
-func (m *Settings) viewAccounts() string {
-	var b strings.Builder
-
-	b.WriteString(titleStyle.Render("Account Settings"))
-	b.WriteString("\n\n")
-
-	if len(m.cfg.Accounts) == 0 {
-		b.WriteString(accountEmailStyle.Render("  No accounts configured.\n"))
-		b.WriteString("\n")
-	}
-
-	for i, account := range m.cfg.Accounts {
-		displayName := account.Email
-		if account.Name != "" {
-			displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail)
-		}
-
-		providerInfo := account.ServiceProvider
-		if account.ServiceProvider == "custom" {
-			providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer)
-		}
-
-		if account.SMIMECert != "" && account.SMIMEKey != "" {
-			providerInfo += " [S/MIME Configured]"
-		}
-		if account.PGPPublicKey != "" && account.PGPPrivateKey != "" {
-			providerInfo += " [PGP Configured]"
-		}
-
-		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.cfg.Accounts) {
-		b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addAccountText)))
-	} else {
-		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", addAccountText)))
-	}
-	b.WriteString("\n")
-
-	mainContent := b.String()
-	helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
-
-	if m.height > 0 {
-		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
-		gap := m.height - currentHeight
-		if gap > 0 {
-			mainContent += strings.Repeat("\n", gap)
-		}
-	} else {
-		mainContent += "\n\n"
-	}
-
-	if m.confirmingDelete {
-		accountName := m.cfg.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(mainContent + helpView)
-}
-
-func (m *Settings) viewSMIMEConfig() string {
-	var b strings.Builder
-
-	account := m.cfg.Accounts[m.editingAccountIdx]
-	b.WriteString(titleStyle.Render(fmt.Sprintf("Crypto Configuration for %s", account.FetchEmail)))
-	b.WriteString("\n\n")
-
-	// --- S/MIME Section ---
-	b.WriteString(settingsFocusedStyle.Render("S/MIME") + "\n")
-
-	if m.focusIndex == 0 {
-		b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n"))
-	} else {
-		b.WriteString(settingsBlurredStyle.Render("Certificate (PEM) Path:\n"))
-	}
-	b.WriteString(m.smimeCertInput.View() + "\n\n")
-
-	if m.focusIndex == 1 {
-		b.WriteString(settingsFocusedStyle.Render("Private Key (PEM) Path:\n"))
-	} else {
-		b.WriteString(settingsBlurredStyle.Render("Private Key (PEM) Path:\n"))
-	}
-	b.WriteString(m.smimeKeyInput.View() + "\n\n")
-
-	smimeSignStatus := "OFF"
-	if account.SMIMESignByDefault {
-		smimeSignStatus = "ON"
-	}
-	if m.focusIndex == 2 {
-		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("Sign By Default: %s", smimeSignStatus)) + "\n\n")
-	} else {
-		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("Sign By Default: %s", smimeSignStatus)) + "\n\n")
-	}
-
-	// --- PGP Section ---
-	b.WriteString(settingsFocusedStyle.Render("PGP") + "\n")
-
-	if m.focusIndex == 3 {
-		b.WriteString(settingsFocusedStyle.Render("Public Key Path:\n"))
-	} else {
-		b.WriteString(settingsBlurredStyle.Render("Public Key Path:\n"))
-	}
-	b.WriteString(m.pgpPublicKeyInput.View() + "\n\n")
-
-	if m.focusIndex == 4 {
-		b.WriteString(settingsFocusedStyle.Render("Private Key Path:\n"))
-	} else {
-		b.WriteString(settingsBlurredStyle.Render("Private Key Path:\n"))
-	}
-	b.WriteString(m.pgpPrivateKeyInput.View() + "\n\n")
-
-	// Key source toggle
-	keySourceDisplay := "File"
-	if m.pgpKeySource == "yubikey" {
-		keySourceDisplay = "YubiKey"
-	}
-	if m.focusIndex == 5 {
-		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("Key Source: %s", keySourceDisplay)) + "\n\n")
-	} else {
-		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("Key Source: %s", keySourceDisplay)) + "\n\n")
-	}
-
-	// PIN input (only shown if YubiKey is selected)
-	if m.pgpKeySource == "yubikey" {
-		if m.focusIndex == 6 {
-			b.WriteString(settingsFocusedStyle.Render("YubiKey PIN:\n"))
-		} else {
-			b.WriteString(settingsBlurredStyle.Render("YubiKey PIN:\n"))
-		}
-		b.WriteString(m.pgpPINInput.View() + "\n\n")
-	}
-
-	pgpSignStatus := "OFF"
-	if account.PGPSignByDefault {
-		pgpSignStatus = "ON"
-	}
-	if m.focusIndex == 7 {
-		b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("Sign By Default: %s", pgpSignStatus)) + "\n\n")
-	} else {
-		b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf("Sign By Default: %s", pgpSignStatus)) + "\n\n")
-	}
-
-	// --- Buttons ---
-	saveBtn := "[ Save ]"
-	cancelBtn := "[ Cancel ]"
-
-	if m.focusIndex == 8 {
-		saveBtn = settingsFocusedStyle.Render(saveBtn)
-	} else {
-		saveBtn = settingsBlurredStyle.Render(saveBtn)
-	}
-
-	if m.focusIndex == 9 {
-		cancelBtn = settingsFocusedStyle.Render(cancelBtn)
-	} else {
-		cancelBtn = settingsBlurredStyle.Render(cancelBtn)
-	}
-
-	b.WriteString(saveBtn + "  " + cancelBtn + "\n\n")
-
-	mainContent := b.String()
-	helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • space: toggle • esc: back")
-
-	if m.height > 0 {
-		currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))
-		gap := m.height - currentHeight
-		if gap > 0 {
-			mainContent += strings.Repeat("\n", gap)
-		}
-	} else {
-		mainContent += "\n\n"
-	}
-
-	return docStyle.Render(mainContent + helpView)
-}
-
-func (m *Settings) viewMailingLists() string {
-	var b strings.Builder
-
-	b.WriteString(titleStyle.Render("Mailing Lists"))
-	b.WriteString("\n\n")
-
-	if len(m.cfg.MailingLists) == 0 {
-		b.WriteString(accountEmailStyle.Render("  No mailing lists configured.\n"))
-		b.WriteString("\n")
-	}
-
-	for i, list := range m.cfg.MailingLists {
-		displayName := list.Name
-
-		addrCount := fmt.Sprintf("%d address", len(list.Addresses))
-		if len(list.Addresses) != 1 {
-			addrCount += "es"
-		}
-
-		line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(addrCount))
-
-		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 Mailing List option
-	addListText := "Add New Mailing List"
-	if m.cursor == len(m.cfg.MailingLists) {
-		b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addListText)))
-	} else {
-		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", addListText)))
-	}
-	b.WriteString("\n")
-
-	helpView := helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete • esc: back")
-	mainContent := b.String()
-
-	if m.height > 0 {
-		contentHeight := strings.Count(mainContent, "\n") + 1
-		gap := m.height - contentHeight - 2 // -2 for margins
-		if gap > 0 {
-			mainContent += strings.Repeat("\n", gap)
-		}
-	} else {
-		mainContent += "\n\n"
-	}
-
-	if m.confirmingDelete {
-		listName := m.cfg.MailingLists[m.cursor].Name
-		dialog := DialogBoxStyle.Render(
-			lipgloss.JoinVertical(lipgloss.Center,
-				dangerStyle.Render("Delete mailing list?"),
-				accountEmailStyle.Render(listName),
-				HelpStyle.Render("\n(y/n)"),
-			),
-		)
-		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
-	}
-
-	return docStyle.Render(mainContent + helpView)
-}
-
-func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	themes := theme.AllThemes()
-
-	switch msg.String() {
-	case "up", "k":
-		if m.cursor > 0 {
-			m.cursor--
-		}
-	case "down", "j":
-		if m.cursor < len(themes)-1 {
-			m.cursor++
-		}
-	case "enter":
-		if m.cursor < len(themes) {
-			selected := themes[m.cursor]
-			theme.SetTheme(selected.Name)
-			RebuildStyles()
-			m.cfg.Theme = selected.Name
-			_ = config.SaveConfig(m.cfg)
-		}
-		m.state = SettingsMain
-		m.cursor = 1 // Return to Theme option
-		return m, nil
-	case "esc":
-		m.state = SettingsMain
-		m.cursor = 1 // Return to Theme option
-		return m, nil
-	}
-	return m, nil
-}
-
-func (m *Settings) viewTheme() string {
-	themes := theme.AllThemes()
-
-	// Build left panel: theme list
-	var left strings.Builder
-	left.WriteString(titleStyle.Render("Theme") + "\n\n")
-
-	for i, t := range themes {
-		isActive := t.Name == theme.ActiveTheme.Name
-
-		label := t.Name
-		if isActive {
-			label += " (active)"
-		}
-
-		if m.cursor == i {
-			left.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", label)))
-		} else {
-			left.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", label)))
-		}
-		left.WriteString("\n")
-	}
-
-	left.WriteString("\n")
-	if !m.cfg.HideTips {
-		left.WriteString(TipStyle.Render("Tip: Custom themes can be added as\nJSON files in ~/.config/matcha/themes/") + "\n")
-	}
-
-	// Build right panel: theme preview
-	var previewTheme theme.Theme
-	if m.cursor < len(themes) {
-		previewTheme = themes[m.cursor]
-	} else {
-		previewTheme = theme.ActiveTheme
-	}
-	preview := renderThemePreview(previewTheme, m.width)
-
-	// Join panels side by side
-	leftPanel := lipgloss.NewStyle().Width(30).Render(left.String())
-	content := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, "  ", preview)
-
-	helpView := helpStyle.Render("↑/↓: navigate • enter: select • esc: back")
-
-	if m.height > 0 {
-		currentHeight := lipgloss.Height(docStyle.Render(content + "\n" + helpView))
+		currentHeight := lipgloss.Height(content + "\n\n" + helpView)
 		gap := m.height - currentHeight
 		if gap > 0 {
 			content += strings.Repeat("\n", gap)

tui/settings_accounts.go 🔗

@@ -0,0 +1,181 @@
+package tui
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/bubbles/v2/textinput"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+)
+
+func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	if m.isCryptoConfig {
+		var m2 *Settings
+		var cmd tea.Cmd
+		m2, cmd = m.updateSMIMEConfig(msg)
+		return m2, cmd
+	}
+
+	if m.confirmingDelete {
+		switch msg.String() {
+		case "y", "Y":
+			if m.accountsCursor < len(m.cfg.Accounts) {
+				accountID := m.cfg.Accounts[m.accountsCursor].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.accountsCursor > 0 {
+			m.accountsCursor--
+		}
+	case "down", "j":
+		if m.accountsCursor < len(m.cfg.Accounts) {
+			m.accountsCursor++
+		}
+	case "d":
+		if m.accountsCursor < len(m.cfg.Accounts) && len(m.cfg.Accounts) > 0 {
+			m.confirmingDelete = true
+		}
+	case "e":
+		if m.accountsCursor < len(m.cfg.Accounts) {
+			acc := m.cfg.Accounts[m.accountsCursor]
+			return m, func() tea.Msg {
+				return GoToEditAccountMsg{
+					AccountID:    acc.ID,
+					Provider:     acc.ServiceProvider,
+					Name:         acc.Name,
+					Email:        acc.Email,
+					FetchEmail:   acc.FetchEmail,
+					SendAsEmail:  acc.SendAsEmail,
+					IMAPServer:   acc.IMAPServer,
+					IMAPPort:     acc.IMAPPort,
+					SMTPServer:   acc.SMTPServer,
+					SMTPPort:     acc.SMTPPort,
+					Insecure:     acc.Insecure,
+					Protocol:     acc.Protocol,
+					JMAPEndpoint: acc.JMAPEndpoint,
+					POP3Server:   acc.POP3Server,
+					POP3Port:     acc.POP3Port,
+				}
+			}
+		}
+	case "c": // Quick shortcut to crypto config
+		if m.accountsCursor < len(m.cfg.Accounts) {
+			m.enterCryptoConfig()
+			return m, textinput.Blink
+		}
+	case "enter":
+		if m.accountsCursor == len(m.cfg.Accounts) {
+			return m, func() tea.Msg { return GoToAddAccountMsg{} }
+		} else if m.accountsCursor < len(m.cfg.Accounts) {
+			m.enterCryptoConfig()
+			return m, textinput.Blink
+		}
+	}
+	return m, nil
+}
+
+func (m *Settings) enterCryptoConfig() {
+	m.isCryptoConfig = true
+	m.editingAccountIdx = m.accountsCursor
+	acc := m.cfg.Accounts[m.accountsCursor]
+
+	m.smimeCertInput.SetValue(acc.SMIMECert)
+	m.smimeKeyInput.SetValue(acc.SMIMEKey)
+	m.pgpPublicKeyInput.SetValue(acc.PGPPublicKey)
+	m.pgpPrivateKeyInput.SetValue(acc.PGPPrivateKey)
+	if acc.PGPKeySource == "" {
+		m.pgpKeySource = "file"
+	} else {
+		m.pgpKeySource = acc.PGPKeySource
+	}
+	m.pgpPINInput.SetValue(acc.PGPPIN)
+
+	m.cryptoFocusIndex = 0
+	m.smimeCertInput.Focus()
+	m.smimeKeyInput.Blur()
+	m.pgpPublicKeyInput.Blur()
+	m.pgpPrivateKeyInput.Blur()
+	m.pgpPINInput.Blur()
+}
+
+func (m *Settings) viewAccounts() string {
+	if m.isCryptoConfig {
+		return m.viewSMIMEConfig()
+	}
+
+	var b strings.Builder
+	b.WriteString(titleStyle.Render("Account Settings") + "\n\n")
+
+	if len(m.cfg.Accounts) == 0 {
+		b.WriteString(accountEmailStyle.Render("  No accounts configured.\n\n"))
+	}
+
+	for i, account := range m.cfg.Accounts {
+		displayName := account.Email
+		if account.Name != "" {
+			displayName = fmt.Sprintf("%s (%s)", account.Name, account.FetchEmail)
+		}
+
+		providerInfo := account.ServiceProvider
+		if account.ServiceProvider == "custom" {
+			providerInfo = fmt.Sprintf("custom: %s", account.IMAPServer)
+		}
+
+		if account.SMIMECert != "" && account.SMIMEKey != "" {
+			providerInfo += " [S/MIME Configured]"
+		}
+		if account.PGPPublicKey != "" && account.PGPPrivateKey != "" {
+			providerInfo += " [PGP Configured]"
+		}
+
+		line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo))
+
+		cursor := "  "
+		style := accountItemStyle
+		if m.accountsCursor == i {
+			cursor = "> "
+			style = selectedAccountItemStyle
+		}
+
+		b.WriteString(style.Render(cursor+line) + "\n")
+	}
+
+	// Add Account option
+	cursor := "  "
+	style := accountItemStyle
+	if m.accountsCursor == len(m.cfg.Accounts) {
+		cursor = "> "
+		style = selectedAccountItemStyle
+	}
+	b.WriteString(style.Render(cursor+"Add New Account") + "\n\n")
+
+	b.WriteString(helpStyle.Render("↑/↓: navigate • enter: edit crypto config • e: edit server • d: delete"))
+
+	if m.confirmingDelete {
+		accountName := m.cfg.Accounts[m.accountsCursor].Email
+		dialog := DialogBoxStyle.Render(
+			lipgloss.JoinVertical(lipgloss.Center,
+				dangerStyle.Render("Delete account?"),
+				accountEmailStyle.Render(accountName),
+				HelpStyle.Render("\n(y/n)"),
+			),
+		)
+		// Try to overlay dialog in a reasonable way, since we don't have full screen width access easily here.
+		// Just append it.
+		b.WriteString("\n\n" + dialog)
+	}
+
+	return b.String()
+}

tui/settings_crypto.go 🔗

@@ -0,0 +1,179 @@
+package tui
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/floatpane/matcha/config"
+)
+
+const cryptoConfigMaxFocus = 9
+
+func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) {
+	var cmds []tea.Cmd
+
+	key := msg.Key()
+	isEnter := key.Code == tea.KeyEnter || key.Code == tea.KeyReturn || key.Code == tea.KeyKpEnter
+	isSpace := key.Code == tea.KeySpace
+
+	setFocus := func(next int) tea.Cmd {
+		m.cryptoFocusIndex = next
+		m.smimeCertInput.Blur()
+		m.smimeKeyInput.Blur()
+		m.pgpPublicKeyInput.Blur()
+		m.pgpPrivateKeyInput.Blur()
+		m.pgpPINInput.Blur()
+
+		switch m.cryptoFocusIndex {
+		case 0:
+			return m.smimeCertInput.Focus()
+		case 1:
+			return m.smimeKeyInput.Focus()
+		case 3:
+			return m.pgpPublicKeyInput.Focus()
+		case 4:
+			return m.pgpPrivateKeyInput.Focus()
+		case 6:
+			return m.pgpPINInput.Focus()
+		}
+		return nil
+	}
+
+	switch msg.String() {
+	case "esc":
+		m.isCryptoConfig = false
+		return m, nil
+	case "tab", "shift+tab", "up", "down":
+		if msg.String() == "shift+tab" || msg.String() == "up" {
+			m.cryptoFocusIndex--
+			if m.cryptoFocusIndex < 0 {
+				m.cryptoFocusIndex = cryptoConfigMaxFocus
+			}
+		} else {
+			m.cryptoFocusIndex++
+			if m.cryptoFocusIndex > cryptoConfigMaxFocus {
+				m.cryptoFocusIndex = 0
+			}
+		}
+		if m.cryptoFocusIndex == 6 && m.pgpKeySource != "yubikey" {
+			if msg.String() == "shift+tab" || msg.String() == "up" {
+				m.cryptoFocusIndex = 5
+			} else {
+				m.cryptoFocusIndex = 7
+			}
+		}
+		cmds = append(cmds, setFocus(m.cryptoFocusIndex))
+		return m, tea.Batch(cmds...)
+	}
+
+	if isEnter {
+		switch m.cryptoFocusIndex {
+		case 8: // Save
+			m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value()
+			m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value()
+			m.cfg.Accounts[m.editingAccountIdx].PGPPublicKey = m.pgpPublicKeyInput.Value()
+			m.cfg.Accounts[m.editingAccountIdx].PGPPrivateKey = m.pgpPrivateKeyInput.Value()
+			m.cfg.Accounts[m.editingAccountIdx].PGPKeySource = m.pgpKeySource
+			m.cfg.Accounts[m.editingAccountIdx].PGPPIN = m.pgpPINInput.Value()
+			_ = config.SaveConfig(m.cfg)
+			m.isCryptoConfig = false
+			return m, nil
+		case 9: // Cancel
+			m.isCryptoConfig = false
+			return m, nil
+		default:
+			// advance to next
+			next := m.cryptoFocusIndex + 1
+			if next == 6 && m.pgpKeySource != "yubikey" {
+				next = 7
+			}
+			cmds = append(cmds, setFocus(next))
+			return m, tea.Batch(cmds...)
+		}
+	}
+
+	if isSpace {
+		switch m.cryptoFocusIndex {
+		case 2:
+			m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault
+			return m, nil
+		case 5:
+			if m.pgpKeySource == "file" {
+				m.pgpKeySource = "yubikey"
+			} else {
+				m.pgpKeySource = "file"
+			}
+			return m, nil
+		case 7:
+			m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault
+			return m, nil
+		}
+	}
+
+	return m, tea.Batch(cmds...)
+}
+
+func (m *Settings) viewSMIMEConfig() string {
+	var b strings.Builder
+	account := m.cfg.Accounts[m.editingAccountIdx]
+	b.WriteString(titleStyle.Render(fmt.Sprintf("Crypto Config: %s", account.FetchEmail)) + "\n\n")
+
+	renderField := func(index int, label, content string) {
+		if m.cryptoFocusIndex == index {
+			b.WriteString(settingsFocusedStyle.Render(label) + "\n")
+		} else {
+			b.WriteString(settingsBlurredStyle.Render(label) + "\n")
+		}
+		b.WriteString(content + "\n\n")
+	}
+
+	// S/MIME
+	b.WriteString(settingsFocusedStyle.Render("S/MIME") + "\n")
+	renderField(0, "Certificate (PEM) Path:", m.smimeCertInput.View())
+	renderField(1, "Private Key (PEM) Path:", m.smimeKeyInput.View())
+	smimeSign := "OFF"
+	if account.SMIMESignByDefault {
+		smimeSign = "ON"
+	}
+	renderField(2, "Sign By Default:", smimeSign)
+
+	// PGP
+	b.WriteString(settingsFocusedStyle.Render("PGP") + "\n")
+	renderField(3, "Public Key Path:", m.pgpPublicKeyInput.View())
+	renderField(4, "Private Key Path:", m.pgpPrivateKeyInput.View())
+
+	keySource := "File"
+	if m.pgpKeySource == "yubikey" {
+		keySource = "YubiKey"
+	}
+	renderField(5, "Key Source:", keySource)
+
+	if m.pgpKeySource == "yubikey" {
+		renderField(6, "YubiKey PIN:", m.pgpPINInput.View())
+	}
+
+	pgpSign := "OFF"
+	if account.PGPSignByDefault {
+		pgpSign = "ON"
+	}
+	renderField(7, "Sign By Default:", pgpSign)
+
+	saveBtn := "[ Save ]"
+	cancelBtn := "[ Cancel ]"
+	if m.cryptoFocusIndex == 8 {
+		saveBtn = settingsFocusedStyle.Render(saveBtn)
+	} else {
+		saveBtn = settingsBlurredStyle.Render(saveBtn)
+	}
+	if m.cryptoFocusIndex == 9 {
+		cancelBtn = settingsFocusedStyle.Render(cancelBtn)
+	} else {
+		cancelBtn = settingsBlurredStyle.Render(cancelBtn)
+	}
+
+	b.WriteString(saveBtn + "  " + cancelBtn + "\n\n")
+	b.WriteString(helpStyle.Render("tab: next • enter: next/save • space: toggle • esc: cancel"))
+
+	return b.String()
+}

tui/settings_encryption.go 🔗

@@ -0,0 +1,149 @@
+package tui
+
+import (
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/floatpane/matcha/config"
+)
+
+func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	isEnabled := config.IsSecureModeEnabled()
+
+	if isEnabled {
+		if m.confirmingDisable {
+			switch msg.String() {
+			case "y", "Y":
+				m.confirmingDisable = false
+				cfg := m.cfg
+				return m, func() tea.Msg {
+					err := config.DisableSecureMode(cfg)
+					return SecureModeDisabledMsg{Err: err}
+				}
+			case "n", "N", "esc":
+				m.confirmingDisable = false
+				return m, nil
+			}
+			return m, nil
+		}
+		if msg.String() == "enter" {
+			m.confirmingDisable = true
+		}
+		return m, nil
+	}
+
+	switch msg.String() {
+	case "tab", "shift+tab", "down", "up":
+		if msg.String() == "shift+tab" || msg.String() == "up" {
+			m.encFocusIndex--
+			if m.encFocusIndex < 0 {
+				m.encFocusIndex = 2
+			}
+		} else {
+			m.encFocusIndex++
+			if m.encFocusIndex > 2 {
+				m.encFocusIndex = 0
+			}
+		}
+		m.encPasswordInput.Blur()
+		m.encConfirmInput.Blur()
+		var cmds []tea.Cmd
+		if m.encFocusIndex == 0 {
+			cmds = append(cmds, m.encPasswordInput.Focus())
+		}
+		if m.encFocusIndex == 1 {
+			cmds = append(cmds, m.encConfirmInput.Focus())
+		}
+		return m, tea.Batch(cmds...)
+	case "enter":
+		switch m.encFocusIndex {
+		case 0:
+			m.encFocusIndex = 1
+			m.encPasswordInput.Blur()
+			return m, m.encConfirmInput.Focus()
+		case 1:
+			m.encFocusIndex = 2
+			m.encConfirmInput.Blur()
+			return m, nil
+		case 2:
+			password := m.encPasswordInput.Value()
+			confirm := m.encConfirmInput.Value()
+			if password == "" {
+				m.encError = "Password cannot be empty"
+				return m, nil
+			}
+			if password != confirm {
+				m.encError = "Passwords do not match"
+				return m, nil
+			}
+			m.encEnabling = true
+			m.encError = ""
+			cfg := m.cfg
+			return m, func() tea.Msg {
+				err := config.EnableSecureMode(password, cfg)
+				return SecureModeEnabledMsg{Err: err}
+			}
+		}
+	}
+	return m, nil
+}
+
+func (m *Settings) viewEncryption() string {
+	var b strings.Builder
+	isEnabled := config.IsSecureModeEnabled()
+
+	b.WriteString(titleStyle.Render("App Encryption") + "\n\n")
+
+	if isEnabled {
+		if m.confirmingDisable {
+			dialog := DialogBoxStyle.Render(
+				lipgloss.JoinVertical(lipgloss.Center,
+					dangerStyle.Render("Disable encryption?"),
+					accountEmailStyle.Render("All data will be stored unencrypted."),
+					HelpStyle.Render("\n(y/n)"),
+				),
+			)
+			b.WriteString(dialog + "\n")
+		} else {
+			b.WriteString(settingsFocusedStyle.Render("  Encryption is currently enabled.") + "\n\n")
+			b.WriteString(accountEmailStyle.Render("  Press enter to disable encryption.") + "\n\n")
+			b.WriteString(helpStyle.Render("enter: disable"))
+		}
+	} else {
+		b.WriteString(accountEmailStyle.Render("Set a password to encrypt all data.") + "\n\n")
+
+		if m.encFocusIndex == 0 {
+			b.WriteString(settingsFocusedStyle.Render("Password:\n"))
+		} else {
+			b.WriteString(settingsBlurredStyle.Render("Password:\n"))
+		}
+		b.WriteString(m.encPasswordInput.View() + "\n\n")
+
+		if m.encFocusIndex == 1 {
+			b.WriteString(settingsFocusedStyle.Render("Confirm Password:\n"))
+		} else {
+			b.WriteString(settingsBlurredStyle.Render("Confirm Password:\n"))
+		}
+		b.WriteString(m.encConfirmInput.View() + "\n\n")
+
+		saveBtn := "[ Enable Encryption ]"
+		if m.encFocusIndex == 2 {
+			b.WriteString(settingsFocusedStyle.Render(saveBtn) + "\n")
+		} else {
+			b.WriteString(settingsBlurredStyle.Render(saveBtn) + "\n")
+		}
+
+		if m.encEnabling {
+			b.WriteString("\n" + accountEmailStyle.Render("  Encrypting data...") + "\n")
+		}
+
+		b.WriteString("\n" + helpStyle.Render("tab: next • enter: save"))
+	}
+
+	if m.encError != "" {
+		b.WriteString("\n" + dangerStyle.Render("  "+m.encError) + "\n")
+	}
+
+	return b.String()
+}

tui/settings_general.go 🔗

@@ -0,0 +1,119 @@
+package tui
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/floatpane/matcha/config"
+)
+
+func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	switch msg.String() {
+	case "up", "k":
+		if m.generalCursor > 0 {
+			m.generalCursor--
+		}
+	case "down", "j":
+		if m.generalCursor < 4 {
+			m.generalCursor++
+		}
+	case "enter", "space", "right", "l":
+		switch m.generalCursor {
+		case 0: // Image Display
+			m.cfg.DisableImages = !m.cfg.DisableImages
+			_ = config.SaveConfig(m.cfg)
+		case 1: // Contextual Tips
+			m.cfg.HideTips = !m.cfg.HideTips
+			_ = config.SaveConfig(m.cfg)
+		case 2: // Desktop Notifications
+			m.cfg.DisableNotifications = !m.cfg.DisableNotifications
+			_ = config.SaveConfig(m.cfg)
+		case 3: // Date Format
+			switch m.cfg.DateFormat {
+			case config.DateFormatEU:
+				m.cfg.DateFormat = config.DateFormatUS
+			case config.DateFormatUS:
+				m.cfg.DateFormat = config.DateFormatISO
+			default: // or ISO
+				m.cfg.DateFormat = config.DateFormatEU
+			}
+			_ = config.SaveConfig(m.cfg)
+		case 4: // Edit Signature
+			if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" {
+				return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
+			}
+		}
+	}
+	return m, nil
+}
+
+func (m *Settings) viewGeneral() string {
+	var b strings.Builder
+
+	b.WriteString(titleStyle.Render("General Settings") + "\n\n")
+
+	options := []struct {
+		label string
+		value string
+		tip   string
+	}{
+		{"Disable Image Display", onOff(m.cfg.DisableImages), "Prevent images from loading automatically in emails."},
+		{"Hide Contextual Tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen."},
+		{"Disable Notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail."},
+		{"Date Format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed."},
+		{"Signature", getSignatureStatus(), "Configure the signature appended to your outgoing emails."},
+	}
+
+	for i, opt := range options {
+		cursor := "  "
+		style := accountItemStyle
+		if m.generalCursor == i {
+			cursor = "> "
+			style = selectedAccountItemStyle
+		}
+
+		text := fmt.Sprintf("%s: %s", opt.label, opt.value)
+		if opt.label == "Signature" {
+			text = fmt.Sprintf("Edit Signature (%s)", opt.value)
+		}
+
+		b.WriteString(style.Render(cursor+text) + "\n")
+	}
+
+	b.WriteString("\n\n")
+
+	if !m.cfg.HideTips && m.generalCursor < len(options) {
+		b.WriteString(TipStyle.Render("Tip: " + options[m.generalCursor].tip))
+	}
+
+	return b.String()
+}
+
+func onOff(b bool) string {
+	if b {
+		return "ON"
+	}
+	return "OFF"
+}
+
+func getDateFormatLabel(f string) string {
+	if f == "" {
+		f = config.DateFormatEU
+	}
+	switch f {
+	case config.DateFormatUS:
+		return "US (MM/DD/YYYY hh:MM AM)"
+	case config.DateFormatISO:
+		return "ISO (YYYY-MM-DD HH:MM)"
+	default:
+		return "EU (DD/MM/YYYY HH:MM)"
+	}
+}
+
+func getSignatureStatus() string {
+	if config.HasSignature() {
+		return "configured"
+	}
+	return "not configured"
+}

tui/settings_lists.go 🔗

@@ -0,0 +1,112 @@
+package tui
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/floatpane/matcha/config"
+)
+
+func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	if m.confirmingDelete {
+		switch msg.String() {
+		case "y", "Y":
+			if m.listsCursor < len(m.cfg.MailingLists) {
+				m.cfg.MailingLists = append(m.cfg.MailingLists[:m.listsCursor], m.cfg.MailingLists[m.listsCursor+1:]...)
+				_ = config.SaveConfig(m.cfg)
+				if m.listsCursor >= len(m.cfg.MailingLists) && m.listsCursor > 0 {
+					m.listsCursor--
+				}
+				m.confirmingDelete = false
+			}
+		case "n", "N", "esc":
+			m.confirmingDelete = false
+			return m, nil
+		}
+		return m, nil
+	}
+
+	switch msg.String() {
+	case "up", "k":
+		if m.listsCursor > 0 {
+			m.listsCursor--
+		}
+	case "down", "j":
+		if m.listsCursor < len(m.cfg.MailingLists) {
+			m.listsCursor++
+		}
+	case "d":
+		if m.listsCursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 {
+			m.confirmingDelete = true
+		}
+	case "e":
+		if m.listsCursor < len(m.cfg.MailingLists) {
+			list := m.cfg.MailingLists[m.listsCursor]
+			idx := m.listsCursor
+			return m, func() tea.Msg {
+				return GoToEditMailingListMsg{
+					Index:     idx,
+					Name:      list.Name,
+					Addresses: strings.Join(list.Addresses, ", "),
+				}
+			}
+		}
+	case "enter":
+		if m.listsCursor == len(m.cfg.MailingLists) {
+			return m, func() tea.Msg { return GoToAddMailingListMsg{} }
+		}
+	}
+	return m, nil
+}
+
+func (m *Settings) viewMailingLists() string {
+	var b strings.Builder
+
+	b.WriteString(titleStyle.Render("Mailing Lists") + "\n\n")
+
+	if len(m.cfg.MailingLists) == 0 {
+		b.WriteString(accountEmailStyle.Render("  No mailing lists configured.\n\n"))
+	}
+
+	for i, list := range m.cfg.MailingLists {
+		addrCount := fmt.Sprintf("%d address", len(list.Addresses))
+		if len(list.Addresses) != 1 {
+			addrCount += "es"
+		}
+		line := fmt.Sprintf("%s - %s", list.Name, accountEmailStyle.Render(addrCount))
+
+		cursor := "  "
+		style := accountItemStyle
+		if m.listsCursor == i {
+			cursor = "> "
+			style = selectedAccountItemStyle
+		}
+		b.WriteString(style.Render(cursor+line) + "\n")
+	}
+
+	cursor := "  "
+	style := accountItemStyle
+	if m.listsCursor == len(m.cfg.MailingLists) {
+		cursor = "> "
+		style = selectedAccountItemStyle
+	}
+	b.WriteString(style.Render(cursor+"Add New Mailing List") + "\n\n")
+
+	b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select • e: edit • d: delete"))
+
+	if m.confirmingDelete {
+		listName := m.cfg.MailingLists[m.listsCursor].Name
+		dialog := DialogBoxStyle.Render(
+			lipgloss.JoinVertical(lipgloss.Center,
+				dangerStyle.Render("Delete mailing list?"),
+				accountEmailStyle.Render(listName),
+				HelpStyle.Render("\n(y/n)"),
+			),
+		)
+		b.WriteString("\n\n" + dialog)
+	}
+
+	return b.String()
+}

tui/settings_theme.go 🔗

@@ -0,0 +1,131 @@
+package tui
+
+import (
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/floatpane/matcha/config"
+	"github.com/floatpane/matcha/theme"
+)
+
+func (m *Settings) updateTheme(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+	themes := theme.AllThemes()
+
+	switch msg.String() {
+	case "up", "k":
+		if m.themeCursor > 0 {
+			m.themeCursor--
+		}
+	case "down", "j":
+		if m.themeCursor < len(themes)-1 {
+			m.themeCursor++
+		}
+	case "enter", "space", "right", "l":
+		if m.themeCursor < len(themes) {
+			selected := themes[m.themeCursor]
+			theme.SetTheme(selected.Name)
+			RebuildStyles()
+			m.cfg.Theme = selected.Name
+			_ = config.SaveConfig(m.cfg)
+		}
+	}
+	return m, nil
+}
+
+func (m *Settings) viewTheme() string {
+	themes := theme.AllThemes()
+	var b strings.Builder
+
+	b.WriteString(titleStyle.Render("Theme") + "\n\n")
+
+	for i, t := range themes {
+		isActive := t.Name == theme.ActiveTheme.Name
+		label := t.Name
+		if isActive {
+			label += " (active)"
+		}
+
+		cursor := "  "
+		style := accountItemStyle
+		if m.themeCursor == i {
+			cursor = "> "
+			style = selectedAccountItemStyle
+		}
+
+		b.WriteString(style.Render(cursor+label) + "\n")
+	}
+
+	b.WriteString("\n")
+
+	// Preview
+	var previewTheme theme.Theme
+	if m.themeCursor < len(themes) {
+		previewTheme = themes[m.themeCursor]
+	} else {
+		previewTheme = theme.ActiveTheme
+	}
+
+	previewWidth := m.width - 34 - 4
+	if previewWidth < 30 {
+		previewWidth = 30
+	}
+
+	b.WriteString(renderThemePreview(previewTheme, previewWidth) + "\n\n")
+
+	if !m.cfg.HideTips {
+		b.WriteString(TipStyle.Render("Tip: Custom themes can be added as JSON files in ~/.config/matcha/themes/") + "\n\n")
+	}
+
+	b.WriteString(helpStyle.Render("↑/↓: navigate • enter/space: apply theme"))
+
+	return b.String()
+}
+
+// renderThemePreview renders a small mockup showing how a theme looks.
+func renderThemePreview(t theme.Theme, previewWidth int) string {
+	if previewWidth > 60 {
+		previewWidth = 60
+	}
+
+	accent := lipgloss.NewStyle().Foreground(t.Accent)
+	accentBold := lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+	secondary := lipgloss.NewStyle().Foreground(t.Secondary)
+	muted := lipgloss.NewStyle().Foreground(t.MutedText)
+	dim := lipgloss.NewStyle().Foreground(t.DimText)
+	danger := lipgloss.NewStyle().Foreground(t.Danger)
+	warn := lipgloss.NewStyle().Foreground(t.Warning)
+	tip := lipgloss.NewStyle().Foreground(t.Tip).Italic(true)
+	link := lipgloss.NewStyle().Foreground(t.Link)
+	title := lipgloss.NewStyle().Foreground(t.AccentText).Background(t.AccentDark).Padding(0, 1)
+	activeTab := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Underline(true)
+	activeFolder := lipgloss.NewStyle().Background(t.Accent).Foreground(t.Contrast).Bold(true).Padding(0, 1)
+
+	var b strings.Builder
+
+	b.WriteString(title.Render("Preview: "+t.Name) + "\n\n")
+	b.WriteString(activeTab.Render("Inbox") + "  " + secondary.Render("Sent") + "  " + secondary.Render("Drafts") + "\n")
+	b.WriteString(secondary.Render(strings.Repeat("─", previewWidth)) + "\n")
+
+	b.WriteString(accentBold.Render("> ") + dim.Render("Alice  ") + accent.Render("Meeting tomorrow") + "  " + muted.Render("2m ago") + "\n")
+	b.WriteString("  " + dim.Render("Bob    ") + secondary.Render("Re: Project update") + "  " + muted.Render("1h ago") + "\n")
+	b.WriteString("  " + dim.Render("Carol  ") + secondary.Render("Quick question") + "    " + muted.Render("3h ago") + "\n\n")
+
+	b.WriteString(accentBold.Render("Folders") + "\n")
+	b.WriteString(activeFolder.Render(" INBOX ") + "  " + secondary.Render("Sent") + "  " + secondary.Render("Trash") + "\n\n")
+
+	b.WriteString(accentBold.Render("Success: ") + accent.Render("Email sent!") + "\n")
+	b.WriteString(danger.Render("Error: ") + danger.Render("Connection failed") + "\n")
+	b.WriteString(warn.Render("Update available: v2.0") + "\n")
+	b.WriteString(tip.Render("Tip: Press ? for help") + "\n")
+	b.WriteString(link.Render("https://example.com") + "\n")
+
+	box := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(t.AccentDark).
+		Padding(1, 2).
+		Width(previewWidth).
+		Render(b.String())
+
+	return box
+}

tui/theme.go 🔗

@@ -117,10 +117,6 @@ func RebuildStyles() {
 	filePickerSelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(t.Accent)
 	directoryStyle = lipgloss.NewStyle().Foreground(t.Directory)
 	fileSizeStyle = lipgloss.NewStyle().Foreground(t.Secondary)
-
-	// trash_archive.go
-	mailboxTabStyle = lipgloss.NewStyle().Padding(0, 3)
-	activeMailboxTabStyle = lipgloss.NewStyle().Padding(0, 3).Foreground(t.Accent).Bold(true).Underline(true)
 }
 
 // ThemedTextInputStyles returns textinput.Styles using the active theme colors.

tui/trash_archive.go 🔗

@@ -1,154 +0,0 @@
-package tui
-
-import (
-	"strings"
-
-	"charm.land/bubbles/v2/key"
-	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
-	"github.com/floatpane/matcha/config"
-	"github.com/floatpane/matcha/fetcher"
-)
-
-var (
-	mailboxTabStyle       = lipgloss.NewStyle().Padding(0, 3)
-	activeMailboxTabStyle = lipgloss.NewStyle().Padding(0, 3).Foreground(lipgloss.Color("42")).Bold(true).Underline(true)
-	mailboxTabBarStyle    = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
-)
-
-// TrashArchive is a combined view for trash and archive emails with a toggle
-type TrashArchive struct {
-	trashInbox   *Inbox
-	archiveInbox *Inbox
-	activeView   MailboxKind // MailboxTrash or MailboxArchive
-	width        int
-	height       int
-	accounts     []config.Account
-}
-
-// NewTrashArchive creates a new combined trash/archive view
-func NewTrashArchive(trashEmails, archiveEmails []fetcher.Email, accounts []config.Account) *TrashArchive {
-	return &TrashArchive{
-		trashInbox:   NewTrashInbox(trashEmails, accounts),
-		archiveInbox: NewArchiveInbox(archiveEmails, accounts),
-		activeView:   MailboxTrash,
-		accounts:     accounts,
-	}
-}
-
-func (m *TrashArchive) Init() tea.Cmd {
-	return nil
-}
-
-func (m *TrashArchive) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyPressMsg:
-		switch msg.String() {
-		case "tab":
-			// Toggle between trash and archive
-			if m.activeView == MailboxTrash {
-				m.activeView = MailboxArchive
-			} else {
-				m.activeView = MailboxTrash
-			}
-			return m, nil
-		}
-	case tea.WindowSizeMsg:
-		m.width = msg.Width
-		m.height = msg.Height
-		// Pass to both inboxes
-		m.trashInbox.Update(msg)
-		m.archiveInbox.Update(msg)
-		return m, nil
-
-	case FetchingMoreEmailsMsg, EmailsAppendedMsg, RefreshingEmailsMsg, EmailsRefreshedMsg:
-		// Forward to the appropriate inbox based on mailbox
-		if m.activeView == MailboxTrash {
-			m.trashInbox.Update(msg)
-		} else {
-			m.archiveInbox.Update(msg)
-		}
-		return m, nil
-	}
-
-	// Forward other messages to the active inbox
-	var cmd tea.Cmd
-	if m.activeView == MailboxTrash {
-		_, cmd = m.trashInbox.Update(msg)
-	} else {
-		_, cmd = m.archiveInbox.Update(msg)
-	}
-	return m, cmd
-}
-
-func (m *TrashArchive) View() tea.View {
-	var b strings.Builder
-
-	// Render the mailbox toggle tabs
-	var tabViews []string
-	if m.activeView == MailboxTrash {
-		tabViews = append(tabViews, activeMailboxTabStyle.Render("Trash"))
-		tabViews = append(tabViews, mailboxTabStyle.Render("Archive"))
-	} else {
-		tabViews = append(tabViews, mailboxTabStyle.Render("Trash"))
-		tabViews = append(tabViews, activeMailboxTabStyle.Render("Archive"))
-	}
-	tabBar := mailboxTabBarStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, tabViews...))
-	b.WriteString(tabBar)
-	b.WriteString("\n")
-
-	// Add help text for tab switching
-	helpText := helpStyle.Render("Press tab to switch between Trash and Archive")
-	b.WriteString(helpText)
-	b.WriteString("\n\n")
-
-	// Render the active inbox
-	if m.activeView == MailboxTrash {
-		b.WriteString(m.trashInbox.View().Content)
-	} else {
-		b.WriteString(m.archiveInbox.View().Content)
-	}
-
-	return tea.NewView(b.String())
-}
-
-// GetActiveMailbox returns the currently active mailbox kind
-func (m *TrashArchive) GetActiveMailbox() MailboxKind {
-	return m.activeView
-}
-
-// GetActiveInbox returns the currently active inbox
-func (m *TrashArchive) GetActiveInbox() *Inbox {
-	if m.activeView == MailboxTrash {
-		return m.trashInbox
-	}
-	return m.archiveInbox
-}
-
-// RemoveEmail removes an email from the appropriate inbox
-func (m *TrashArchive) RemoveEmail(uid uint32, accountID string, mailbox MailboxKind) {
-	if mailbox == MailboxTrash {
-		m.trashInbox.RemoveEmail(uid, accountID)
-	} else if mailbox == MailboxArchive {
-		m.archiveInbox.RemoveEmail(uid, accountID)
-	}
-}
-
-// SetTrashEmails updates the trash emails
-func (m *TrashArchive) SetTrashEmails(emails []fetcher.Email, accounts []config.Account) {
-	m.trashInbox.SetEmails(emails, accounts)
-	m.accounts = accounts
-}
-
-// SetArchiveEmails updates the archive emails
-func (m *TrashArchive) SetArchiveEmails(emails []fetcher.Email, accounts []config.Account) {
-	m.archiveInbox.SetEmails(emails, accounts)
-	m.accounts = accounts
-}
-
-// AdditionalShortHelpKeys returns additional help keys for the trash/archive view
-func (m *TrashArchive) AdditionalShortHelpKeys() []key.Binding {
-	return []key.Binding{
-		key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch view")),
-	}
-}

tui/trash_archive_test.go 🔗

@@ -1,340 +0,0 @@
-package tui
-
-import (
-	"testing"
-	"time"
-
-	tea "charm.land/bubbletea/v2"
-	"github.com/floatpane/matcha/config"
-	"github.com/floatpane/matcha/fetcher"
-)
-
-// TestNewTrashArchive verifies that a new TrashArchive is created correctly.
-func TestNewTrashArchive(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "test@example.com", FetchEmail: "fetch@example.com"},
-	}
-
-	trashEmails := []fetcher.Email{
-		{UID: 1, From: "a@example.com", Subject: "Trash Email 1", AccountID: "account-1"},
-	}
-
-	archiveEmails := []fetcher.Email{
-		{UID: 2, From: "b@example.com", Subject: "Archive Email 1", AccountID: "account-1"},
-	}
-
-	ta := NewTrashArchive(trashEmails, archiveEmails, accounts)
-
-	// Default view should be Trash
-	if ta.activeView != MailboxTrash {
-		t.Errorf("Expected default view to be MailboxTrash, got %v", ta.activeView)
-	}
-
-	// GetActiveMailbox should return Trash
-	if ta.GetActiveMailbox() != MailboxTrash {
-		t.Errorf("Expected GetActiveMailbox to return MailboxTrash, got %v", ta.GetActiveMailbox())
-	}
-
-	// GetActiveInbox should return trashInbox
-	if ta.GetActiveInbox() != ta.trashInbox {
-		t.Error("Expected GetActiveInbox to return trashInbox")
-	}
-}
-
-// TestTrashArchiveToggle verifies toggling between trash and archive views.
-func TestTrashArchiveToggle(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "test@example.com"},
-	}
-
-	trashEmails := []fetcher.Email{
-		{UID: 1, From: "a@example.com", Subject: "Trash Email", AccountID: "account-1"},
-	}
-
-	archiveEmails := []fetcher.Email{
-		{UID: 2, From: "b@example.com", Subject: "Archive Email", AccountID: "account-1"},
-	}
-
-	ta := NewTrashArchive(trashEmails, archiveEmails, accounts)
-
-	// Initially should be Trash
-	if ta.activeView != MailboxTrash {
-		t.Fatalf("Expected initial view to be MailboxTrash, got %v", ta.activeView)
-	}
-
-	// Press tab to switch to Archive
-	ta.Update(tea.KeyPressMsg{Code: tea.KeyTab})
-
-	if ta.activeView != MailboxArchive {
-		t.Errorf("Expected view to be MailboxArchive after tab, got %v", ta.activeView)
-	}
-
-	if ta.GetActiveInbox() != ta.archiveInbox {
-		t.Error("Expected GetActiveInbox to return archiveInbox after toggle")
-	}
-
-	// Press tab again to switch back to Trash
-	ta.Update(tea.KeyPressMsg{Code: tea.KeyTab})
-
-	if ta.activeView != MailboxTrash {
-		t.Errorf("Expected view to be MailboxTrash after second tab, got %v", ta.activeView)
-	}
-}
-
-// TestTrashArchiveRemoveEmail verifies removing emails from the correct inbox.
-func TestTrashArchiveRemoveEmail(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "test@example.com"},
-	}
-
-	trashEmails := []fetcher.Email{
-		{UID: 1, From: "a@example.com", Subject: "Trash Email 1", AccountID: "account-1"},
-		{UID: 2, From: "b@example.com", Subject: "Trash Email 2", AccountID: "account-1"},
-	}
-
-	archiveEmails := []fetcher.Email{
-		{UID: 3, From: "c@example.com", Subject: "Archive Email 1", AccountID: "account-1"},
-		{UID: 4, From: "d@example.com", Subject: "Archive Email 2", AccountID: "account-1"},
-	}
-
-	ta := NewTrashArchive(trashEmails, archiveEmails, accounts)
-
-	// Remove a trash email
-	ta.RemoveEmail(1, "account-1", MailboxTrash)
-
-	if len(ta.trashInbox.allEmails) != 1 {
-		t.Errorf("Expected 1 trash email after removal, got %d", len(ta.trashInbox.allEmails))
-	}
-
-	// Archive emails should be unchanged
-	if len(ta.archiveInbox.allEmails) != 2 {
-		t.Errorf("Expected 2 archive emails unchanged, got %d", len(ta.archiveInbox.allEmails))
-	}
-
-	// Remove an archive email
-	ta.RemoveEmail(3, "account-1", MailboxArchive)
-
-	if len(ta.archiveInbox.allEmails) != 1 {
-		t.Errorf("Expected 1 archive email after removal, got %d", len(ta.archiveInbox.allEmails))
-	}
-}
-
-// TestTrashArchiveSetEmails verifies updating emails in trash and archive.
-func TestTrashArchiveSetEmails(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "test@example.com"},
-	}
-
-	ta := NewTrashArchive(nil, nil, accounts)
-
-	// Set trash emails
-	newTrashEmails := []fetcher.Email{
-		{UID: 10, From: "new@example.com", Subject: "New Trash", AccountID: "account-1"},
-	}
-	ta.SetTrashEmails(newTrashEmails, accounts)
-
-	if len(ta.trashInbox.allEmails) != 1 {
-		t.Errorf("Expected 1 trash email after SetTrashEmails, got %d", len(ta.trashInbox.allEmails))
-	}
-
-	// Set archive emails
-	newArchiveEmails := []fetcher.Email{
-		{UID: 20, From: "archive@example.com", Subject: "New Archive", AccountID: "account-1"},
-		{UID: 21, From: "archive2@example.com", Subject: "New Archive 2", AccountID: "account-1"},
-	}
-	ta.SetArchiveEmails(newArchiveEmails, accounts)
-
-	if len(ta.archiveInbox.allEmails) != 2 {
-		t.Errorf("Expected 2 archive emails after SetArchiveEmails, got %d", len(ta.archiveInbox.allEmails))
-	}
-}
-
-// TestTrashArchiveWindowSizeMsg verifies window size is passed to both inboxes.
-func TestTrashArchiveWindowSizeMsg(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "test@example.com"},
-	}
-
-	ta := NewTrashArchive(nil, nil, accounts)
-
-	ta.Update(tea.WindowSizeMsg{Width: 100, Height: 50})
-
-	if ta.width != 100 {
-		t.Errorf("Expected width 100, got %d", ta.width)
-	}
-	if ta.height != 50 {
-		t.Errorf("Expected height 50, got %d", ta.height)
-	}
-}
-
-// TestTrashArchiveViewEmailMsg verifies that selecting an email generates correct message.
-func TestTrashArchiveViewEmailMsg(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "test@example.com"},
-	}
-
-	trashEmails := []fetcher.Email{
-		{UID: 100, From: "sender@example.com", Subject: "Test Trash", AccountID: "account-1", Date: time.Now()},
-	}
-
-	ta := NewTrashArchive(trashEmails, nil, accounts)
-
-	// Simulate pressing Enter on trash view
-	_, cmd := ta.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
-	if cmd == nil {
-		t.Fatal("Expected a command, but got nil")
-	}
-
-	msg := cmd()
-	viewMsg, ok := msg.(ViewEmailMsg)
-	if !ok {
-		t.Fatalf("Expected ViewEmailMsg, got %T", msg)
-	}
-
-	if viewMsg.UID != 100 {
-		t.Errorf("Expected UID 100, got %d", viewMsg.UID)
-	}
-
-	if viewMsg.Mailbox != MailboxTrash {
-		t.Errorf("Expected Mailbox MailboxTrash, got %v", viewMsg.Mailbox)
-	}
-}
-
-// TestTrashArchiveDeleteEmailMsg verifies delete messages from trash/archive views.
-func TestTrashArchiveDeleteEmailMsg(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "test@example.com"},
-	}
-
-	archiveEmails := []fetcher.Email{
-		{UID: 200, From: "sender@example.com", Subject: "Test Archive", AccountID: "account-1", Date: time.Now()},
-	}
-
-	ta := NewTrashArchive(nil, archiveEmails, accounts)
-
-	// Switch to archive view
-	ta.Update(tea.KeyPressMsg{Code: tea.KeyTab})
-
-	// Simulate pressing 'd' to delete
-	_, cmd := ta.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
-	if cmd == nil {
-		t.Fatal("Expected a command, but got nil")
-	}
-
-	msg := cmd()
-	deleteMsg, ok := msg.(DeleteEmailMsg)
-	if !ok {
-		t.Fatalf("Expected DeleteEmailMsg, got %T", msg)
-	}
-
-	if deleteMsg.UID != 200 {
-		t.Errorf("Expected UID 200, got %d", deleteMsg.UID)
-	}
-
-	if deleteMsg.Mailbox != MailboxArchive {
-		t.Errorf("Expected Mailbox MailboxArchive, got %v", deleteMsg.Mailbox)
-	}
-}
-
-// TestTrashArchiveMultipleAccounts verifies tabs are created for multiple accounts.
-func TestTrashArchiveMultipleAccounts(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "test1@example.com", FetchEmail: "fetch1@example.com"},
-		{ID: "account-2", Email: "test2@example.com", FetchEmail: "fetch2@example.com"},
-	}
-
-	trashEmails := []fetcher.Email{
-		{UID: 1, From: "a@example.com", Subject: "Trash 1", AccountID: "account-1"},
-		{UID: 2, From: "b@example.com", Subject: "Trash 2", AccountID: "account-2"},
-	}
-
-	ta := NewTrashArchive(trashEmails, nil, accounts)
-
-	// Both trash and archive inboxes should have 3 tabs (ALL + 2 accounts)
-	if len(ta.trashInbox.tabs) != 3 {
-		t.Errorf("Expected 3 tabs in trashInbox, got %d", len(ta.trashInbox.tabs))
-	}
-
-	if len(ta.archiveInbox.tabs) != 3 {
-		t.Errorf("Expected 3 tabs in archiveInbox, got %d", len(ta.archiveInbox.tabs))
-	}
-
-	// Verify FetchEmail is used for tab labels
-	if ta.trashInbox.tabs[1].Label != "fetch1@example.com" {
-		t.Errorf("Expected tab label 'fetch1@example.com', got %q", ta.trashInbox.tabs[1].Label)
-	}
-}
-
-// TestTrashInboxMailboxKind verifies that trash inbox has correct mailbox kind.
-func TestTrashInboxMailboxKind(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "test@example.com"},
-	}
-
-	inbox := NewTrashInbox(nil, accounts)
-
-	if inbox.GetMailbox() != MailboxTrash {
-		t.Errorf("Expected MailboxTrash, got %v", inbox.GetMailbox())
-	}
-}
-
-// TestArchiveInboxMailboxKind verifies that archive inbox has correct mailbox kind.
-func TestArchiveInboxMailboxKind(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "test@example.com"},
-	}
-
-	inbox := NewArchiveInbox(nil, accounts)
-
-	if inbox.GetMailbox() != MailboxArchive {
-		t.Errorf("Expected MailboxArchive, got %v", inbox.GetMailbox())
-	}
-}
-
-// TestInboxGetBaseTitle verifies correct titles for different mailbox kinds.
-func TestInboxGetBaseTitle(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "test@example.com"},
-	}
-
-	tests := []struct {
-		name     string
-		mailbox  MailboxKind
-		expected string
-	}{
-		{"Inbox", MailboxInbox, "Inbox"},
-		{"Sent", MailboxSent, "Sent"},
-		{"Trash", MailboxTrash, "Trash"},
-		{"Archive", MailboxArchive, "Archive"},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			inbox := NewInboxWithMailbox(nil, accounts, tt.mailbox)
-			title := inbox.getBaseTitle()
-			if title != tt.expected {
-				t.Errorf("Expected title %q, got %q", tt.expected, title)
-			}
-		})
-	}
-}
-
-// TestInboxFetchEmailUsedForTabs verifies that FetchEmail is used for tab labels.
-func TestInboxFetchEmailUsedForTabs(t *testing.T) {
-	accounts := []config.Account{
-		{ID: "account-1", Email: "login@example.com", FetchEmail: "display@example.com"},
-		{ID: "account-2", Email: "login2@example.com"}, // No FetchEmail, should fallback to Email
-	}
-
-	inbox := NewInbox(nil, accounts)
-
-	// First account should use FetchEmail
-	if inbox.tabs[1].Label != "display@example.com" {
-		t.Errorf("Expected tab label 'display@example.com', got %q", inbox.tabs[1].Label)
-	}
-
-	// Second account should fallback to Email
-	if inbox.tabs[2].Label != "login2@example.com" {
-		t.Errorf("Expected tab label 'login2@example.com', got %q", inbox.tabs[2].Label)
-	}
-}