fix: unknown window appear (DOESNT COMPILE)

drew created

Change summary

main.go         | 237 +++++++++++++++++++++++++++++++++++---------------
tui/choice.go   |  83 ++++++++++-------
tui/inbox.go    | 148 ++++++++++++++-----------------
tui/login.go    | 137 ++++++++++------------------
tui/messages.go |  41 ++++++--
5 files changed, 358 insertions(+), 288 deletions(-)

Detailed changes

main.go 🔗

@@ -1,122 +1,211 @@
 package main
 
 import (
-	"fmt"
 	"log"
 	"os"
 	"time"
 
+	// These imports were missing, causing all the "undefined" errors.
 	"github.com/andrinoff/email-cli/config"
+	"github.com/andrinoff/email-cli/fetcher"
 	"github.com/andrinoff/email-cli/sender"
 	"github.com/andrinoff/email-cli/tui"
+
 	tea "github.com/charmbracelet/bubbletea"
 )
 
-// mainModel now holds the state for the entire application.
-type mainModel struct {
-	current tea.Model
-	config  *config.Config
-	width   int
-	height  int
-	err     error
+type modelState int
+
+const (
+	loginView modelState = iota
+	mainMenu
+	inboxView
+	emailView
+	composeView
+)
+
+// Model holds the application's state.
+type Model struct {
+	state         modelState
+	login         tea.Model
+	mainMenu      tea.Model
+	inbox         tea.Model
+	emailView     tea.Model
+	composer      tea.Model
+	fetcher       *fetcher.Fetcher
+	sender        *sender.Sender
+	emails        []fetcher.Email
+	statusMessage string
+	err           error
 }
 
-// newInitialModel now returns a pointer, which is crucial for state management.
-func newInitialModel(cfg *config.Config) *mainModel {
-	return &mainModel{
-		current: tui.NewChoice(),
-		config:  cfg,
+// New creates the initial model.
+func New() Model {
+	// We start with the login view.
+	return Model{
+		state:    loginView,
+		login:    tui.NewLogin(),
+		mainMenu: tui.NewChoice(),
 	}
 }
 
-func (m *mainModel) Init() tea.Cmd {
-	return m.current.Init()
+// Init runs any initial commands.
+func (m Model) Init() tea.Cmd {
+	return tea.EnterAltScreen
 }
 
-func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+// Update handles all messages and updates the model.
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
 
+	// Global messages that can be handled regardless of state.
 	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		m.width = msg.Width
-		m.height = msg.Height
-		// Pass the window size message to the current view
-		m.current, cmd = m.current.Update(msg)
-		cmds = append(cmds, cmd)
-		return m, tea.Batch(cmds...)
-
 	case tea.KeyMsg:
 		if msg.String() == "ctrl+c" {
 			return m, tea.Quit
 		}
-		// Allow ESC to go back to the main menu
-		if msg.String() == "esc" {
-			if _, ok := m.current.(*tui.Choice); !ok {
-				m.current = tui.NewChoice()
-				return m, m.current.Init()
+	case tui.EmailResultMsg:
+		if msg.Err != nil {
+			m.statusMessage = "❌ Error: " + msg.Err.Error()
+		} else {
+			m.statusMessage = "✅ Email sent successfully!"
+		}
+		m.state = mainMenu
+		return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg {
+			return tui.ClearStatusMsg{}
+		})
+	case tui.ClearStatusMsg:
+		m.statusMessage = ""
+		return m, nil
+	}
+
+	// State-specific message handling.
+	switch m.state {
+	case loginView:
+		// When the user submits their credentials...
+		if msg, ok := msg.(tui.Credentials); ok {
+			// For now, we'll default to Gmail.
+			service := "gmail"
+			cfg := &config.Config{Email: msg.Email, Password: msg.Password, Service: service}
+			config.Save(cfg)
+			m.fetcher = fetcher.New(cfg)
+			m.sender = sender.New(msg.Email, msg.Password, service)
+			m.state = mainMenu
+			return m, nil
+		}
+
+	case mainMenu:
+		// Handle user's choice from the main menu.
+		if msg, ok := msg.(tea.KeyMsg); ok {
+			switch msg.String() {
+			case "1", "j", "down": // View Inbox
+				m.state = inboxView
+				m.inbox = tui.NewInbox(m.emails)
+				return m, func() tea.Msg {
+					emails, err := m.fetcher.FetchEmails("INBOX")
+					if err != nil {
+						return tui.FetchErr(err)
+					}
+					return tui.EmailsFetchedMsg{Emails: emails}
+				}
+			case "2", "k", "up": // Compose Email
+				m.state = composeView
+				m.composer = tui.NewComposer(m.fetcher.Config.Email)
+				return m, nil
+			}
+		}
+
+	case inboxView:
+		switch msg := msg.(type) {
+		case tui.EmailsFetchedMsg:
+			m.emails = msg.Emails
+			m.inbox = tui.NewInbox(m.emails)
+			return m, nil
+		case tui.ViewEmailMsg:
+			m.state = emailView
+			m.emailView = tui.NewEmailView(m.emails[msg.Index])
+			return m, nil
+		case tea.KeyMsg:
+			if msg.String() == "esc" {
+				m.state = mainMenu
 			}
 		}
 
-	// --- Custom Messages for switching views ---
-	case tui.GoToInboxMsg:
-		m.current = tui.NewInbox()
-		// Manually set the size of the new view
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-		cmds = append(cmds, m.current.Init())
-
-	case tui.GoToSendMsg:
-		m.current = tui.NewComposer(m.config.Email)
-		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-		cmds = append(cmds, m.current.Init())
-
-	case tui.ViewEmailMsg:
-		m.current = tui.NewEmailView(msg.Email, m.width, m.height)
-		cmds = append(cmds, m.current.Init())
-
-	case tui.SendEmailMsg:
-		m.current = tui.NewStatus("Sending email...")
-		cmds = append(cmds, m.current.Init(), sendEmail(m.config, msg))
-
-	case tui.EmailSentMsg:
-		m.current = tui.NewChoice()
-		cmds = append(cmds, m.current.Init())
+	case emailView:
+		if msg, ok := msg.(tea.KeyMsg); ok && msg.String() == "esc" {
+			m.state = inboxView // Go back to the inbox list.
+		}
+
+	case composeView:
+		switch msg := msg.(type) {
+		case tui.SendEmailMsg:
+			m.statusMessage = "⏳ Sending email..."
+			return m, func() tea.Msg {
+				err := m.sender.Send(msg.To, msg.Subject, msg.Body)
+				return tui.EmailResultMsg{Err: err}
+			}
+		case tea.KeyMsg:
+			if msg.String() == "esc" {
+				m.state = mainMenu
+			}
+		}
 	}
 
-	// Pass all other messages to the current view
-	m.current, cmd = m.current.Update(msg)
+	// Pass the message to the current view's Update function.
+	switch m.state {
+	case loginView:
+		m.login, cmd = m.login.Update(msg)
+	case mainMenu:
+		m.mainMenu, cmd = m.mainMenu.Update(msg)
+	case inboxView:
+		m.inbox, cmd = m.inbox.Update(msg)
+	case emailView:
+		m.emailView, cmd = m.emailView.Update(msg)
+	case composeView:
+		m.composer, cmd = m.composer.Update(msg)
+	}
 	cmds = append(cmds, cmd)
-
 	return m, tea.Batch(cmds...)
 }
 
-func (m *mainModel) View() string {
-	return m.current.View()
-}
+// View renders the current UI.
+func (m Model) View() string {
+	var s string
+	switch m.state {
+	case loginView:
+		s = m.login.View()
+	case mainMenu:
+		s = m.mainMenu.View()
+	case inboxView:
+		s = m.inbox.View()
+	case emailView:
+		s = m.emailView.View()
+	case composeView:
+		s = m.composer.View()
+	default:
+		s = "Unknown state."
+	}
 
-// sendEmail is a command that sends an email in the background.
-func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
-	return func() tea.Msg {
-		recipients := []string{msg.To}
-		err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body)
-		if err != nil {
-			log.Printf("Failed to send email: %v", err) // Log error
-		}
-		time.Sleep(1 * time.Second) // Give user time to see the "Sending" message
-		return tui.EmailSentMsg{}
+	if m.statusMessage != "" {
+		return s + "\n\n" + m.statusMessage
 	}
+	return s
 }
 
 func main() {
-	cfg, err := config.LoadConfig()
-	if err != nil {
-		log.Fatalf("could not load config: %v", err)
+	// Load config first to see if we can skip login.
+	cfg := config.Load()
+	m := New()
+	if cfg.Email != "" && cfg.Password != "" && cfg.Service != "" {
+		m.fetcher = fetcher.New(cfg)
+		m.sender = sender.New(cfg.Email, cfg.Password, cfg.Service)
+		m.state = mainMenu
 	}
 
-	p := tea.NewProgram(newInitialModel(cfg), tea.WithAltScreen())
-
+	p := tea.NewProgram(m)
 	if _, err := p.Run(); err != nil {
-		fmt.Printf("Alas, there's been an error: %v", err)
+		log.Fatal(err)
 		os.Exit(1)
 	}
 }

tui/choice.go 🔗

@@ -1,57 +1,70 @@
 package tui
 
 import (
-	"github.com/charmbracelet/bubbles/list"
+	"fmt"
+
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
 )
 
-type Choice struct {
-	list list.Model
-}
-
-func NewChoice() *Choice {
-	items := []list.Item{
-		choiceItem("View Inbox"),
-		choiceItem("Send Email"),
-	}
+var (
+	titleStyle = lipgloss.NewStyle().
+			Bold(true).
+			Foreground(lipgloss.Color("#FAFAFA")).
+			Background(lipgloss.Color("#7D56F4")).
+			PaddingLeft(2).
+			PaddingRight(2)
 
-	l := list.New(items, list.NewDefaultDelegate(), 0, 0)
-	l.Title = "What would you like to do?"
-	l.SetShowStatusBar(false)
-	l.SetFilteringEnabled(false)
+	choiceStyle = lipgloss.NewStyle().
+			Foreground(lipgloss.Color("205")).
+			PaddingLeft(2)
+)
 
-	return &Choice{list: l}
+type Choice struct {
+	cursor int
 }
 
-type choiceItem string
-
-func (i choiceItem) FilterValue() string { return "" }
-func (i choiceItem) Title() string       { return string(i) }
-func (i choiceItem) Description() string { return "" }
+func NewChoice() Choice {
+	return Choice{cursor: 0}
+}
 
-func (m *Choice) Init() tea.Cmd {
+func (m Choice) Init() tea.Cmd {
 	return nil
 }
 
-func (m *Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmd tea.Cmd
+func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		m.list.SetSize(msg.Width, msg.Height)
 	case tea.KeyMsg:
-		if msg.String() == "enter" {
-			switch m.list.SelectedItem().(choiceItem) {
-			case "View Inbox":
-				return m, func() tea.Msg { return GoToInboxMsg{} }
-			case "Send Email":
-				return m, func() tea.Msg { return GoToSendMsg{} }
+		switch msg.String() {
+		case "up", "k":
+			if m.cursor > 0 {
+				m.cursor--
+			}
+		case "down", "j":
+			if m.cursor < 1 {
+				m.cursor++
 			}
 		}
 	}
-	m.list, cmd = m.list.Update(msg)
-	return m, cmd
+	return m, nil
 }
 
-func (m *Choice) View() string {
-	return DocStyle.Render(m.list.View())
+func (m Choice) View() string {
+	s := "What would you like to do?\n\n"
+
+	choices := []string{"View Inbox", "Compose Email"}
+
+	for i, choice := range choices {
+		cursor := " "
+		if m.cursor == i {
+			cursor = ">"
+			s += choiceStyle.Render(fmt.Sprintf("%s %s\n", cursor, choice))
+		} else {
+			s += fmt.Sprintf("%s %s\n", cursor, choice)
+		}
+	}
+
+	s += "\nPress q to quit.\n"
+
+	return s
 }

tui/inbox.go 🔗

@@ -2,115 +2,103 @@ package tui
 
 import (
 	"fmt"
+	"io"
 
-	"github.com/andrinoff/email-cli/config"
 	"github.com/andrinoff/email-cli/fetcher"
 	"github.com/charmbracelet/bubbles/list"
-	"github.com/charmbracelet/bubbles/spinner"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 )
 
-// inboxItem wraps fetcher.Email to satisfy the list.Item interface.
-type inboxItem struct {
-	fetcher.Email
+var (
+	itemStyle         = lipgloss.NewStyle().PaddingLeft(4)
+	selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("205"))
+	paginationStyle   = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
+	inboxHelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
+)
+
+type item struct {
+	title, desc string
 }
 
-func (i inboxItem) Title() string       { return i.Subject }
-func (i inboxItem) Description() string { return fmt.Sprintf("From: %s", i.From) }
-func (i inboxItem) FilterValue() string { return i.Subject }
+func (i item) Title() string       { return i.title }
+func (i item) Description() string { return i.desc }
+func (i item) FilterValue() string { return i.title + " " + i.desc }
 
-type Inbox struct {
-	list    list.Model
-	spinner spinner.Model
-	loading bool
-	err     error
-}
+type itemDelegate struct{}
 
-func NewInbox() *Inbox {
-	s := spinner.New()
-	s.Spinner = spinner.Dot
-	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+func (d itemDelegate) Height() int                             { return 1 }
+func (d itemDelegate) Spacing() int                            { return 0 }
+func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	i, ok := listItem.(item)
+	if !ok {
+		return
+	}
 
-	// Create an empty list for now. It will be populated later.
-	l := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
-	l.Title = "Inbox"
+	str := fmt.Sprintf("%d. %s", index+1, i.title)
 
-	return &Inbox{
-		spinner: s,
-		list:    l,
-		loading: true,
+	// **FIX**: Corrected the function literal to accept a variadic string
+	fn := itemStyle.Render
+	if index == m.Index() {
+		fn = func(s ...string) string {
+			return selectedItemStyle.Render("> " + s[0])
+		}
 	}
+
+	fmt.Fprint(w, fn(str))
 }
 
-func (m *Inbox) Init() tea.Cmd {
-	return tea.Batch(m.spinner.Tick, fetchEmails)
+type Inbox struct {
+	list list.Model
 }
 
-func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmd tea.Cmd
-	switch msg := msg.(type) {
-	case spinner.TickMsg:
-		if m.loading {
-			m.spinner, cmd = m.spinner.Update(msg)
+func NewInbox(emails []fetcher.Email) Inbox {
+	items := make([]list.Item, len(emails))
+	for i, email := range emails {
+		items[i] = item{
+			title: email.Subject,
+			desc:  email.From,
 		}
-		return m, cmd
+	}
 
-	case emailsFetchedMsg:
-		m.loading = false
-		items := make([]list.Item, len(msg))
-		for i, email := range msg {
-			items[i] = inboxItem{email}
-		}
-		m.list.SetItems(items)
-		return m, nil
+	l := list.New(items, itemDelegate{}, 20, 14)
+	l.Title = "Inbox"
+	l.SetShowStatusBar(false)
+	l.SetFilteringEnabled(false)
+	l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
+	l.Styles.PaginationStyle = paginationStyle
+	l.Styles.HelpStyle = inboxHelpStyle
 
-	case errMsg:
-		m.err = msg.err
-		return m, tea.Quit
+	return Inbox{list: l}
+}
 
+func (m Inbox) Init() tea.Cmd {
+	return nil
+}
+
+func (m Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
 	case tea.KeyMsg:
-		if msg.String() == "enter" && !m.loading {
-			selected := m.list.SelectedItem().(inboxItem)
-			return m, func() tea.Msg {
-				return ViewEmailMsg{Email: selected.Email}
+		if msg.String() == "enter" {
+			i, ok := m.list.SelectedItem().(item)
+			if ok {
+				_ = i
+				return m, func() tea.Msg {
+					return ViewEmailMsg{Index: m.list.Index()}
+				}
 			}
 		}
-
 	case tea.WindowSizeMsg:
-		// When the window size changes, update the list's dimensions.
-		m.list.SetSize(msg.Width, msg.Height)
+		m.list.SetWidth(msg.Width)
+		return m, nil
 	}
 
-	if !m.loading {
-		m.list, cmd = m.list.Update(msg)
-	}
+	var cmd tea.Cmd
+	m.list, cmd = m.list.Update(msg)
 	return m, cmd
 }
 
-func (m *Inbox) View() string {
-	if m.err != nil {
-		return fmt.Sprintf("Error: %v", m.err)
-	}
-	if m.loading {
-		return fmt.Sprintf("\n\n   %s Fetching emails...\n\n", m.spinner.View())
-	}
-	return DocStyle.Render(m.list.View())
-}
-
-// --- Bubble Tea Commands and Messages ---
-
-type emailsFetchedMsg []fetcher.Email
-type errMsg struct{ err error }
-
-func fetchEmails() tea.Msg {
-	cfg, err := config.LoadConfig()
-	if err != nil {
-		return errMsg{err}
-	}
-	emails, err := fetcher.FetchEmails(cfg)
-	if err != nil {
-		return errMsg{err}
-	}
-	return emailsFetchedMsg(emails)
+func (m Inbox) View() string {
+	return "\n" + m.list.View()
 }

tui/login.go 🔗

@@ -1,54 +1,38 @@
 package tui
 
 import (
-	"strings"
-
-	"github.com/andrinoff/email-cli/config"
-	"github.com/charmbracelet/bubbles/cursor"
 	"github.com/charmbracelet/bubbles/textinput"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 )
 
-type loginModel struct {
+// Login holds the state for the login form.
+type Login struct {
 	focusIndex int
 	inputs     []textinput.Model
-	cursorMode cursor.Mode
-	config     *config.Config
-	err        error
 }
 
-func initialLoginModel() loginModel {
-	m := loginModel{
-		inputs: make([]textinput.Model, 4),
+// NewLogin creates a new login model. This function was missing.
+func NewLogin() tea.Model {
+	m := Login{
+		inputs: make([]textinput.Model, 2),
 	}
 
 	var t textinput.Model
 	for i := range m.inputs {
 		t = textinput.New()
-		t.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
-		t.CharLimit = 32
+		t.Cursor.Style = focusedStyle
+		t.CharLimit = 64
 
 		switch i {
 		case 0:
-			t.Placeholder = "gmail or icloud"
+			t.Placeholder = "Email"
 			t.Focus()
-			t.Prompt = "> "
-			t.CharLimit = 10
+			t.Prompt = "✉️ > "
 		case 1:
-			t.Placeholder = "your@email.com"
-			t.Prompt = "  "
-			t.CharLimit = 64
-		case 2:
-			t.Placeholder = "App-specific password"
-			t.Prompt = "  "
+			t.Placeholder = "Password"
 			t.EchoMode = textinput.EchoPassword
-			t.EchoCharacter = '•'
-			t.CharLimit = 64
-		case 3:
-			t.Placeholder = "Name"
-			t.Prompt = "  "
-			t.CharLimit = 64
+			t.Prompt = "🔑 > "
 		}
 		m.inputs[i] = t
 	}
@@ -56,92 +40,69 @@ func initialLoginModel() loginModel {
 	return m
 }
 
-func (m loginModel) Init() tea.Cmd { return textinput.Blink }
+// Init initializes the login model.
+func (m Login) Init() tea.Cmd {
+	return textinput.Blink
+}
 
-func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+// Update handles messages for the login model.
+func (m Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
-		switch msg.String() {
-		case "ctrl+c", "esc":
-			return m, tea.Quit
-		case "tab", "shift+tab", "enter", "up", "down":
-			s := msg.String()
-			if s == "enter" && m.focusIndex == len(m.inputs) {
-				m.config = &config.Config{
-					ServiceProvider: m.inputs[0].Value(),
-					Email:           m.inputs[1].Value(),
-					Password:        m.inputs[2].Value(),
-					Name: 		     m.inputs[3].Value(),
-				}
-				if err := config.SaveConfig(m.config); err != nil {
-					m.err = err
-					return m, nil
+		switch msg.Type {
+		// On Enter, if we are on the last field, submit the credentials.
+		case tea.KeyEnter:
+			if m.focusIndex == len(m.inputs)-1 {
+				return m, func() tea.Msg {
+					return Credentials{
+						Email:    m.inputs[0].Value(),
+						Password: m.inputs[1].Value(),
+					}
 				}
-				return m, tea.Quit
 			}
+			fallthrough
+		// Cycle focus between inputs.
+		case tea.KeyTab, tea.KeyShiftTab, tea.KeyUp, tea.KeyDown:
+			s := msg.String()
 			if s == "up" || s == "shift+tab" {
 				m.focusIndex--
 			} else {
 				m.focusIndex++
 			}
-			if m.focusIndex > len(m.inputs) {
+
+			if m.focusIndex >= len(m.inputs) {
 				m.focusIndex = 0
 			} else if m.focusIndex < 0 {
-				m.focusIndex = len(m.inputs)
+				m.focusIndex = len(m.inputs) - 1
 			}
+
 			cmds := make([]tea.Cmd, len(m.inputs))
 			for i := 0; i < len(m.inputs); i++ {
 				if i == m.focusIndex {
 					cmds[i] = m.inputs[i].Focus()
-					m.inputs[i].Prompt = "> "
-					continue
+				} else {
+					m.inputs[i].Blur()
 				}
-				m.inputs[i].Blur()
-				m.inputs[i].Prompt = "  "
 			}
 			return m, tea.Batch(cmds...)
 		}
 	}
-	cmd := m.updateInputs(msg)
-	return m, cmd
-}
 
-func (m *loginModel) updateInputs(msg tea.Msg) tea.Cmd {
-	cmds := make([]tea.Cmd, len(m.inputs))
+	// 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)
 	}
-	return tea.Batch(cmds...)
+	return m, tea.Batch(cmds...)
 }
 
-func (m loginModel) View() string {
-	var b strings.Builder
-	b.WriteString(InfoStyle.Render(" Welcome to email-cli! Please configure your email account. ") + "\n\n")
-	for i := range m.inputs {
-		b.WriteString(m.inputs[i].View())
-		if i < len(m.inputs)-1 {
-			b.WriteRune('\n')
-		}
-	}
-	button := "\n\n" + "[ Submit ]"
-	if m.focusIndex == len(m.inputs) {
-		button = "\n\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("[ Submit ]")
-	}
-	b.WriteString(button)
-	if m.err != nil {
-		b.WriteString("\n\nError: " + m.err.Error())
-	}
-	b.WriteString(HelpStyle.Render("\n\n(tab to navigate, enter to submit, esc to quit)"))
-	return DialogBoxStyle.Render(b.String())
-}
-
-// RunLogin starts the login UI and returns the created config.
-func RunLogin() (*config.Config, error) {
-	p := tea.NewProgram(initialLoginModel())
-	m, err := p.Run()
-	if err != nil {
-		return nil, err
-	}
-	finalModel := m.(loginModel)
-	return finalModel.config, nil
+// View renders the login form.
+func (m Login) View() string {
+	return lipgloss.JoinVertical(lipgloss.Left,
+		titleStyle.Render("Welcome to Email CLI"),
+		"Please enter your credentials.",
+		m.inputs[0].View(),
+		m.inputs[1].View(),
+		helpStyle.Render("\nenter: submit • tab: next field • esc: quit"),
+	)
 }

tui/messages.go 🔗

@@ -2,23 +2,42 @@ package tui
 
 import "github.com/andrinoff/email-cli/fetcher"
 
-// A message to tell the main model to switch to the inbox view.
-type GoToInboxMsg struct{}
-
-// A message to tell the main model to switch to the composer view.
-type GoToSendMsg struct{}
-
-// A message containing a fetched email to be viewed.
+// A message to view an email.
 type ViewEmailMsg struct {
-	Email fetcher.Email
+	Index int
 }
 
-// A message with the content of an email to be sent.
+// A message to indicate that an email has been sent.
 type SendEmailMsg struct {
 	To      string
 	Subject string
 	Body    string
 }
 
-// A message to indicate that an email has been successfully sent.
-type EmailSentMsg struct{}
+// A message to indicate that the user has entered their credentials.
+type Credentials struct {
+	Email    string
+	Password string
+}
+
+// A message to indicate that the user has chosen a service.
+type ChooseServiceMsg struct {
+	Service string
+}
+
+// EmailResultMsg is sent after an email sending attempt.
+// If Err is not nil, the email failed to send.
+type EmailResultMsg struct {
+	Err error
+}
+
+// ClearStatusMsg is sent to clear the status message from the view.
+type ClearStatusMsg struct{}
+
+// A message containing the fetched emails.
+type EmailsFetchedMsg struct {
+	Emails []fetcher.Email
+}
+
+// A message to indicate that an error occurred while fetching emails.
+type FetchErr error