feat: attachments (#24)

drew created

Change summary

main.go           |  86 ++++++++++++++++++++------------
sender/sender.go  |  70 ++++++++++++++++++---------
tui/composer.go   |  68 +++++++++++++++++---------
tui/filepicker.go | 125 +++++++++++++++++++++++++++++++++++++++++++++++++
tui/messages.go   |  26 +++++++--
5 files changed, 289 insertions(+), 86 deletions(-)

Detailed changes

main.go 🔗

@@ -28,18 +28,18 @@ const (
 
 // 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 // To store the view before opening the file picker
+	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.
 	if cfg == nil {
 		return &mainModel{
 			current: tui.NewLogin(),
@@ -63,7 +63,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	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...)
@@ -72,19 +71,20 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if msg.String() == "ctrl+c" {
 			return m, tea.Quit
 		}
-		// Allow ESC to navigate back.
 		if msg.String() == "esc" {
+			// If we are in a nested view like email or file picker, handle it here
 			switch m.current.(type) {
 			case *tui.EmailView:
-				m.current = m.inbox // Go back to the cached inbox.
+				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() // Go back to the main menu.
+				m.current = tui.NewChoice()
 				return m, m.current.Init()
 			}
 		}
 
-	// --- Custom Messages for Switching Views and Pagination ---
 	case tui.Credentials:
 		cfg := &config.Config{
 			ServiceProvider: msg.Provider,
@@ -108,7 +108,6 @@ 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())
 
@@ -119,7 +118,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	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...)
@@ -146,10 +144,31 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		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())
-		// This is a reply, so we'll need to pass the message ID and references.
-		// We'll add this to the SendEmailMsg that gets created when the user hits send.
-		// We'll modify the composer so that when it creates a SendEmailMsg, it can be passed
-		// the InReplyTo and References.
+
+	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:
+		if m.previousModel != nil {
+			// Send the selection message to the previous model (the composer)
+			m.previousModel, cmd = m.previousModel.Update(msg)
+			cmds = append(cmds, cmd)
+			// Return to the composer view
+			m.current = m.previousModel
+			m.previousModel = nil
+		}
+		return m, tea.Batch(cmds...)
+
+	case tui.CancelFilePickerMsg:
+		if m.previousModel != nil {
+			m.current = m.previousModel
+			m.previousModel = nil
+		}
+		return m, nil
 
 	case tui.SendEmailMsg:
 		m.current = tui.NewStatus("Sending email...")
@@ -160,7 +179,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, m.current.Init())
 	}
 
-	// Pass all other messages to the current view.
 	m.current, cmd = m.current.Update(msg)
 	cmds = append(cmds, cmd)
 
@@ -171,40 +189,36 @@ 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.
+			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)
@@ -212,7 +226,17 @@ 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, msg.InReplyTo, msg.References)
+		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}
@@ -222,7 +246,6 @@ func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
 	}
 }
 
-// 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)
@@ -230,10 +253,8 @@ 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}
 	}
 }
@@ -242,7 +263,6 @@ 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)

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, inReplyTo string, references []string) 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,19 +53,18 @@ 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
-		// When replying, the references should be the previous references plus the message-id of the email we're replying to.
 		if len(references) > 0 {
 			headers["References"] = strings.Join(references, " ") + " " + inReplyTo
 		} else {
@@ -78,41 +77,48 @@ func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody str
 	}
 	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"
@@ -124,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/composer.go 🔗

@@ -1,6 +1,8 @@
 package tui
 
 import (
+	"fmt"
+
 	"github.com/charmbracelet/bubbles/textarea"
 	"github.com/charmbracelet/bubbles/textinput"
 	tea "github.com/charmbracelet/bubbletea"
@@ -16,15 +18,17 @@ var (
 	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"))
 )
 
 // 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
 }
 
 // NewComposer initializes a new composer model.
@@ -52,7 +56,7 @@ func NewComposer(from, to, subject, body string) *Composer {
 	m.bodyInput.SetValue(body)
 	m.bodyInput.Prompt = "> "
 	m.bodyInput.SetHeight(10)
-	m.bodyInput.SetCursor(0) // Set cursor to the beginning on creation
+	m.bodyInput.SetCursor(0)
 
 	return m
 }
@@ -67,8 +71,7 @@ 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
+		inputWidth := msg.Width - 6
 		m.toInput.Width = inputWidth
 		m.subjectInput.Width = inputWidth
 		m.bodyInput.SetWidth(inputWidth)
@@ -77,6 +80,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.bodyInput.SetCursor(0)
 		return m, nil
 
+	case FileSelectedMsg:
+		m.attachmentPath = msg.Path
+		return m, nil
+
 	case tea.KeyMsg:
 		switch msg.Type {
 		case tea.KeyCtrlC:
@@ -89,19 +96,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 { // 4 is the Send button
 				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())
@@ -109,25 +113,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())
-				// Send a message to explicitly set the cursor position AFTER focus.
 				cmds = append(cmds, func() tea.Msg { return SetComposerCursorToStartMsg{} })
 			}
 			return m, tea.Batch(cmds...)
 
 		case tea.KeyEnter:
-			if m.focusIndex == 3 {
+			if m.focusIndex == 3 { // Attachment field focused
+				return m, func() tea.Msg { return GoToFilePickerMsg{} }
+			}
+			if m.focusIndex == 4 { // Send button focused
 				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)
@@ -143,11 +149,24 @@ 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
-	if m.focusIndex == 3 {
-		button = &focusedButton
+	var button string
+	if m.focusIndex == 4 { // Send button
+		button = focusedButton
+	} else {
+		button = blurredButton
+	}
+
+	var attachmentField string
+	attachmentText := "None (Press Enter to select)"
+	if m.attachmentPath != "" {
+		attachmentText = m.attachmentPath
+	}
+
+	if m.focusIndex == 3 { // Attachment field
+		attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText))
+	} else {
+		attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachment: %s", attachmentText))
 	}
 
 	return lipgloss.JoinVertical(lipgloss.Left,
@@ -156,7 +175,8 @@ func (m *Composer) View() string {
 		m.toInput.View(),
 		m.subjectInput.View(),
 		m.bodyInput.View(),
-		*button,
+		attachmentStyle.Render(attachmentField),
+		button,
 		helpStyle.Render("Markdown/HTML • tab: next field • esc: back to menu"),
 	)
 }

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/messages.go 🔗

@@ -9,11 +9,12 @@ type ViewEmailMsg struct {
 
 // A message to indicate that an email has been sent.
 type SendEmailMsg struct {
-	To         string
-	Subject    string
-	Body       string
-	InReplyTo  string
-	References []string
+	To             string
+	Subject        string
+	Body           string
+	AttachmentPath string
+	InReplyTo      string
+	References     []string
 }
 
 // A message to indicate that the user has entered their credentials.
@@ -78,4 +79,17 @@ type ReplyToEmailMsg struct {
 }
 
 // A message to set the composer cursor to the start.
-type SetComposerCursorToStartMsg struct{}
+type SetComposerCursorToStartMsg struct{}
+
+// --- File Picker Messages ---
+
+// A message to open the file picker.
+type GoToFilePickerMsg struct{}
+
+// A message sent when a file has been selected.
+type FileSelectedMsg struct {
+	Path string
+}
+
+// A message to cancel the file picker and return to the previous view.
+type CancelFilePickerMsg struct{}