v0.5.0

drew created

features: 

#24, #25, #26, #27, #28

Fix: 

test for composer

Change summary

fetcher/fetcher.go   | 129 +++++++++++++++++++----
main.go              | 255 +++++++++++++++++++++++++++++++++------------
sender/sender.go     |  79 ++++++++++---
tui/choice.go        |  57 ++++-----
tui/composer.go      | 123 +++++++++++++++++-----
tui/composer_test.go |  46 ++++---
tui/email_view.go    | 103 ++++++++++++++++-
tui/filepicker.go    | 125 ++++++++++++++++++++++
tui/inbox.go         |  53 +++++---
tui/messages.go      |  85 +++++++++++---
10 files changed, 810 insertions(+), 245 deletions(-)

Detailed changes

fetcher/fetcher.go 🔗

@@ -1,6 +1,7 @@
 package fetcher
 
 import (
+	"encoding/base64"
 	"fmt"
 	"io"
 	"io/ioutil"
@@ -17,15 +18,24 @@ import (
 	"golang.org/x/text/transform"
 )
 
+// Attachment holds data for an email attachment.
+type Attachment struct {
+	Filename string
+	Data     []byte
+}
+
 type Email struct {
-	From    string
-	To      []string
-	Subject string
-	Body    string
-	Date    time.Time
+	UID         uint32
+	From        string
+	To          []string
+	Subject     string
+	Body        string
+	Date        time.Time
+	MessageID   string
+	References  []string
+	Attachments []Attachment
 }
 
-// ... (decodePart and decodeHeader functions remain the same)
 func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
 	mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
 	if err != nil {
@@ -33,7 +43,7 @@ func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
 		return string(body), nil
 	}
 
-	charset := "utf-8" // Default charset
+	charset := "utf-8"
 	if params["charset"] != "" {
 		charset = strings.ToLower(params["charset"])
 	}
@@ -72,8 +82,7 @@ func decodeHeader(header string) string {
 	return decoded
 }
 
-// FetchEmails now supports pagination with a limit and offset.
-func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
+func connect(cfg *config.Config) (*client.Client, error) {
 	var imapServer string
 	var imapPort int
 
@@ -85,7 +94,7 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
 		imapServer = "imap.mail.me.com"
 		imapPort = 993
 	default:
-		return nil, fmt.Errorf("unsupported or missing service_provider in config.json: %s", cfg.ServiceProvider)
+		return nil, fmt.Errorf("unsupported service_provider: %s", cfg.ServiceProvider)
 	}
 
 	addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
@@ -93,20 +102,26 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
 	if err != nil {
 		return nil, err
 	}
-	log.Println("Connected")
-	defer c.Logout()
 
 	if err := c.Login(cfg.Email, cfg.Password); err != nil {
 		return nil, err
 	}
-	log.Println("Logged in")
+
+	return c, nil
+}
+
+func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
+	c, err := connect(cfg)
+	if err != nil {
+		return nil, err
+	}
+	defer c.Logout()
 
 	mbox, err := c.Select("INBOX", false)
 	if err != nil {
 		return nil, err
 	}
 
-	// Handle pagination logic
 	if mbox.Messages == 0 {
 		return []Email{}, nil
 	}
@@ -118,7 +133,7 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
 	}
 
 	if to < 1 {
-		return []Email{}, nil // No more messages to fetch
+		return []Email{}, nil
 	}
 
 	seqset := new(imap.SeqSet)
@@ -126,8 +141,9 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
 
 	messages := make(chan *imap.Message, limit)
 	done := make(chan error, 1)
+	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchItem("BODY[]")}
 	go func() {
-		done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchItem("BODY[]")}, messages)
+		done <- c.Fetch(seqset, fetchItems, messages)
 	}()
 
 	var emails []Email
@@ -153,6 +169,8 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
 		toAddrs, _ := header.AddressList("To")
 		subject := decodeHeader(header.Get("Subject"))
 		date, _ := header.Date()
+		messageID := header.Get("Message-ID")
+		references := header.Get("References")
 
 		var fromAddr string
 		if len(fromAddrs) > 0 {
@@ -165,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 {
@@ -174,22 +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{
-			From:    fromAddr,
-			To:      toAddrList,
-			Subject: subject,
-			Body:    body,
-			Date:    date,
+			UID:         msg.Uid,
+			From:        fromAddr,
+			To:          toAddrList,
+			Subject:     subject,
+			Body:        body,
+			Date:        date,
+			MessageID:   messageID,
+			References:  strings.Fields(references),
+			Attachments: attachments,
 		})
 	}
 
@@ -202,4 +246,43 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
 	}
 
 	return emails, nil
+}
+
+func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error {
+	c, err := connect(cfg)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+
+	if _, err := c.Select("INBOX", false); err != nil {
+		return err
+	}
+
+	seqSet := new(imap.SeqSet)
+	seqSet.AddNum(uid)
+
+	return c.UidMove(seqSet, destMailbox)
+}
+
+func DeleteEmail(cfg *config.Config, uid uint32) error {
+	var trashMailbox string
+	switch cfg.ServiceProvider {
+	case "gmail":
+		trashMailbox = "[Gmail]/Trash"
+	default:
+		trashMailbox = "Trash"
+	}
+	return moveEmail(cfg, uid, trashMailbox)
+}
+
+func ArchiveEmail(cfg *config.Config, uid uint32) error {
+	var archiveMailbox string
+	switch cfg.ServiceProvider {
+	case "gmail":
+		archiveMailbox = "[Gmail]/All Mail"
+	default:
+		archiveMailbox = "Archive"
+	}
+	return moveEmail(cfg, uid, archiveMailbox)
 }

main.go 🔗

@@ -26,29 +26,29 @@ const (
 	paginationLimit   = 20
 )
 
-// mainModel holds the state for the entire application.
 type mainModel struct {
-	current 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
 }
 
-// newInitialModel returns a pointer to the initial model.
 func newInitialModel(cfg *config.Config) *mainModel {
-	// If config is nil, start with the login screen.
+	// 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 {
@@ -59,32 +59,50 @@ 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
-		// 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...)
+		return m, nil
 
 	case tea.KeyMsg:
 		if msg.String() == "ctrl+c" {
 			return m, tea.Quit
 		}
-		// Allow ESC to navigate back.
 		if msg.String() == "esc" {
 			switch m.current.(type) {
-			case *tui.EmailView:
-				m.current = m.inbox // Go back to the cached inbox.
-				return m, nil
-			case *tui.Inbox, *tui.Composer, *tui.Login:
-				m.current = tui.NewChoice() // Go back to the main menu.
+			case *tui.FilePicker:
+				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
+			case *tui.Inbox, *tui.Login:
+				m.current = tui.NewChoice(m.cachedComposer != nil)
 				return m, m.current.Init()
 			}
 		}
 
-	// --- Custom Messages for Switching Views and Pagination ---
+	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,
@@ -97,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...")
@@ -108,50 +126,120 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.emails = msg.Emails
 		m.inbox = tui.NewInbox(m.emails)
 		m.current = m.inbox
-		// 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())
+		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...)
-		// Pass the new emails to the inbox to be appended.
-		m.current, cmd = m.current.Update(msg)
-		cmds = append(cmds, cmd)
-		return m, tea.Batch(cmds...)
+		return m, nil
 
 	case tui.GoToSendMsg:
-		m.current = tui.NewComposer(m.config.Email)
+		// 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
+		subject := "Re: " + msg.Email.Subject
+		body := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))
+		m.current = tui.NewComposer(m.config.Email, to, subject, body)
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		return m, m.current.Init()
+
+	case tui.GoToFilePickerMsg:
+		m.previousModel = m.current
+		wd, _ := os.Getwd()
+		m.current = tui.NewFilePicker(wd)
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		return m, m.current.Init()
+
+	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
+		if m.previousModel != nil {
+			m.current = m.previousModel
+			m.previousModel = nil
+		}
+		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...")
+		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...")
+		return m, tea.Batch(m.current.Init(), archiveEmailCmd(m.config, msg.UID))
+
+	case tui.EmailActionDoneMsg:
+		if msg.Err != nil {
+			log.Printf("Action failed: %v", msg.Err)
+			m.current = m.inbox
+			return m, nil
+		}
+		var updatedEmails []fetcher.Email
+		for _, email := range m.emails {
+			if email.UID != msg.UID {
+				updatedEmails = append(updatedEmails, email)
+			}
+		}
+		m.emails = updatedEmails
+		m.inbox = tui.NewInbox(m.emails)
+		m.current = m.inbox
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		return m, m.current.Init()
+
+	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
 	}
 
-	// Pass all other messages to the current view.
-	m.current, cmd = m.current.Update(msg)
-	cmds = append(cmds, cmd)
-
 	return m, tea.Batch(cmds...)
 }
 
@@ -159,40 +247,32 @@ func (m *mainModel) View() string {
 	return m.current.View()
 }
 
-// markdownToHTML converts a Markdown string to an HTML string.
 func markdownToHTML(md []byte) []byte {
 	var buf bytes.Buffer
-	p := goldmark.New(
-		goldmark.WithRendererOptions(
-			html.WithUnsafe(), // Allow raw HTML in email.
-		),
-	)
+	p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
 	if err := p.Convert(md, &buf); err != nil {
-		return md // Fallback to original markdown.
+		return md
 	}
 	return buf.Bytes()
 }
 
-// sendEmail finds local image paths, embeds them, and sends the email.
 func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
 	return func() tea.Msg {
 		recipients := []string{msg.To}
 		body := msg.Body
 		images := make(map[string][]byte)
+		attachments := make(map[string][]byte)
 
-		// Find all markdown image tags.
 		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
 		matches := re.FindAllStringSubmatch(body, -1)
 
 		for _, match := range matches {
 			imgPath := match[1]
-			imgData, err := os.ReadFile(imgPath) // Use os.ReadFile.
+			imgData, err := os.ReadFile(imgPath)
 			if err != nil {
 				log.Printf("Could not read image file %s: %v", imgPath, err)
 				continue
 			}
-
-			// Create a unique CID that includes the file extension.
 			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "email-cli")
 			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
 			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
@@ -200,17 +280,25 @@ func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
 
 		htmlBody := markdownToHTML([]byte(body))
 
-		err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images)
+		if msg.AttachmentPath != "" {
+			fileData, err := os.ReadFile(msg.AttachmentPath)
+			if err != nil {
+				log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
+			} else {
+				_, filename := filepath.Split(msg.AttachmentPath)
+				attachments[filename] = fileData
+			}
+		}
+
+		err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
 		if err != nil {
 			log.Printf("Failed to send email: %v", err)
 			return tui.EmailResultMsg{Err: err}
 		}
-		time.Sleep(1 * time.Second)
 		return tui.EmailResultMsg{}
 	}
 }
 
-// fetchEmails retrieves emails in the background and dispatches the correct message.
 func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd {
 	return func() tea.Msg {
 		emails, err := fetcher.FetchEmails(cfg, limit, offset)
@@ -218,19 +306,48 @@ func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd {
 			return tui.FetchErr(err)
 		}
 		if offset == 0 {
-			// This is the initial fetch.
 			return tui.EmailsFetchedMsg{Emails: emails}
 		}
-		// This is a subsequent fetch for pagination.
 		return tui.EmailsAppendedMsg{Emails: emails}
 	}
 }
 
+func deleteEmailCmd(cfg *config.Config, uid uint32) tea.Cmd {
+	return func() tea.Msg {
+		err := fetcher.DeleteEmail(cfg, uid)
+		return tui.EmailActionDoneMsg{UID: uid, Err: err}
+	}
+}
+
+func archiveEmailCmd(cfg *config.Config, uid uint32) tea.Cmd {
+	return func() tea.Msg {
+		err := fetcher.ArchiveEmail(cfg, uid)
+		return tui.EmailActionDoneMsg{UID: uid, Err: err}
+	}
+}
+
+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
 	if err != nil {
-		// If config doesn't exist, guide the user to create one via the login view.
 		initialModel = newInitialModel(nil)
 	} else {
 		initialModel = newInitialModel(cfg)
@@ -242,4 +359,4 @@ func main() {
 		fmt.Printf("Alas, there's been an error: %v", err)
 		os.Exit(1)
 	}
-}
+}

sender/sender.go 🔗

@@ -26,8 +26,8 @@ func generateMessageID(from string) string {
 	return fmt.Sprintf("<%x@%s>", buf, from)
 }
 
-// SendEmail constructs a multipart message with plain text, HTML, and embedded images.
-func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody string, images map[string][]byte) error {
+// SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
+func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string) error {
 	var smtpServer string
 	var smtpPort int
 
@@ -53,55 +53,72 @@ func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody str
 	var msg bytes.Buffer
 	mainWriter := multipart.NewWriter(&msg)
 
-	// Set top-level headers for multipart/related
+	// Set top-level headers for a mixed message type to support content and attachments
 	headers := map[string]string{
 		"From":         fromHeader,
 		"To":           to[0],
 		"Subject":      subject,
 		"Date":         time.Now().Format(time.RFC1123Z),
 		"Message-ID":   generateMessageID(cfg.Email),
-		"Content-Type": "multipart/related; boundary=" + mainWriter.Boundary(),
+		"Content-Type": "multipart/mixed; boundary=" + mainWriter.Boundary(),
 	}
+
+	if inReplyTo != "" {
+		headers["In-Reply-To"] = inReplyTo
+		if len(references) > 0 {
+			headers["References"] = strings.Join(references, " ") + " " + inReplyTo
+		} else {
+			headers["References"] = inReplyTo
+		}
+	}
+
 	for k, v := range headers {
 		fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
 	}
 	fmt.Fprintf(&msg, "\r\n") // End of headers
 
-	// Create the multipart/alternative part as a nested part
+	// --- Body Part (multipart/related) ---
+	// This part contains the multipart/alternative (text/html) and any inline images.
+	relatedHeader := textproto.MIMEHeader{}
+	relatedBoundary := "related-" + mainWriter.Boundary()
+	relatedHeader.Set("Content-Type", "multipart/related; boundary="+relatedBoundary)
+	relatedPartWriter, err := mainWriter.CreatePart(relatedHeader)
+	if err != nil {
+		return err
+	}
+	relatedWriter := multipart.NewWriter(relatedPartWriter)
+	relatedWriter.SetBoundary(relatedBoundary)
+
+	// --- Alternative Part (text and html) ---
 	altHeader := textproto.MIMEHeader{}
 	altBoundary := "alt-" + mainWriter.Boundary()
 	altHeader.Set("Content-Type", "multipart/alternative; boundary="+altBoundary)
-	altPartWriter, err := mainWriter.CreatePart(altHeader)
+	altPartWriter, err := relatedWriter.CreatePart(altHeader)
 	if err != nil {
 		return err
 	}
-
 	altWriter := multipart.NewWriter(altPartWriter)
 	altWriter.SetBoundary(altBoundary)
 
-	// Create plain text part inside multipart/alternative
-	textHeader := textproto.MIMEHeader{}
-	textHeader.Set("Content-Type", "text/plain; charset=UTF-8")
-	textPart, err := altWriter.CreatePart(textHeader)
+	// Plain text part
+	textPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}})
 	if err != nil {
 		return err
 	}
 	fmt.Fprint(textPart, plainBody)
 
-	// Create HTML part inside multipart/alternative
-	htmlHeader := textproto.MIMEHeader{}
-	htmlHeader.Set("Content-Type", "text/html; charset=UTF-8")
-	htmlPart, err := altWriter.CreatePart(htmlHeader)
+	// HTML part
+	htmlPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}})
 	if err != nil {
 		return err
 	}
 	fmt.Fprint(htmlPart, htmlBody)
 
-	altWriter.Close()
+	altWriter.Close() // Finish the alternative part
 
-	// Attach images to the main multipart/related part
+	// --- Inline Images ---
 	for cid, data := range images {
-		ext := filepath.Ext(strings.Split(cid, "@")[0]) // Extract extension from CID
+		ext := filepath.Ext(strings.Split(cid, "@")[0])
 		mimeType := mime.TypeByExtension(ext)
 		if mimeType == "" {
 			mimeType = "application/octet-stream"
@@ -113,18 +130,36 @@ func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody str
 		imgHeader.Set("Content-ID", "<"+cid+">")
 		imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
 
-		imgPart, err := mainWriter.CreatePart(imgHeader)
+		imgPart, err := relatedWriter.CreatePart(imgHeader)
 		if err != nil {
 			return err
 		}
-		decodedData, err := base64.StdEncoding.DecodeString(string(data))
+		imgPart.Write(data) // data is already base64 encoded
+	}
+
+	relatedWriter.Close() // Finish the related part
+
+	// --- Attachments ---
+	for filename, data := range attachments {
+		mimeType := mime.TypeByExtension(filepath.Ext(filename))
+		if mimeType == "" {
+			mimeType = "application/octet-stream"
+		}
+
+		partHeader := textproto.MIMEHeader{}
+		partHeader.Set("Content-Type", mimeType)
+		partHeader.Set("Content-Transfer-Encoding", "base64")
+		partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
+
+		attachmentPart, err := mainWriter.CreatePart(partHeader)
 		if err != nil {
 			return err
 		}
-		imgPart.Write(decodedData)
+		encodedData := base64.StdEncoding.EncodeToString(data)
+		attachmentPart.Write([]byte(encodedData))
 	}
 
-	mainWriter.Close()
+	mainWriter.Close() // Finish the main message
 
 	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
 	return smtp.SendMail(addr, auth, cfg.Email, to, msg.Bytes())

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 🔗

@@ -1,6 +1,9 @@
 package tui
 
 import (
+	"fmt"
+	"strings"
+
 	"github.com/charmbracelet/bubbles/textarea"
 	"github.com/charmbracelet/bubbles/textinput"
 	tea "github.com/charmbracelet/bubbletea"
@@ -13,27 +16,34 @@ 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")) // This was the missing style
 )
 
 // Composer model holds the state of the email composition UI.
 type Composer struct {
-	focusIndex   int
-	toInput      textinput.Model
-	subjectInput textinput.Model
-	bodyInput    textarea.Model
-	fromAddr     string
+	focusIndex     int
+	toInput        textinput.Model
+	subjectInput   textinput.Model
+	bodyInput      textarea.Model
+	attachmentPath string
+	fromAddr       string
+	width          int
+	height         int
+	confirmingExit bool
 }
 
 // NewComposer initializes a new composer model.
-func NewComposer(from string) *Composer {
+func NewComposer(from, to, subject, body string) *Composer {
 	m := &Composer{fromAddr: from}
 
 	m.toInput = textinput.New()
 	m.toInput.Cursor.Style = cursorStyle
 	m.toInput.Placeholder = "To"
+	m.toInput.SetValue(to)
 	m.toInput.Focus()
 	m.toInput.Prompt = "> "
 	m.toInput.CharLimit = 256
@@ -41,18 +51,26 @@ func NewComposer(from string) *Composer {
 	m.subjectInput = textinput.New()
 	m.subjectInput.Cursor.Style = cursorStyle
 	m.subjectInput.Placeholder = "Subject"
+	m.subjectInput.SetValue(subject)
 	m.subjectInput.Prompt = "> "
 	m.subjectInput.CharLimit = 256
 
 	m.bodyInput = textarea.New()
 	m.bodyInput.Cursor.Style = cursorStyle
 	m.bodyInput.Placeholder = "Body (Markdown supported)..."
+	m.bodyInput.SetValue(body)
 	m.bodyInput.Prompt = "> "
 	m.bodyInput.SetHeight(10)
+	m.bodyInput.SetCursor(0)
 
 	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
 }
@@ -63,19 +81,41 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		// When the window is resized, update the width of the inputs.
-		inputWidth := msg.Width - 6 // Subtract for padding and prompt
+		m.width = msg.Width
+		m.height = msg.Height
+		inputWidth := msg.Width - 6
 		m.toInput.Width = inputWidth
 		m.subjectInput.Width = inputWidth
 		m.bodyInput.SetWidth(inputWidth)
 
+	case SetComposerCursorToStartMsg:
+		m.bodyInput.SetCursor(0)
+		return m, nil
+
+	case FileSelectedMsg:
+		m.attachmentPath = msg.Path
+		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 {
-		// IMPORTANT: Removed tea.KeyEsc from this case
 		case tea.KeyCtrlC:
 			return m, tea.Quit
+		case tea.KeyEsc:
+			m.confirmingExit = true
+			return m, nil
 
-		// Handle Tab and Shift+Tab to cycle focus between inputs.
 		case tea.KeyTab, tea.KeyShiftTab:
 			if msg.Type == tea.KeyShiftTab {
 				m.focusIndex--
@@ -83,19 +123,16 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.focusIndex++
 			}
 
-			// Wrap around
-			if m.focusIndex > 3 { // 3 is the Send button
+			if m.focusIndex > 4 {
 				m.focusIndex = 0
 			} else if m.focusIndex < 0 {
-				m.focusIndex = 3
+				m.focusIndex = 4
 			}
 
-			// Blur all inputs
 			m.toInput.Blur()
 			m.subjectInput.Blur()
 			m.bodyInput.Blur()
 
-			// Focus the correct input
 			switch m.focusIndex {
 			case 0:
 				cmds = append(cmds, m.toInput.Focus())
@@ -103,25 +140,27 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, m.subjectInput.Focus())
 			case 2:
 				cmds = append(cmds, m.bodyInput.Focus())
+				cmds = append(cmds, func() tea.Msg { return SetComposerCursorToStartMsg{} })
 			}
 			return m, tea.Batch(cmds...)
 
-		// Handle Enter key.
 		case tea.KeyEnter:
-			// If on the Send button, send the email.
 			if m.focusIndex == 3 {
+				return m, func() tea.Msg { return GoToFilePickerMsg{} }
+			}
+			if m.focusIndex == 4 {
 				return m, func() tea.Msg {
 					return SendEmailMsg{
-						To:      m.toInput.Value(),
-						Subject: m.subjectInput.Value(),
-						Body:    m.bodyInput.Value(),
+						To:             m.toInput.Value(),
+						Subject:        m.subjectInput.Value(),
+						Body:           m.bodyInput.Value(),
+						AttachmentPath: m.attachmentPath,
 					}
 				}
 			}
 		}
 	}
 
-	// Update the focused input.
 	switch m.focusIndex {
 	case 0:
 		m.toInput, cmd = m.toInput.Update(msg)
@@ -137,20 +176,48 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
-// View renders the UI.
 func (m *Composer) View() string {
-	button := &blurredButton
+	var composerView strings.Builder
+	var button string
+
+	if m.focusIndex == 4 {
+		button = focusedButton
+	} else {
+		button = blurredButton
+	}
+
+	var attachmentField string
+	attachmentText := "None (Press Enter to select)"
+	if m.attachmentPath != "" {
+		attachmentText = m.attachmentPath
+	}
+
 	if m.focusIndex == 3 {
-		button = &focusedButton
+		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(),
 		m.subjectInput.View(),
 		m.bodyInput.View(),
-		*button,
-		helpStyle.Render("Markdown enabled! • tab: next field • esc: back to menu"),
-	)
+		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/composer_test.go 🔗

@@ -10,7 +10,7 @@ import (
 // TestComposerUpdate verifies the state transitions in the email composer.
 func TestComposerUpdate(t *testing.T) {
 	// Initialize a new composer.
-	composer := NewComposer("test@example.com")
+	composer := NewComposer("test@example.com", "", "", "")
 
 	t.Run("Focus cycling", func(t *testing.T) {
 		// Initial focus is on the 'To' input (index 0).
@@ -19,31 +19,38 @@ func TestComposerUpdate(t *testing.T) {
 		}
 
 		// Simulate pressing Tab to move to the 'Subject' field.
-		model, _ := composer.Update(tea.KeyMsg{Type: tea.KeyTab}) // model is tea.Model
-		composer = model.(*Composer) // Cast to *Composer
+		model, _ := composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		composer = model.(*Composer)
 		if composer.focusIndex != 1 {
-			t.Errorf("After one Tab, focusIndex should be 1, got %d", composer.focusIndex)
+			t.Errorf("After one Tab, focusIndex should be 1 (Subject), got %d", composer.focusIndex)
 		}
 
 		// Simulate pressing Tab again to move to the 'Body' field.
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) // model is tea.Model
-		composer = model.(*Composer) // Cast to *Composer
+		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		composer = model.(*Composer)
 		if composer.focusIndex != 2 {
-			t.Errorf("After two Tabs, focusIndex should be 2, got %d", composer.focusIndex)
+			t.Errorf("After two Tabs, focusIndex should be 2 (Body), got %d", composer.focusIndex)
 		}
 
-		// Simulate pressing Tab again to move to the 'Send' button.
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) // model is tea.Model
-		composer = model.(*Composer) // Cast to *Composer
+		// Simulate pressing Tab again to move to the 'Attachment' field.
+		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		composer = model.(*Composer)
 		if composer.focusIndex != 3 {
-			t.Errorf("After three Tabs, focusIndex should be 3 (Send), got %d", composer.focusIndex)
+			t.Errorf("After three Tabs, focusIndex should be 3 (Attachment), got %d", composer.focusIndex)
+		}
+
+		// Simulate pressing Tab again to move to the 'Send' button.
+		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		composer = model.(*Composer)
+		if composer.focusIndex != 4 {
+			t.Errorf("After four Tabs, focusIndex should be 4 (Send), got %d", composer.focusIndex)
 		}
 
 		// Simulate one more Tab to wrap around to the 'To' field.
-		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) // model is tea.Model
-		composer = model.(*Composer) // Cast to *Composer
+		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		composer = model.(*Composer)
 		if composer.focusIndex != 0 {
-			t.Errorf("After four Tabs, focusIndex should wrap to 0, got %d", composer.focusIndex)
+			t.Errorf("After five Tabs, focusIndex should wrap to 0, got %d", composer.focusIndex)
 		}
 	})
 
@@ -53,7 +60,7 @@ func TestComposerUpdate(t *testing.T) {
 		composer.subjectInput.SetValue("Test Subject")
 		composer.bodyInput.SetValue("This is the body.")
 		// Set focus to the Send button.
-		composer.focusIndex = 3
+		composer.focusIndex = 4
 
 		// Simulate pressing Enter to send the email.
 		_, cmd := composer.Update(tea.KeyMsg{Type: tea.KeyEnter})
@@ -70,12 +77,13 @@ func TestComposerUpdate(t *testing.T) {
 
 		// Verify the content of the message.
 		expectedMsg := SendEmailMsg{
-			To:      "recipient@example.com",
-			Subject: "Test Subject",
-			Body:    "This is the body.",
+			To:             "recipient@example.com",
+			Subject:        "Test Subject",
+			Body:           "This is the body.",
+			AttachmentPath: "", // Expect empty attachment path in this test
 		}
 		if !reflect.DeepEqual(sendMsg, expectedMsg) {
 			t.Errorf("Mismatched SendEmailMsg.\nGot:  %+v\nWant: %+v", sendMsg, expectedMsg)
 		}
 	})
-}
+}

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,19 +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:
+		// 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)
-	return fmt.Sprintf("%s\n%s", styledHeader, m.viewport.View())
+
+	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/filepicker.go 🔗

@@ -0,0 +1,125 @@
+package tui
+
+import (
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+var (
+	filePickerItemStyle         = lipgloss.NewStyle().PaddingLeft(2)
+	filePickerSelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("205"))
+	directoryStyle              = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
+)
+
+type FilePicker struct {
+	cursor      int
+	currentPath string
+	items       []fs.DirEntry
+	width       int
+	height      int
+}
+
+func NewFilePicker(startPath string) *FilePicker {
+	fp := &FilePicker{currentPath: startPath}
+	fp.readDir()
+	return fp
+}
+
+func (m *FilePicker) readDir() {
+	files, err := os.ReadDir(m.currentPath)
+	if err != nil {
+		// Handle error, maybe show a message
+		m.items = []fs.DirEntry{}
+		return
+	}
+	m.items = files
+	m.cursor = 0 // Reset cursor
+}
+
+func (m *FilePicker) Init() tea.Cmd {
+	return nil
+}
+
+func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		m.height = msg.Height
+
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "up", "k":
+			if m.cursor > 0 {
+				m.cursor--
+			}
+		case "down", "j":
+			if m.cursor < len(m.items)-1 {
+				m.cursor++
+			}
+		case "enter":
+			if len(m.items) == 0 {
+				return m, nil
+			}
+			selectedItem := m.items[m.cursor]
+			newPath := filepath.Join(m.currentPath, selectedItem.Name())
+
+			if selectedItem.IsDir() {
+				m.currentPath = newPath
+				m.readDir()
+			} else {
+				// It's a file, send a message with the path
+				return m, func() tea.Msg {
+					return FileSelectedMsg{Path: newPath}
+				}
+			}
+		case "backspace":
+			// Go up one directory
+			parentDir := filepath.Dir(m.currentPath)
+			if parentDir != m.currentPath { // Avoid getting stuck at root
+				m.currentPath = parentDir
+				m.readDir()
+			}
+		case "esc", "q":
+			return m, func() tea.Msg { return CancelFilePickerMsg{} }
+		}
+	}
+	return m, nil
+}
+
+func (m *FilePicker) View() string {
+	var b strings.Builder
+
+	b.WriteString(titleStyle.Render("Select a File") + "\n")
+	b.WriteString(fmt.Sprintf("Current Path: %s\n\n", m.currentPath))
+
+	for i, item := range m.items {
+		cursor := "  "
+		if m.cursor == i {
+			cursor = "> "
+		}
+
+		itemName := item.Name()
+		if item.IsDir() {
+			itemName = directoryStyle.Render(itemName + "/")
+		}
+
+		line := fmt.Sprintf("%s%s", cursor, itemName)
+
+		if m.cursor == i {
+			b.WriteString(filePickerSelectedItemStyle.Render(line))
+		} else {
+			b.WriteString(filePickerItemStyle.Render(line))
+		}
+		b.WriteString("\n")
+	}
+
+	b.WriteString("\n" + helpStyle.Render("↑/↓: navigate • enter: select • backspace: up • esc: cancel"))
+
+	return docStyle.Render(b.String())
+}

tui/inbox.go 🔗

@@ -5,6 +5,7 @@ import (
 	"io"
 
 	"github.com/andrinoff/email-cli/fetcher"
+	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
@@ -15,10 +16,10 @@ var (
 	inboxHelpStyle  = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
 )
 
-// item now holds its original index from the main email slice.
 type item struct {
 	title, desc   string
 	originalIndex int
+	uid           uint32 // Added UID to item
 }
 
 func (i item) Title() string       { return i.title }
@@ -48,7 +49,6 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	fmt.Fprint(w, fn(str))
 }
 
-// Inbox is now stateful to handle pagination.
 type Inbox struct {
 	list        list.Model
 	isFetching  bool
@@ -61,7 +61,8 @@ func NewInbox(emails []fetcher.Email) *Inbox {
 		items[i] = item{
 			title:         email.Subject,
 			desc:          email.From,
-			originalIndex: i, // Store the original index here.
+			originalIndex: i,
+			uid:           email.UID, // Store UID
 		}
 	}
 
@@ -73,6 +74,12 @@ func NewInbox(emails []fetcher.Email) *Inbox {
 	l.Styles.PaginationStyle = paginationStyle
 	l.Styles.HelpStyle = inboxHelpStyle
 	l.SetStatusBarItemName("email", "emails")
+	l.AdditionalShortHelpKeys = func() []key.Binding {
+		return []key.Binding{
+			key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
+			key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "archive")),
+		}
+	}
 
 	return &Inbox{
 		list:        l,
@@ -90,13 +97,28 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
-		// When the user presses enter, we look at the selected item and send
-		// a message with its *original* index.
-		if msg.String() == "enter" {
+		if m.list.FilterState() == list.Filtering {
+			break
+		}
+		switch keypress := msg.String(); keypress {
+		case "d":
+			selectedItem, ok := m.list.SelectedItem().(item)
+			if ok {
+				return m, func() tea.Msg {
+					return DeleteEmailMsg{UID: selectedItem.uid}
+				}
+			}
+		case "a":
+			selectedItem, ok := m.list.SelectedItem().(item)
+			if ok {
+				return m, func() tea.Msg {
+					return ArchiveEmailMsg{UID: selectedItem.uid}
+				}
+			}
+		case "enter":
 			selectedItem, ok := m.list.SelectedItem().(item)
 			if ok {
 				return m, func() tea.Msg {
-					// Use the stored original index, which is correct even when filtered.
 					return ViewEmailMsg{Index: selectedItem.originalIndex}
 				}
 			}
@@ -118,7 +140,8 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			newItems[i] = item{
 				title:         email.Subject,
 				desc:          email.From,
-				originalIndex: m.emailsCount + i, // The original index continues to grow.
+				originalIndex: m.emailsCount + i,
+				uid:           email.UID,
 			}
 		}
 		currentItems := m.list.Items()
@@ -129,26 +152,14 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, tea.Batch(cmds...)
 	}
 
-	// Infinite scroll logic
 	if !m.isFetching && len(m.list.Items()) > 0 && m.list.Index() >= len(m.list.Items())-5 {
 		cmds = append(cmds, func() tea.Msg {
 			return FetchMoreEmailsMsg{Offset: uint32(m.emailsCount)}
 		})
 	}
 
-	// New logic to fetch more emails when filtering is active
-	if m.list.FilterState() == list.Filtering && !m.isFetching {
-		m.isFetching = true
-		m.list.Title = "Fetching more emails..."
-		cmds = append(cmds, func() tea.Msg {
-			return FetchMoreEmailsMsg{Offset: uint32(m.emailsCount)}
-		})
-	}
-
 	var cmd tea.Cmd
-	var newModel list.Model
-	newModel, cmd = m.list.Update(msg)
-	m.list = newModel
+	m.list, cmd = m.list.Update(msg)
 	cmds = append(cmds, cmd)
 	return m, tea.Batch(cmds...)
 }

tui/messages.go 🔗

@@ -2,19 +2,19 @@ package tui
 
 import "github.com/andrinoff/email-cli/fetcher"
 
-// A message to view an email.
 type ViewEmailMsg struct {
 	Index int
 }
 
-// A message to indicate that an email has been sent.
 type SendEmailMsg struct {
-	To      string
-	Subject string
-	Body    string
+	To             string
+	Subject        string
+	Body           string
+	AttachmentPath string
+	InReplyTo      string
+	References     []string
 }
 
-// A message to indicate that the user has entered their credentials.
 type Credentials struct {
 	Provider string
 	Name     string
@@ -22,46 +22,91 @@ type Credentials struct {
 	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
 
-// A message to navigate to the inbox view.
 type GoToInboxMsg struct{}
 
-// A message to navigate to the composer view.
-type GoToSendMsg struct{}
+type GoToSendMsg struct {
+	To      string
+	Subject string
+	Body    string
+}
 
-// A message to navigate to the settings view.
 type GoToSettingsMsg struct{}
 
-// A message to fetch more emails with a given offset.
 type FetchMoreEmailsMsg struct {
 	Offset uint32
 }
 
-// A message to indicate that the app is fetching more emails.
 type FetchingMoreEmailsMsg struct{}
 
-// A message to indicate that new emails have been fetched and should be appended.
 type EmailsAppendedMsg struct {
 	Emails []fetcher.Email
-}
+}
+
+type ReplyToEmailMsg struct {
+	Email fetcher.Email
+}
+
+type SetComposerCursorToStartMsg struct{}
+
+type GoToFilePickerMsg struct{}
+
+type FileSelectedMsg struct {
+	Path string
+}
+
+type CancelFilePickerMsg struct{}
+
+type DeleteEmailMsg struct {
+	UID uint32
+}
+
+type ArchiveEmailMsg struct {
+	UID uint32
+}
+
+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{}