fix: turned the send_mail back up

drew created

Change summary

main.go           | 226 ++++++++++++++++--------------------------------
tui/choice.go     |  95 +++++++------------
tui/composer.go   | 199 +++++++++++++++++-------------------------
tui/email_view.go |  64 +++++++++++++
tui/inbox.go      | 168 ++++++++++++++++-------------------
tui/messages.go   |  24 +++++
tui/styles.go     |  37 +++++++
7 files changed, 393 insertions(+), 420 deletions(-)

Detailed changes

main.go 🔗

@@ -2,199 +2,121 @@ package main
 
 import (
 	"fmt"
+	"log"
 	"os"
+	"time"
 
 	"github.com/andrinoff/email-cli/config"
-	"github.com/andrinoff/email-cli/fetcher"
-	"github.com/andrinoff/email-cli/view" // Updated package
-	"github.com/charmbracelet/bubbles/list"
-	"github.com/charmbracelet/bubbles/spinner"
-	"github.com/charmbracelet/bubbles/viewport" // Import viewport
+	"github.com/andrinoff/email-cli/sender"
+	"github.com/andrinoff/email-cli/tui"
 	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
 )
 
-var (
-	appStyle = lipgloss.NewStyle().Padding(1, 2)
-	headerStyle = lipgloss.NewStyle().
-			BorderStyle(lipgloss.NormalBorder()).
-			BorderBottom(true)
-)
-
-// emailItem wraps fetcher.Email to satisfy the list.Item interface.
-type emailItem struct {
-	fetcher.Email
-}
-
-func (e emailItem) Title() string       { return e.Subject }
-func (e emailItem) Description() string { return fmt.Sprintf("From: %s", e.From) }
-func (e emailItem) FilterValue() string { return e.Subject }
-
-type model struct {
-	list         list.Model
-	spinner      spinner.Model
-	viewport     viewport.Model
-	state        viewState
-	selectedItem emailItem
-	err          error
+// 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 viewState int
-
-const (
-	choosingState viewState = iota
-	loadingState
-	inboxState
-	emailViewState
-)
-
-// Messages for tea.Cmd
-type (
-	emailsFetchedMsg []fetcher.Email
-	errMsg           struct{ err error }
-)
-
-func (e errMsg) Error() string { return e.err.Error() }
-
-func newModel() *model {
-	s := spinner.New()
-	s.Spinner = spinner.Dot
-	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
-
-	initialItems := []list.Item{
-		emailItem{fetcher.Email{Subject: "View Inbox"}},
-		emailItem{fetcher.Email{Subject: "Send Email (coming soon...)"}},
-	}
-
-	m := &model{
-		spinner:  s,
-		list:     list.New(initialItems, list.NewDefaultDelegate(), 0, 0),
-		state:    choosingState,
-		viewport: viewport.New(10, 10), // Initial size
+// newInitialModel now returns a pointer, which is crucial for state management.
+func newInitialModel(cfg *config.Config) *mainModel {
+	return &mainModel{
+		current: tui.NewChoice(),
+		config:  cfg,
 	}
-	m.list.Title = "Email CLI"
-	m.list.SetShowStatusBar(false)
-	return m
 }
 
-func (m *model) Init() tea.Cmd {
-	return nil
+func (m *mainModel) Init() tea.Cmd {
+	return m.current.Init()
 }
 
-func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
 
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		listHeight := msg.Height - 2
-		// Adjust viewport size, leaving space for a header
-		viewportHeaderHeight := 4
-		m.list.SetSize(msg.Width-2, listHeight)
-		m.viewport.Width = msg.Width
-		m.viewport.Height = msg.Height - viewportHeaderHeight
+		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:
-		switch msg.String() {
-		case "ctrl+c", "q":
+		if msg.String() == "ctrl+c" {
 			return m, tea.Quit
-		case "esc":
-			if m.state == emailViewState {
-				m.state = inboxState
-			} else if m.state == inboxState {
-				// Reset to initial choice list
-				initialItems := []list.Item{
-					emailItem{fetcher.Email{Subject: "View Inbox"}},
-					emailItem{fetcher.Email{Subject: "Send Email (coming soon...)"}},
-				}
-				m.list.SetItems(initialItems)
-				m.list.Title = "Email CLI"
-				m.state = choosingState
-			}
-			return m, nil
-		case "enter":
-			if m.state == choosingState {
-				selected := m.list.SelectedItem().(emailItem)
-				if selected.Subject == "View Inbox" {
-					m.state = loadingState
-					cmds = append(cmds, m.spinner.Tick, fetchEmails)
-				}
-			} else if m.state == inboxState {
-				m.selectedItem = m.list.SelectedItem().(emailItem)
-				m.state = emailViewState
-
-				// Process body and set viewport content
-				body, err := view.ProcessBody(m.selectedItem.Body)
-				if err != nil {
-					body = fmt.Sprintf("Error rendering body: %v", err)
-				}
-				m.viewport.SetContent(body)
-				m.viewport.GotoTop() // Scroll to top on new email
-			}
 		}
-
-	case emailsFetchedMsg:
-		items := make([]list.Item, len(msg))
-		for i, email := range msg {
-			items[i] = emailItem{fetcher.Email(email)}
+		// 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()
+			}
 		}
-		m.list.SetItems(items)
-		m.list.Title = "Inbox"
-		m.state = inboxState
 
-	case errMsg:
-		m.err = msg
-		return m, tea.Quit
+	// --- 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())
 	}
 
-	// Handle updates for the current state
-	switch m.state {
-	case loadingState:
-		m.spinner, cmd = m.spinner.Update(msg)
-	case inboxState, choosingState:
-		m.list, cmd = m.list.Update(msg)
-	case emailViewState:
-		m.viewport, cmd = m.viewport.Update(msg)
-	}
+	// Pass all other messages to the current view
+	m.current, cmd = m.current.Update(msg)
 	cmds = append(cmds, cmd)
 
 	return m, tea.Batch(cmds...)
 }
 
-func (m *model) View() string {
-	if m.err != nil {
-		return fmt.Sprintf("Error: %v\n", m.err)
-	}
+func (m *mainModel) View() string {
+	return m.current.View()
+}
 
-	switch m.state {
-	case loadingState:
-		return fmt.Sprintf("\n\n   %s Fetching emails...\n\n", m.spinner.View())
-	case emailViewState:
-		header := fmt.Sprintf("From: %s\nSubject: %s", m.selectedItem.From, m.selectedItem.Subject)
-		styledHeader := headerStyle.Width(m.viewport.Width).Render(header)
-		return fmt.Sprintf("%s\n%s", styledHeader, m.viewport.View())
-	default: // choosingState and inboxState
-		return appStyle.Render(m.list.View())
+// 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{}
 	}
 }
 
-func fetchEmails() tea.Msg {
+func main() {
 	cfg, err := config.LoadConfig()
 	if err != nil {
-		return errMsg{err}
-	}
-	emails, err := fetcher.FetchEmails(cfg)
-	if err != nil {
-		return errMsg{err}
+		log.Fatalf("could not load config: %v", err)
 	}
-	return emailsFetchedMsg(emails)
-}
 
-func main() {
-	p := tea.NewProgram(newModel(), tea.WithAltScreen())
+	p := tea.NewProgram(newInitialModel(cfg), tea.WithAltScreen())
 
 	if _, err := p.Run(); err != nil {
-		fmt.Println("Error running program:", err)
+		fmt.Printf("Alas, there's been an error: %v", err)
 		os.Exit(1)
 	}
 }

tui/choice.go 🔗

@@ -1,80 +1,57 @@
 package tui
 
 import (
-	"fmt"
-	"os"
-
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
 )
 
-type choiceModel struct {
-	list   list.Model
-	choice string
+type Choice struct {
+	list list.Model
+}
+
+func NewChoice() *Choice {
+	items := []list.Item{
+		choiceItem("View Inbox"),
+		choiceItem("Send Email"),
+	}
+
+	l := list.New(items, list.NewDefaultDelegate(), 0, 0)
+	l.Title = "What would you like to do?"
+	l.SetShowStatusBar(false)
+	l.SetFilteringEnabled(false)
+
+	return &Choice{list: l}
 }
 
-func (m choiceModel) Init() tea.Cmd {
+type choiceItem string
+
+func (i choiceItem) FilterValue() string { return "" }
+func (i choiceItem) Title() string       { return string(i) }
+func (i choiceItem) Description() string { return "" }
+
+func (m *Choice) Init() tea.Cmd {
 	return nil
 }
 
-func (m choiceModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (m *Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
 	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.list.SetSize(msg.Width, msg.Height)
 	case tea.KeyMsg:
-		switch keypress := msg.String(); keypress {
-		case "ctrl+c", "q":
-			return m, tea.Quit
-
-		case "enter":
-			i, ok := m.list.SelectedItem().(item)
-			if ok {
-				m.choice = string(i.title)
+		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{} }
 			}
-			return m, tea.Quit
 		}
-	case tea.WindowSizeMsg:
-		h, v := DialogBoxStyle.GetFrameSize()
-		m.list.SetSize(msg.Width-h, msg.Height-v)
 	}
-
-	var cmd tea.Cmd
 	m.list, cmd = m.list.Update(msg)
 	return m, cmd
 }
 
-func (m choiceModel) View() string {
-	return DialogBoxStyle.Render(m.list.View())
-}
-
-type item struct {
-	title, desc string
-}
-
-func (i item) Title() string       { return i.title }
-func (i item) Description() string { return i.desc }
-func (i item) FilterValue() string { return i.title }
-
-func RunChoice() (string, error) {
-	items := []list.Item{
-		item{title: "send", desc: "Send a new email"},
-		item{title: "inbox", desc: "View your inbox"},
-	}
-
-	l := list.New(items, list.NewDefaultDelegate(), 0, 0)
-	l.Title = "What would you like to do?"
-	l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
-
-	m := choiceModel{list: l}
-
-	p := tea.NewProgram(m, tea.WithAltScreen())
-
-	mod, err := p.Run()
-	if err != nil {
-		fmt.Println("Error running program:", err)
-		os.Exit(1)
-	}
-	if m, ok := mod.(choiceModel); ok && m.choice != "" {
-		return m.choice, nil
-	}
-	return "", fmt.Errorf("no choice made")
-}
+func (m *Choice) View() string {
+	return DocStyle.Render(m.list.View())
+}

tui/composer.go 🔗

@@ -1,146 +1,113 @@
 package tui
 
 import (
-	"fmt"
-	"strings"
-	"time"
-
-	"github.com/andrinoff/email-cli/config"
-	"github.com/andrinoff/email-cli/sender"
-	"github.com/charmbracelet/bubbles/textarea"
 	"github.com/charmbracelet/bubbles/textinput"
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
 )
 
-type composerModel struct {
-	recipientInput textinput.Model
-	subjectInput   textinput.Model
-	bodyArea       textarea.Model
-	index          int
-	sending        bool
-	sent           bool
-	err            error
-	config         *config.Config
+type Composer struct {
+	focusIndex int
+	inputs     []textinput.Model
+	fromAddr   string
 }
 
-func initialComposerModel(cfg *config.Config) composerModel {
-	ti := textinput.New()
-	ti.Placeholder = "recipient@example.com"
-	ti.Focus()
-	ti.CharLimit = 156
-	ti.Width = 50
-	ti.Prompt = "To:      "
-	si := textinput.New()
-	si.Placeholder = "Hello there!"
-	si.CharLimit = 156
-	si.Width = 50
-	si.Prompt = "Subject: "
-	ta := textarea.New()
-	ta.Placeholder = "Dear friend, ..."
-	ta.SetWidth(50)
-	ta.SetHeight(10)
-	return composerModel{
-		recipientInput: ti,
-		subjectInput:   si,
-		bodyArea:       ta,
-		index:          0,
-		config:         cfg,
-		err:            nil,
+func NewComposer(from string) Composer {
+	m := Composer{
+		inputs:   make([]textinput.Model, 3),
+		fromAddr: from,
+	}
+
+	var t textinput.Model
+	for i := range m.inputs {
+		t = textinput.New()
+		t.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+		t.CharLimit = 0 // no limit
+
+		switch i {
+		case 0:
+			t.Placeholder = "To"
+			t.Focus()
+			t.Prompt = "> "
+		case 1:
+			t.Placeholder = "Subject"
+			t.Prompt = "> "
+		case 2:
+			t.Placeholder = "Body..."
+			t.Prompt = "> "
+		}
+		m.inputs[i] = t
 	}
+	return m
 }
 
-func (m composerModel) Init() tea.Cmd { return textinput.Blink }
+func (m Composer) Init() tea.Cmd {
+	return textinput.Blink
+}
 
-func (m composerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
+func (m Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
-		switch msg.Type {
-		case tea.KeyCtrlC, tea.KeyEsc:
-			return m, tea.Quit
-		case tea.KeyCtrlS:
-			if !m.sending {
-				m.sending = true
-				to := []string{m.recipientInput.Value()}
-				subject := m.subjectInput.Value()
-				body := m.bodyArea.Value()
-				return m, func() tea.Msg {
-					err := sender.SendEmail(m.config, to, subject, body)
-					if err != nil {
-						return err
+		s := msg.String()
+		switch s {
+		case "tab", "shift+tab", "enter", "up", "down":
+			if s == "enter" {
+				// If we're on the last input, send the email
+				if m.focusIndex == len(m.inputs)-1 {
+					return m, func() tea.Msg {
+						return SendEmailMsg{
+							To:      m.inputs[0].Value(),
+							Subject: m.inputs[1].Value(),
+							Body:    m.inputs[2].Value(),
+						}
 					}
-					return "sent"
 				}
 			}
-		case tea.KeyTab, tea.KeyShiftTab:
-			if msg.Type == tea.KeyTab {
-				m.index = (m.index + 1) % 3
+
+			// Cycle focus
+			if s == "up" || s == "shift+tab" {
+				m.focusIndex--
 			} else {
-				m.index--
-				if m.index < 0 {
-					m.index = 2
-				}
+				m.focusIndex++
 			}
-			m.recipientInput.Blur()
-			m.subjectInput.Blur()
-			m.bodyArea.Blur()
-			switch m.index {
-			case 0:
-				m.recipientInput.Focus()
-			case 1:
-				m.subjectInput.Focus()
-			case 2:
-				m.bodyArea.Focus()
+
+			if m.focusIndex > len(m.inputs)-1 {
+				m.focusIndex = 0
+			} else if m.focusIndex < 0 {
+				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()
+				} else {
+					m.inputs[i].Blur()
+				}
 			}
 			return m, tea.Batch(cmds...)
 		}
-	case string:
-		if msg == "sent" {
-			m.sending = false
-			m.sent = true
-			return m, tea.Sequence(func() tea.Msg { time.Sleep(time.Second); return nil }, tea.Quit)
-		}
-	case error:
-		m.sending = false
-		m.err = msg
-		return m, nil
 	}
-	var cmd tea.Cmd
-	switch m.index {
-	case 0:
-		m.recipientInput, cmd = m.recipientInput.Update(msg)
-	case 1:
-		m.subjectInput, cmd = m.subjectInput.Update(msg)
-	case 2:
-		m.bodyArea, cmd = m.bodyArea.Update(msg)
-	}
-	cmds = append(cmds, cmd)
-	return m, tea.Batch(cmds...)
+
+	// Update the focused input
+	cmd := m.updateInputs(msg)
+	return m, cmd
 }
 
-func (m composerModel) View() string {
-	if m.err != nil {
-		return InfoStyle.Render(fmt.Sprintf("\n   Error: %v \n\n Press any key to exit.", m.err))
-	}
-	if m.sent {
-		return SuccessStyle.Render("\n   ✓ Email sent successfully! \n\n")
-	}
-	if m.sending {
-		return InfoStyle.Render("\n   Sending email... \n\n")
+func (m *Composer) updateInputs(msg tea.Msg) tea.Cmd {
+	var cmds = make([]tea.Cmd, len(m.inputs))
+	for i := range m.inputs {
+		m.inputs[i], cmds[i] = m.inputs[i].Update(msg)
 	}
-	var s strings.Builder
-	s.WriteString("\n")
-	s.WriteString(m.recipientInput.View() + "\n")
-	s.WriteString(m.subjectInput.View() + "\n")
-	s.WriteString(m.bodyArea.View())
-	help := fmt.Sprintf("\n\n %s | %s | %s \n", "Tab: Next Field", "Esc: Quit", "Ctrl+S: Send")
-	s.WriteString(HelpStyle.Render(help))
-	return DialogBoxStyle.Render(s.String())
+	return tea.Batch(cmds...)
 }
 
-// RunComposer starts the email composer UI.
-func RunComposer(cfg *config.Config) error {
-	p := tea.NewProgram(initialComposerModel(cfg))
-	_, err := p.Run()
-	return err
+func (m Composer) View() string {
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		"Compose Email (Press Enter on Body to Send)",
+		m.inputs[0].View(),
+		m.inputs[1].View(),
+		m.inputs[2].View(),
+	)
 }

tui/email_view.go 🔗

@@ -0,0 +1,64 @@
+package tui
+
+import (
+	"fmt"
+
+	"github.com/andrinoff/email-cli/fetcher"
+	"github.com/andrinoff/email-cli/view"
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+var (
+	emailHeaderStyle = lipgloss.NewStyle().
+				BorderStyle(lipgloss.NormalBorder()).
+				BorderBottom(true).
+				Padding(0, 1)
+)
+
+type EmailView struct {
+	viewport viewport.Model
+	email    fetcher.Email
+}
+
+func NewEmailView(email fetcher.Email, width, height int) *EmailView {
+	body, err := view.ProcessBody(email.Body)
+	if err != nil {
+		body = fmt.Sprintf("Error rendering body: %v", err)
+	}
+
+	header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject)
+	headerHeight := lipgloss.Height(header) + 2 // Account for padding and border
+
+	vp := viewport.New(width, height-headerHeight)
+	vp.SetContent(body)
+
+	return &EmailView{
+		viewport: vp,
+		email:    email,
+	}
+}
+
+func (m *EmailView) Init() tea.Cmd {
+	return nil
+}
+
+func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject)
+		headerHeight := lipgloss.Height(header) + 2
+		m.viewport.Width = msg.Width
+		m.viewport.Height = msg.Height - headerHeight
+	}
+	m.viewport, cmd = m.viewport.Update(msg)
+	return m, cmd
+}
+
+func (m *EmailView) View() string {
+	header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject)
+	styledHeader := emailHeaderStyle.Width(m.viewport.Width).Render(header)
+	return fmt.Sprintf("%s\n%s", styledHeader, m.viewport.View())
+}

tui/inbox.go 🔗

@@ -1,10 +1,7 @@
-
-
 package tui
 
 import (
 	"fmt"
-	"strings"
 
 	"github.com/andrinoff/email-cli/config"
 	"github.com/andrinoff/email-cli/fetcher"
@@ -14,117 +11,106 @@ import (
 	"github.com/charmbracelet/lipgloss"
 )
 
-type inboxModel struct {
-	spinner  spinner.Model
-	loading  bool
-	emails   []fetcher.Email
-	list     list.Model
-	err      error
-	config   *config.Config
-	selected bool
+// inboxItem wraps fetcher.Email to satisfy the list.Item interface.
+type inboxItem struct {
+	fetcher.Email
 }
 
-func (m inboxModel) Init() tea.Cmd {
-	return m.spinner.Tick
+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 }
+
+type Inbox struct {
+	list    list.Model
+	spinner spinner.Model
+	loading bool
+	err     error
 }
 
-func (m inboxModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func NewInbox() *Inbox {
+	s := spinner.New()
+	s.Spinner = spinner.Dot
+	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+
+	// Create an empty list for now. It will be populated later.
+	l := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
+	l.Title = "Inbox"
+
+	return &Inbox{
+		spinner: s,
+		list:    l,
+		loading: true,
+	}
+}
+
+func (m *Inbox) Init() tea.Cmd {
+	return tea.Batch(m.spinner.Tick, fetchEmails)
+}
+
+func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
 	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		if msg.String() == "ctrl+c" || msg.String() == "q" {
-			return m, tea.Quit
-		}
-		if msg.String() == "enter" {
-			m.selected = true
-			return m, nil
+	case spinner.TickMsg:
+		if m.loading {
+			m.spinner, cmd = m.spinner.Update(msg)
 		}
-	case tea.WindowSizeMsg:
-		h, v := DialogBoxStyle.GetFrameSize()
-		m.list.SetSize(msg.Width-h, msg.Height-v)
-	case []fetcher.Email:
+		return m, cmd
+
+	case emailsFetchedMsg:
 		m.loading = false
-		m.emails = msg
-		items := make([]list.Item, len(m.emails))
-		for i, email := range m.emails {
-			items[i] = item{
-				title: email.Subject,
-				desc:  fmt.Sprintf("From: %s", email.From),
-			}
+		items := make([]list.Item, len(msg))
+		for i, email := range msg {
+			items[i] = inboxItem{email}
 		}
 		m.list.SetItems(items)
-
-	case error:
-		m.err = msg
 		return m, nil
+
+	case errMsg:
+		m.err = msg.err
+		return m, tea.Quit
+
+	case tea.KeyMsg:
+		if msg.String() == "enter" && !m.loading {
+			selected := m.list.SelectedItem().(inboxItem)
+			return m, func() tea.Msg {
+				return ViewEmailMsg{Email: selected.Email}
+			}
+		}
+
+	case tea.WindowSizeMsg:
+		// When the window size changes, update the list's dimensions.
+		m.list.SetSize(msg.Width, msg.Height)
 	}
 
-	var cmd tea.Cmd
-	if m.loading {
-		m.spinner, cmd = m.spinner.Update(msg)
-	} else {
+	if !m.loading {
 		m.list, cmd = m.list.Update(msg)
 	}
 	return m, cmd
 }
 
-func (m inboxModel) View() string {
+func (m *Inbox) View() string {
 	if m.err != nil {
-		return InfoStyle.Render(fmt.Sprintf("\n   Error: %v \n\n Press any key to exit.", m.err))
+		return fmt.Sprintf("Error: %v", m.err)
 	}
 	if m.loading {
-		return InfoStyle.Render(fmt.Sprintf("\n   %s Fetching emails... \n\n", m.spinner.View()))
-	}
-	if m.selected {
-		i, ok := m.list.SelectedItem().(item)
-		if !ok {
-			return "error"
-		}
-		var selectedEmail fetcher.Email
-		for _, email := range m.emails {
-			if email.Subject == i.Title() {
-				selectedEmail = email
-				break
-			}
-		}
-		var s strings.Builder
-		s.WriteString(fmt.Sprintf("From: %s\n", selectedEmail.From))
-		s.WriteString(fmt.Sprintf("Subject: %s\n", selectedEmail.Subject))
-		s.WriteString(fmt.Sprintf("Date: %s\n\n", selectedEmail.Date.Format("2006-01-02 15:04:05")))
-		s.WriteString(selectedEmail.Body)
-		s.WriteString(HelpStyle.Render("\n\n(q to quit)"))
-		return DialogBoxStyle.Render(s.String())
-
-	}
-	return DialogBoxStyle.Render(m.list.View())
-}
-
-func initialInboxModel(cfg *config.Config) inboxModel {
-	s := spinner.New()
-	s.Spinner = spinner.Dot
-	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
-	l := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
-	l.Title = "Inbox"
-	l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
-	return inboxModel{
-		spinner: s,
-		loading: true,
-		config:  cfg,
-		list:    l,
+		return fmt.Sprintf("\n\n   %s Fetching emails...\n\n", m.spinner.View())
 	}
+	return DocStyle.Render(m.list.View())
 }
 
-func RunInbox(cfg *config.Config) error {
-	m := initialInboxModel(cfg)
-	p := tea.NewProgram(m, tea.WithAltScreen())
+// --- Bubble Tea Commands and Messages ---
 
-	go func() {
-		emails, err := fetcher.FetchEmails(cfg)
-		if err != nil {
-			p.Send(err)
-		}
-		p.Send(emails)
-	}()
+type emailsFetchedMsg []fetcher.Email
+type errMsg struct{ err error }
 
-	_, err := p.Run()
-	return err
+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)
 }

tui/messages.go 🔗

@@ -0,0 +1,24 @@
+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.
+type ViewEmailMsg struct {
+	Email fetcher.Email
+}
+
+// A message with the content of an email to be sent.
+type SendEmailMsg struct {
+	To      string
+	Subject string
+	Body    string
+}
+
+// A message to indicate that an email has been successfully sent.
+type EmailSentMsg struct{}

tui/styles.go 🔗

@@ -1,6 +1,12 @@
 package tui
 
-import "github.com/charmbracelet/lipgloss"
+import (
+	"fmt"
+
+	"github.com/charmbracelet/bubbles/spinner"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
 
 var (
 	DialogBoxStyle = lipgloss.NewStyle().
@@ -15,4 +21,31 @@ var (
 	HelpStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 	SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
 	InfoStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
-)
+)
+
+var DocStyle = lipgloss.NewStyle().Margin(1, 2)
+
+// A simple model for showing a status message
+type Status struct {
+    spinner spinner.Model
+    message string
+}
+
+func NewStatus(msg string) Status {
+    s := spinner.New()
+    s.Spinner = spinner.Dot
+    s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+    return Status{spinner: s, message: msg}
+}
+
+func (m Status) Init() tea.Cmd { return m.spinner.Tick }
+
+func (m Status) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	m.spinner, cmd = m.spinner.Update(msg)
+	return m, cmd
+}
+
+func (m Status) View() string {
+	return fmt.Sprintf("\n\n   %s %s\n\n", m.spinner.View(), m.message)
+}