feat: reply (#25)

drew created

Change summary

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(-)

Detailed changes

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),
 		})
 	}
 

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)
 	}
-}
+}

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)
 	}

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"),
 	)
 }

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)
 		}
 	})
-}
+}

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)
 }

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
-}
+}
+
+// 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{}