diff --git a/main.go b/main.go index 5292d85ab0c178d2e18b6150344a57ba0a667720..67ed463bd4a5d62856a9da545d0f5864c602781e 100644 --- a/main.go +++ b/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) diff --git a/sender/sender.go b/sender/sender.go index 7a6c2b227a7a2ee48c2f82fefcd45160e6f8b815..d45df8d66f48fe8271ed6d001425dcf4d6d96a4c 100644 --- a/sender/sender.go +++ b/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()) diff --git a/tui/composer.go b/tui/composer.go index 96bbcc906da13acc6a6a815e2412157f5fb66de7..ac44371a45dc87dbde0d9d3dc1403a317a747753 100644 --- a/tui/composer.go +++ b/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"), ) } \ No newline at end of file diff --git a/tui/filepicker.go b/tui/filepicker.go new file mode 100644 index 0000000000000000000000000000000000000000..a950337443ee4b8f481e168e2f54f38abaf00e81 --- /dev/null +++ b/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()) +} \ No newline at end of file diff --git a/tui/messages.go b/tui/messages.go index 62714a366412c809f95895c25a5eb97064036977..c65c6d3b02fd812d3b7c52df79e595e67e1c63de 100644 --- a/tui/messages.go +++ b/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{} \ No newline at end of file +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{} \ No newline at end of file