Detailed changes
@@ -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)
@@ -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())
@@ -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"),
)
}
@@ -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())
+}
@@ -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{}