feat: attachment viewer + esc confirmation (#28, #26)

drew created

Change summary

fetcher/fetcher.go |  71 +++++++++++++------
main.go            | 174 ++++++++++++++++++++++++++++++-----------------
tui/choice.go      |  57 ++++++--------
tui/composer.go    |  57 +++++++++++++--
tui/email_view.go  | 105 ++++++++++++++++++++++++----
tui/messages.go    |  31 +++++++-
6 files changed, 349 insertions(+), 146 deletions(-)

Detailed changes

fetcher/fetcher.go 🔗

@@ -1,6 +1,7 @@
 package fetcher
 
 import (
+	"encoding/base64"
 	"fmt"
 	"io"
 	"io/ioutil"
@@ -17,15 +18,22 @@ import (
 	"golang.org/x/text/transform"
 )
 
+// Attachment holds data for an email attachment.
+type Attachment struct {
+	Filename string
+	Data     []byte
+}
+
 type Email struct {
-	UID        uint32 // Added UID to uniquely identify emails
-	From       string
-	To         []string
-	Subject    string
-	Body       string
-	Date       time.Time
-	MessageID  string
-	References []string
+	UID         uint32
+	From        string
+	To          []string
+	Subject     string
+	Body        string
+	Date        time.Time
+	MessageID   string
+	References  []string
+	Attachments []Attachment
 }
 
 func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
@@ -74,7 +82,6 @@ func decodeHeader(header string) string {
 	return decoded
 }
 
-// connect initializes a connection to the IMAP server.
 func connect(cfg *config.Config) (*client.Client, error) {
 	var imapServer string
 	var imapPort int
@@ -176,6 +183,7 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
 		}
 
 		var body string
+		var attachments []Attachment
 		for {
 			p, err := mr.NextPart()
 			if err == io.EOF {
@@ -185,25 +193,47 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
 				break
 			}
 
+			// Correctly parse Content-Disposition
+			cdHeader := p.Header.Get("Content-Disposition")
+			if cdHeader != "" {
+				disposition, params, err := mime.ParseMediaType(cdHeader)
+				if err == nil && (disposition == "attachment" || disposition == "inline") {
+					filename := params["filename"]
+					if filename != "" {
+						partBody, _ := ioutil.ReadAll(p.Body)
+						encoding := p.Header.Get("Content-Transfer-Encoding")
+						if strings.ToLower(encoding) == "base64" {
+							decoded, decodeErr := base64.StdEncoding.DecodeString(string(partBody))
+							if decodeErr == nil {
+								partBody = decoded
+							}
+						}
+						attachments = append(attachments, Attachment{Filename: filename, Data: partBody})
+						continue // Skip to next part
+					}
+				}
+			}
+
+			// Process body part if not an attachment
 			mediaType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
-			if mediaType == "text/plain" || mediaType == "text/html" {
+			if (mediaType == "text/plain" || mediaType == "text/html") && body == "" {
 				decodedPart, decodeErr := decodePart(p.Body, p.Header)
 				if decodeErr == nil {
 					body = decodedPart
-					break
 				}
 			}
 		}
 
 		emails = append(emails, Email{
-			UID:        msg.Uid,
-			From:       fromAddr,
-			To:         toAddrList,
-			Subject:    subject,
-			Body:       body,
-			Date:       date,
-			MessageID:  messageID,
-			References: strings.Fields(references),
+			UID:         msg.Uid,
+			From:        fromAddr,
+			To:          toAddrList,
+			Subject:     subject,
+			Body:        body,
+			Date:        date,
+			MessageID:   messageID,
+			References:  strings.Fields(references),
+			Attachments: attachments,
 		})
 	}
 
@@ -218,7 +248,6 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
 	return emails, nil
 }
 
-// moveEmail moves an email to a specified destination mailbox.
 func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error {
 	c, err := connect(cfg)
 	if err != nil {
@@ -236,7 +265,6 @@ func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error {
 	return c.UidMove(seqSet, destMailbox)
 }
 
-// DeleteEmail moves an email to the trash folder.
 func DeleteEmail(cfg *config.Config, uid uint32) error {
 	var trashMailbox string
 	switch cfg.ServiceProvider {
@@ -248,7 +276,6 @@ func DeleteEmail(cfg *config.Config, uid uint32) error {
 	return moveEmail(cfg, uid, trashMailbox)
 }
 
-// ArchiveEmail moves an email to the archive folder.
 func ArchiveEmail(cfg *config.Config, uid uint32) error {
 	var archiveMailbox string
 	switch cfg.ServiceProvider {

main.go 🔗

@@ -27,26 +27,28 @@ const (
 )
 
 type mainModel struct {
-	current       tea.Model
-	previousModel tea.Model
-	config        *config.Config
-	emails        []fetcher.Email
-	inbox         *tui.Inbox
-	width         int
-	height        int
-	err           error
+	current        tea.Model
+	previousModel  tea.Model
+	cachedComposer *tui.Composer // To cache a discarded draft
+	config         *config.Config
+	emails         []fetcher.Email
+	inbox          *tui.Inbox
+	width          int
+	height         int
+	err            error
 }
 
 func newInitialModel(cfg *config.Config) *mainModel {
+	// Determine if there is a cached composer to pass to the initial choice view
+	hasCache := false
+	initialModel := &mainModel{}
 	if cfg == nil {
-		return &mainModel{
-			current: tui.NewLogin(),
-		}
-	}
-	return &mainModel{
-		current: tui.NewChoice(),
-		config:  cfg,
+		initialModel.current = tui.NewLogin()
+	} else {
+		initialModel.current = tui.NewChoice(hasCache)
+		initialModel.config = cfg
 	}
+	return initialModel
 }
 
 func (m *mainModel) Init() tea.Cmd {
@@ -57,13 +59,14 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
 	var cmds []tea.Cmd
 
+	m.current, cmd = m.current.Update(msg)
+	cmds = append(cmds, cmd)
+
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
 		m.height = msg.Height
-		m.current, cmd = m.current.Update(msg)
-		cmds = append(cmds, cmd)
-		return m, tea.Batch(cmds...)
+		return m, nil
 
 	case tea.KeyMsg:
 		if msg.String() == "ctrl+c" {
@@ -71,17 +74,35 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		if msg.String() == "esc" {
 			switch m.current.(type) {
-			case *tui.EmailView:
-				m.current = m.inbox
-				return m, nil
 			case *tui.FilePicker:
 				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
-			case *tui.Inbox, *tui.Composer, *tui.Login:
-				m.current = tui.NewChoice()
+			case *tui.Inbox, *tui.Login:
+				m.current = tui.NewChoice(m.cachedComposer != nil)
 				return m, m.current.Init()
 			}
 		}
 
+	case tui.BackToInboxMsg:
+		if m.inbox != nil {
+			m.current = m.inbox
+		} else {
+			m.current = tui.NewChoice(m.cachedComposer != nil)
+		}
+		return m, nil
+
+	case tui.DiscardDraftMsg:
+		m.cachedComposer = msg.ComposerState
+		m.current = tui.NewChoice(true) // Now there is a cached draft
+		return m, m.current.Init()
+
+	case tui.RestoreDraftMsg:
+		if m.cachedComposer != nil {
+			m.current = m.cachedComposer
+			m.cachedComposer.ResetConfirmation()
+			m.cachedComposer = nil // Clear cache after restoring
+			return m, m.current.Init()
+		}
+
 	case tui.Credentials:
 		cfg := &config.Config{
 			ServiceProvider: msg.Provider,
@@ -94,8 +115,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, tea.Quit
 		}
 		m.config = cfg
-		m.current = tui.NewChoice()
-		cmds = append(cmds, m.current.Init())
+		m.current = tui.NewChoice(m.cachedComposer != nil)
+		return m, m.current.Init()
 
 	case tui.GoToInboxMsg:
 		m.current = tui.NewStatus("Fetching emails...")
@@ -106,33 +127,34 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.inbox = tui.NewInbox(m.emails)
 		m.current = m.inbox
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-		cmds = append(cmds, m.current.Init())
+		return m, m.current.Init()
 
 	case tui.FetchMoreEmailsMsg:
-		cmds = append(cmds, func() tea.Msg { return tui.FetchingMoreEmailsMsg{} })
-		cmds = append(cmds, fetchEmails(m.config, paginationLimit, msg.Offset))
-		return m, tea.Batch(cmds...)
+		return m, tea.Batch(
+			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
+			fetchEmails(m.config, paginationLimit, msg.Offset),
+		)
 
 	case tui.EmailsAppendedMsg:
 		m.emails = append(m.emails, msg.Emails...)
-		m.current, cmd = m.current.Update(msg)
-		cmds = append(cmds, cmd)
-		return m, tea.Batch(cmds...)
+		return m, nil
 
 	case tui.GoToSendMsg:
+		// When composing a new email, we discard any previously cached draft.
+		m.cachedComposer = nil
 		m.current = tui.NewComposer(m.config.Email, msg.To, msg.Subject, msg.Body)
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-		cmds = append(cmds, m.current.Init())
+		return m, m.current.Init()
 
 	case tui.GoToSettingsMsg:
 		m.current = tui.NewLogin()
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-		cmds = append(cmds, m.current.Init())
+		return m, m.current.Init()
 
 	case tui.ViewEmailMsg:
 		emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height)
 		m.current = emailView
-		cmds = append(cmds, m.current.Init())
+		return m, m.current.Init()
 
 	case tui.ReplyToEmailMsg:
 		to := msg.Email.From
@@ -140,7 +162,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		body := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))
 		m.current = tui.NewComposer(m.config.Email, to, subject, body)
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
-		cmds = append(cmds, m.current.Init())
+		return m, m.current.Init()
 
 	case tui.GoToFilePickerMsg:
 		m.previousModel = m.current
@@ -149,16 +171,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
-	case tui.FileSelectedMsg:
-		if m.previousModel != nil {
-			m.previousModel, cmd = m.previousModel.Update(msg)
-			cmds = append(cmds, cmd)
-			m.current = m.previousModel
-			m.previousModel = nil
-		}
-		return m, tea.Batch(cmds...)
-
-	case tui.CancelFilePickerMsg:
+	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
 		if m.previousModel != nil {
 			m.current = m.previousModel
 			m.previousModel = nil
@@ -166,30 +179,30 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 
 	case tui.SendEmailMsg:
+		m.cachedComposer = nil // Clear cache on successful send
 		m.current = tui.NewStatus("Sending email...")
-		cmds = append(cmds, m.current.Init(), sendEmail(m.config, msg))
+		return m, tea.Batch(m.current.Init(), sendEmail(m.config, msg))
 
 	case tui.EmailResultMsg:
-		m.current = tui.NewChoice()
-		cmds = append(cmds, m.current.Init())
+		m.current = tui.NewChoice(m.cachedComposer != nil)
+		return m, m.current.Init()
 
 	case tui.DeleteEmailMsg:
+		m.previousModel = m.current
 		m.current = tui.NewStatus("Deleting email...")
-		cmds = append(cmds, m.current.Init(), deleteEmailCmd(m.config, msg.UID))
+		return m, tea.Batch(m.current.Init(), deleteEmailCmd(m.config, msg.UID))
 
 	case tui.ArchiveEmailMsg:
+		m.previousModel = m.current
 		m.current = tui.NewStatus("Archiving email...")
-		cmds = append(cmds, m.current.Init(), archiveEmailCmd(m.config, msg.UID))
+		return m, tea.Batch(m.current.Init(), archiveEmailCmd(m.config, msg.UID))
 
 	case tui.EmailActionDoneMsg:
 		if msg.Err != nil {
-			// In a real app, you might show an error message.
-			// For now, we'll just go back to the inbox.
 			log.Printf("Action failed: %v", msg.Err)
 			m.current = m.inbox
 			return m, nil
 		}
-		// Remove the email from the local cache
 		var updatedEmails []fetcher.Email
 		for _, email := range m.emails {
 			if email.UID != msg.UID {
@@ -197,15 +210,35 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 		m.emails = updatedEmails
-		// Refresh the inbox view
 		m.inbox = tui.NewInbox(m.emails)
 		m.current = m.inbox
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
-	}
 
-	m.current, cmd = m.current.Update(msg)
-	cmds = append(cmds, cmd)
+	case tui.DownloadAttachmentMsg:
+		m.previousModel = m.current
+		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
+		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(msg))
+
+	case tui.AttachmentDownloadedMsg:
+		var statusMsg string
+		if msg.Err != nil {
+			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
+		} else {
+			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
+		}
+		m.current = tui.NewStatus(statusMsg)
+		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
+			return tui.RestoreViewMsg{}
+		})
+
+	case tui.RestoreViewMsg:
+		if m.previousModel != nil {
+			m.current = m.previousModel
+			m.previousModel = nil
+		}
+		return m, nil
+	}
 
 	return m, tea.Batch(cmds...)
 }
@@ -216,11 +249,7 @@ func (m *mainModel) View() string {
 
 func markdownToHTML(md []byte) []byte {
 	var buf bytes.Buffer
-	p := goldmark.New(
-		goldmark.WithRendererOptions(
-			html.WithUnsafe(),
-		),
-	)
+	p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
 	if err := p.Convert(md, &buf); err != nil {
 		return md
 	}
@@ -266,7 +295,6 @@ func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
 			log.Printf("Failed to send email: %v", err)
 			return tui.EmailResultMsg{Err: err}
 		}
-		time.Sleep(1 * time.Second)
 		return tui.EmailResultMsg{}
 	}
 }
@@ -298,6 +326,24 @@ func archiveEmailCmd(cfg *config.Config, uid uint32) tea.Cmd {
 	}
 }
 
+func downloadAttachmentCmd(msg tui.DownloadAttachmentMsg) tea.Cmd {
+	return func() tea.Msg {
+		homeDir, err := os.UserHomeDir()
+		if err != nil {
+			return tui.AttachmentDownloadedMsg{Err: err}
+		}
+		downloadsPath := filepath.Join(homeDir, "Downloads")
+		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
+			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
+				return tui.AttachmentDownloadedMsg{Err: mkErr}
+			}
+		}
+		filePath := filepath.Join(downloadsPath, msg.Filename)
+		err = os.WriteFile(filePath, msg.Data, 0644)
+		return tui.AttachmentDownloadedMsg{Path: filePath, Err: err}
+	}
+}
+
 func main() {
 	cfg, err := config.LoadConfig()
 	var initialModel *mainModel

tui/choice.go 🔗

@@ -8,34 +8,30 @@ import (
 	"github.com/charmbracelet/lipgloss"
 )
 
+// Styles defined locally to avoid import issues.
 var (
-	// A more beautiful style for the main menu
-	docStyle = lipgloss.NewStyle().Margin(1, 2)
-
-	titleStyle = lipgloss.NewStyle().
-			Foreground(lipgloss.Color("#FFFDF5")).
-			Background(lipgloss.Color("#25A065")).
-			Padding(0, 1)
-
-	helpStyle = lipgloss.NewStyle().
-			Foreground(lipgloss.Color("241"))
-
-	// Styling for the choice list
-	listHeader = lipgloss.NewStyle().
-			Foreground(lipgloss.Color("241")).
-			PaddingBottom(1)
-
-	// Custom item styles
+	docStyle          = lipgloss.NewStyle().Margin(1, 2)
+	titleStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFDF5")).Background(lipgloss.Color("#25A065")).Padding(0, 1)
+	listHeader        = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).PaddingBottom(1)
 	itemStyle         = lipgloss.NewStyle().PaddingLeft(2)
 	selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("205"))
 )
 
 type Choice struct {
-	cursor int
+	cursor         int
+	choices        []string
+	hasCachedDraft bool
 }
 
-func NewChoice() Choice {
-	return Choice{cursor: 0}
+func NewChoice(hasCachedDraft bool) Choice {
+	choices := []string{"View Inbox", "Compose Email", "Settings"}
+	if hasCachedDraft {
+		choices = append(choices, "Restore Draft")
+	}
+	return Choice{
+		choices:        choices,
+		hasCachedDraft: hasCachedDraft,
+	}
 }
 
 func (m Choice) Init() tea.Cmd {
@@ -51,17 +47,20 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.cursor--
 			}
 		case "down", "j":
-			if m.cursor < 2 { // We have three choices now
+			if m.cursor < len(m.choices)-1 {
 				m.cursor++
 			}
 		case "enter":
-			switch m.cursor {
-			case 0:
+			selectedChoice := m.choices[m.cursor]
+			switch selectedChoice {
+			case "View Inbox":
 				return m, func() tea.Msg { return GoToInboxMsg{} }
-			case 1:
+			case "Compose Email":
 				return m, func() tea.Msg { return GoToSendMsg{} }
-			case 2:
+			case "Settings":
 				return m, func() tea.Msg { return GoToSettingsMsg{} }
+			case "Restore Draft":
+				return m, func() tea.Msg { return RestoreDraftMsg{} }
 			}
 		}
 	}
@@ -71,16 +70,11 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (m Choice) View() string {
 	var b strings.Builder
 
-	// Title
 	b.WriteString(titleStyle.Render("Email CLI") + "\n\n")
-
-	// Header
 	b.WriteString(listHeader.Render("What would you like to do?"))
 	b.WriteString("\n\n")
 
-	// Choices
-	choices := []string{"View Inbox", "Compose Email", "Settings"}
-	for i, choice := range choices {
+	for i, choice := range m.choices {
 		if m.cursor == i {
 			b.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", choice)))
 		} else {
@@ -89,7 +83,6 @@ func (m Choice) View() string {
 		b.WriteString("\n")
 	}
 
-	// Help
 	b.WriteString("\n\n")
 	b.WriteString(helpStyle.Render("Use ↑/↓ to navigate, enter to select, and q to quit."))
 

tui/composer.go 🔗

@@ -2,6 +2,7 @@ package tui
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/charmbracelet/bubbles/textarea"
 	"github.com/charmbracelet/bubbles/textinput"
@@ -15,10 +16,11 @@ var (
 	blurredStyle        = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 	cursorStyle         = focusedStyle.Copy()
 	noStyle             = lipgloss.NewStyle()
+	helpStyle           = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
 	focusedButton       = focusedStyle.Copy().Render("[ Send ]")
 	blurredButton       = blurredStyle.Copy().Render("[ Send ]")
 	emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
-	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240"))
+	attachmentStyle     = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) // This was the missing style
 )
 
 // Composer model holds the state of the email composition UI.
@@ -29,6 +31,9 @@ type Composer struct {
 	bodyInput      textarea.Model
 	attachmentPath string
 	fromAddr       string
+	width          int
+	height         int
+	confirmingExit bool
 }
 
 // NewComposer initializes a new composer model.
@@ -61,6 +66,11 @@ func NewComposer(from, to, subject, body string) *Composer {
 	return m
 }
 
+// ResetConfirmation ensures a restored draft isn't stuck in the exit prompt.
+func (m *Composer) ResetConfirmation() {
+	m.confirmingExit = false
+}
+
 func (m *Composer) Init() tea.Cmd {
 	return textinput.Blink
 }
@@ -71,6 +81,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		m.height = msg.Height
 		inputWidth := msg.Width - 6
 		m.toInput.Width = inputWidth
 		m.subjectInput.Width = inputWidth
@@ -85,9 +97,24 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 
 	case tea.KeyMsg:
+		if m.confirmingExit {
+			switch msg.String() {
+			case "y", "Y":
+				return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} }
+			case "n", "N", "esc":
+				m.confirmingExit = false
+				return m, nil
+			default:
+				return m, nil
+			}
+		}
+
 		switch msg.Type {
 		case tea.KeyCtrlC:
 			return m, tea.Quit
+		case tea.KeyEsc:
+			m.confirmingExit = true
+			return m, nil
 
 		case tea.KeyTab, tea.KeyShiftTab:
 			if msg.Type == tea.KeyShiftTab {
@@ -96,7 +123,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.focusIndex++
 			}
 
-			if m.focusIndex > 4 { // 4 is the Send button
+			if m.focusIndex > 4 {
 				m.focusIndex = 0
 			} else if m.focusIndex < 0 {
 				m.focusIndex = 4
@@ -118,10 +145,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, tea.Batch(cmds...)
 
 		case tea.KeyEnter:
-			if m.focusIndex == 3 { // Attachment field focused
+			if m.focusIndex == 3 {
 				return m, func() tea.Msg { return GoToFilePickerMsg{} }
 			}
-			if m.focusIndex == 4 { // Send button focused
+			if m.focusIndex == 4 {
 				return m, func() tea.Msg {
 					return SendEmailMsg{
 						To:             m.toInput.Value(),
@@ -150,8 +177,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (m *Composer) View() string {
+	var composerView strings.Builder
 	var button string
-	if m.focusIndex == 4 { // Send button
+
+	if m.focusIndex == 4 {
 		button = focusedButton
 	} else {
 		button = blurredButton
@@ -163,13 +192,13 @@ func (m *Composer) View() string {
 		attachmentText = m.attachmentPath
 	}
 
-	if m.focusIndex == 3 { // Attachment field
+	if m.focusIndex == 3 {
 		attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText))
 	} else {
 		attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachment: %s", attachmentText))
 	}
 
-	return lipgloss.JoinVertical(lipgloss.Left,
+	composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left,
 		"Compose New Email",
 		"From: "+emailRecipientStyle.Render(m.fromAddr),
 		m.toInput.View(),
@@ -178,5 +207,17 @@ func (m *Composer) View() string {
 		attachmentStyle.Render(attachmentField),
 		button,
 		helpStyle.Render("Markdown/HTML • tab: next field • esc: back to menu"),
-	)
+	))
+
+	if m.confirmingExit {
+		dialog := DialogBoxStyle.Render(
+			lipgloss.JoinVertical(lipgloss.Center,
+				"Discard draft?",
+				HelpStyle.Render("\n(y/n)"),
+			),
+		)
+		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
+	}
+
+	return composerView.String()
 }

tui/email_view.go 🔗

@@ -2,6 +2,7 @@ package tui
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/andrinoff/email-cli/fetcher"
 	"github.com/andrinoff/email-cli/view"
@@ -11,15 +12,15 @@ import (
 )
 
 var (
-	emailHeaderStyle = lipgloss.NewStyle().
-				BorderStyle(lipgloss.NormalBorder()).
-				BorderBottom(true).
-				Padding(0, 1)
+	emailHeaderStyle   = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Padding(0, 1)
+	attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1)
 )
 
 type EmailView struct {
-	viewport viewport.Model
-	email    fetcher.Email
+	viewport           viewport.Model
+	email              fetcher.Email
+	attachmentCursor   int
+	focusOnAttachments bool
 }
 
 func NewEmailView(email fetcher.Email, width, height int) *EmailView {
@@ -29,9 +30,15 @@ func NewEmailView(email fetcher.Email, width, height int) *EmailView {
 	}
 
 	header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject)
-	headerHeight := lipgloss.Height(header) + 2 // Account for padding and border
+	headerHeight := lipgloss.Height(header) + 2
 
-	vp := viewport.New(width, height-headerHeight)
+	// Calculate height for attachments if they exist
+	attachmentHeight := 0
+	if len(email.Attachments) > 0 {
+		attachmentHeight = len(email.Attachments) + 2 // +2 for title and border
+	}
+
+	vp := viewport.New(width, height-headerHeight-attachmentHeight)
 	vp.SetContent(body)
 
 	return &EmailView{
@@ -46,27 +53,93 @@ func (m *EmailView) Init() tea.Cmd {
 
 func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmd tea.Cmd
+	var cmds []tea.Cmd
+
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
-		switch msg.String() {
-		case "r":
-			return m, func() tea.Msg {
-				return ReplyToEmailMsg{Email: m.email}
+		// Handle 'esc' key locally
+		if msg.Type == tea.KeyEsc {
+			if m.focusOnAttachments {
+				m.focusOnAttachments = false
+				return m, nil
+			}
+			return m, func() tea.Msg { return BackToInboxMsg{} }
+		}
+
+		if m.focusOnAttachments {
+			switch msg.String() {
+			case "up", "k":
+				if m.attachmentCursor > 0 {
+					m.attachmentCursor--
+				}
+			case "down", "j":
+				if m.attachmentCursor < len(m.email.Attachments)-1 {
+					m.attachmentCursor++
+				}
+			case "enter":
+				if len(m.email.Attachments) > 0 {
+					selected := m.email.Attachments[m.attachmentCursor]
+					return m, func() tea.Msg {
+						return DownloadAttachmentMsg{Filename: selected.Filename, Data: selected.Data}
+					}
+				}
+			case "tab":
+				m.focusOnAttachments = false
+			}
+		} else {
+			switch msg.String() {
+			case "r":
+				return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
+			case "tab":
+				if len(m.email.Attachments) > 0 {
+					m.focusOnAttachments = true
+				}
 			}
 		}
 	case tea.WindowSizeMsg:
 		header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject)
 		headerHeight := lipgloss.Height(header) + 2
+		attachmentHeight := 0
+		if len(m.email.Attachments) > 0 {
+			attachmentHeight = len(m.email.Attachments) + 2
+		}
 		m.viewport.Width = msg.Width
-		m.viewport.Height = msg.Height - headerHeight
+		m.viewport.Height = msg.Height - headerHeight - attachmentHeight
 	}
+
 	m.viewport, cmd = m.viewport.Update(msg)
-	return m, cmd
+	cmds = append(cmds, cmd)
+
+	return m, tea.Batch(cmds...)
 }
 
 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)
-	help := helpStyle.Render("r: reply • esc: back to inbox")
-	return fmt.Sprintf("%s\n%s\n%s", styledHeader, m.viewport.View(), help)
+
+	var help string
+	if m.focusOnAttachments {
+		help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body")
+	} else {
+		help = helpStyle.Render("r: reply • tab: focus attachments • esc: back to inbox")
+	}
+
+	var attachmentView string
+	if len(m.email.Attachments) > 0 {
+		var b strings.Builder
+		b.WriteString("Attachments:\n")
+		for i, attachment := range m.email.Attachments {
+			cursor := "  "
+			style := itemStyle
+			if m.focusOnAttachments && i == m.attachmentCursor {
+				cursor = "> "
+				style = selectedItemStyle
+			}
+			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
+			b.WriteString("\n")
+		}
+		attachmentView = attachmentBoxStyle.Render(b.String())
+	}
+
+	return fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help)
 }

tui/messages.go 🔗

@@ -72,8 +72,6 @@ type FileSelectedMsg struct {
 
 type CancelFilePickerMsg struct{}
 
-// --- Email Action Messages ---
-
 type DeleteEmailMsg struct {
 	UID uint32
 }
@@ -82,8 +80,33 @@ type ArchiveEmailMsg struct {
 	UID uint32
 }
 
-// EmailActionDoneMsg reports the result of an action like delete or archive.
 type EmailActionDoneMsg struct {
 	UID uint32
 	Err error
-}
+}
+
+type GoToChoiceMenuMsg struct{}
+
+type DownloadAttachmentMsg struct {
+	Filename string
+	Data     []byte
+}
+
+type AttachmentDownloadedMsg struct {
+	Path string
+	Err  error
+}
+
+type RestoreViewMsg struct{}
+
+type BackToInboxMsg struct{}
+
+// --- Draft Messages ---
+
+// DiscardDraftMsg signals that a draft should be cached.
+type DiscardDraftMsg struct {
+	ComposerState *Composer
+}
+
+// RestoreDraftMsg signals that the cached draft should be restored.
+type RestoreDraftMsg struct{}