From 0bb09a28a9be9c91e2b7a7b89a92cda31500a781 Mon Sep 17 00:00:00 2001 From: drew Date: Tue, 29 Jul 2025 14:46:07 +0400 Subject: [PATCH] feat: reply (#25) --- fetcher/fetcher.go | 27 ++++++++++++++++----------- main.go | 18 +++++++++++++++--- sender/sender.go | 13 ++++++++++++- tui/composer.go | 18 ++++++++++++------ tui/composer_test.go | 12 ++++++------ tui/email_view.go | 10 +++++++++- tui/messages.go | 24 +++++++++++++++++++----- 7 files changed, 89 insertions(+), 33 deletions(-) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index c7c9ee4d4e2597b5598ed9adc047e05f4ee99870..4849ec2046e18a2878cb5f409aafb2aa02c2ed15 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -18,14 +18,15 @@ import ( ) type Email struct { - From string - To []string - Subject string - Body string - Date time.Time + From string + To []string + Subject string + Body string + Date time.Time + MessageID string + References []string } -// ... (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 { @@ -153,6 +154,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 { @@ -185,11 +188,13 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { } emails = append(emails, Email{ - From: fromAddr, - To: toAddrList, - Subject: subject, - Body: body, - Date: date, + From: fromAddr, + To: toAddrList, + Subject: subject, + Body: body, + Date: date, + MessageID: messageID, + References: strings.Fields(references), }) } diff --git a/main.go b/main.go index a01562b1f913c89245e8af1f86e383ddd2153936..5292d85ab0c178d2e18b6150344a57ba0a667720 100644 --- a/main.go +++ b/main.go @@ -125,7 +125,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) case tui.GoToSendMsg: - m.current = tui.NewComposer(m.config.Email) + 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()) @@ -139,6 +139,18 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = emailView cmds = append(cmds, 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}) + 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.SendEmailMsg: m.current = tui.NewStatus("Sending email...") cmds = append(cmds, m.current.Init(), sendEmail(m.config, msg)) @@ -200,7 +212,7 @@ 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) + err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images, msg.InReplyTo, msg.References) if err != nil { log.Printf("Failed to send email: %v", err) return tui.EmailResultMsg{Err: err} @@ -242,4 +254,4 @@ func main() { fmt.Printf("Alas, there's been an error: %v", err) os.Exit(1) } -} +} \ No newline at end of file diff --git a/sender/sender.go b/sender/sender.go index 7cf4537c547f763989bb87e456a90bf591dbfc2e..7a6c2b227a7a2ee48c2f82fefcd45160e6f8b815 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -27,7 +27,7 @@ func generateMessageID(from string) string { } // 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 { +func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody string, images map[string][]byte, inReplyTo string, references []string) error { var smtpServer string var smtpPort int @@ -62,6 +62,17 @@ func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody str "Message-ID": generateMessageID(cfg.Email), "Content-Type": "multipart/related; 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 { + headers["References"] = inReplyTo + } + } + for k, v := range headers { fmt.Fprintf(&msg, "%s: %s\r\n", k, v) } diff --git a/tui/composer.go b/tui/composer.go index 0a291c0622ee4820538865dcf18aa3783103f32f..96bbcc906da13acc6a6a815e2412157f5fb66de7 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -28,12 +28,13 @@ type Composer struct { } // 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,14 +42,17 @@ 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) // Set cursor to the beginning on creation return m } @@ -69,13 +73,15 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.subjectInput.Width = inputWidth m.bodyInput.SetWidth(inputWidth) + case SetComposerCursorToStartMsg: + m.bodyInput.SetCursor(0) + return m, nil + case tea.KeyMsg: switch msg.Type { - // IMPORTANT: Removed tea.KeyEsc from this case case tea.KeyCtrlC: return m, tea.Quit - // Handle Tab and Shift+Tab to cycle focus between inputs. case tea.KeyTab, tea.KeyShiftTab: if msg.Type == tea.KeyShiftTab { m.focusIndex-- @@ -103,12 +109,12 @@ 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...) - // Handle Enter key. case tea.KeyEnter: - // If on the Send button, send the email. if m.focusIndex == 3 { return m, func() tea.Msg { return SendEmailMsg{ @@ -151,6 +157,6 @@ func (m *Composer) View() string { m.subjectInput.View(), m.bodyInput.View(), *button, - helpStyle.Render("Markdown enabled! • tab: next field • esc: back to menu"), + helpStyle.Render("Markdown/HTML • tab: next field • esc: back to menu"), ) } \ No newline at end of file diff --git a/tui/composer_test.go b/tui/composer_test.go index 007051959f0739be395e91cb6644e8155683fb5f..77a32c2dbc7e694ec46469030924e39dbca6fa65 100644 --- a/tui/composer_test.go +++ b/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). @@ -20,28 +20,28 @@ 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 + composer = model.(*Composer) // Cast to *Composer if composer.focusIndex != 1 { t.Errorf("After one Tab, focusIndex should be 1, 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 + composer = model.(*Composer) // Cast to *Composer if composer.focusIndex != 2 { t.Errorf("After two Tabs, focusIndex should be 2, 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 + composer = model.(*Composer) // Cast to *Composer if composer.focusIndex != 3 { t.Errorf("After three Tabs, focusIndex should be 3 (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 + composer = model.(*Composer) // Cast to *Composer if composer.focusIndex != 0 { t.Errorf("After four Tabs, focusIndex should wrap to 0, got %d", composer.focusIndex) } @@ -78,4 +78,4 @@ func TestComposerUpdate(t *testing.T) { t.Errorf("Mismatched SendEmailMsg.\nGot: %+v\nWant: %+v", sendMsg, expectedMsg) } }) -} +} \ No newline at end of file diff --git a/tui/email_view.go b/tui/email_view.go index ac1778a7674523b4fe722f1c4a1593f5b898f13c..626b75ea5246da8806cd8e07a4b319c11a7b5ebc 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -47,6 +47,13 @@ func (m *EmailView) Init() tea.Cmd { func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "r": + return m, func() tea.Msg { + return ReplyToEmailMsg{Email: m.email} + } + } case tea.WindowSizeMsg: header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject) headerHeight := lipgloss.Height(header) + 2 @@ -60,5 +67,6 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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()) + help := helpStyle.Render("r: reply • esc: back to inbox") + return fmt.Sprintf("%s\n%s\n%s", styledHeader, m.viewport.View(), help) } \ No newline at end of file diff --git a/tui/messages.go b/tui/messages.go index 36da0b9e365d1715f331f57b1bfe097cc02104cb..62714a366412c809f95895c25a5eb97064036977 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -9,9 +9,11 @@ type ViewEmailMsg struct { // 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 + InReplyTo string + References []string } // A message to indicate that the user has entered their credentials. @@ -48,7 +50,11 @@ type FetchErr error 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{} @@ -64,4 +70,12 @@ type FetchingMoreEmailsMsg struct{} // A message to indicate that new emails have been fetched and should be appended. type EmailsAppendedMsg struct { Emails []fetcher.Email -} \ No newline at end of file +} + +// A message to reply to an email. +type ReplyToEmailMsg struct { + Email fetcher.Email +} + +// A message to set the composer cursor to the start. +type SetComposerCursorToStartMsg struct{} \ No newline at end of file