feat: signatures (#122) (#124)

Drew Smirnoff created

Change summary

config/signature.go  | 52 +++++++++++++++++++++++++++++
main.go              | 11 +++++
tui/composer.go      | 35 ++++++++++++++++++++
tui/composer_test.go | 21 ++++++++---
tui/messages.go      |  3 +
tui/settings.go      | 20 ++++++++++-
tui/signature.go     | 80 ++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 213 insertions(+), 9 deletions(-)

Detailed changes

config/signature.go 🔗

@@ -0,0 +1,52 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+)
+
+// signatureFile returns the full path to the signature file.
+func signatureFile() (string, error) {
+	dir, err := configDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(dir, "signature.txt"), nil
+}
+
+// LoadSignature loads the signature from the signature file.
+func LoadSignature() (string, error) {
+	path, err := signatureFile()
+	if err != nil {
+		return "", err
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return "", nil
+		}
+		return "", err
+	}
+	return string(data), nil
+}
+
+// SaveSignature saves the signature to the signature file.
+func SaveSignature(signature string) error {
+	path, err := signatureFile()
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+		return err
+	}
+	return os.WriteFile(path, []byte(signature), 0600)
+}
+
+// HasSignature checks if a signature file exists and is non-empty.
+func HasSignature() bool {
+	sig, err := LoadSignature()
+	if err != nil {
+		return false
+	}
+	return sig != ""
+}

main.go 🔗

@@ -429,6 +429,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		return m, m.current.Init()
 
+	case tui.GoToSignatureEditorMsg:
+		m.current = tui.NewSignatureEditor()
+		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+		return m, m.current.Init()
+
 	case tui.GoToChoiceMenuMsg:
 		m.current = tui.NewChoice()
 		return m, m.current.Init()
@@ -1048,6 +1053,10 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
 
 		recipients := []string{msg.To}
 		body := msg.Body
+		// Append signature if present
+		if msg.Signature != "" {
+			body = body + "\n\n" + msg.Signature
+		}
 		// Append quoted text if present (for replies)
 		if msg.QuotedText != "" {
 			body = body + msg.QuotedText
@@ -1082,7 +1091,7 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
 			}
 		}
 
-		err := sender.SendEmail(account, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
+		err := sender.SendEmail(account, recipients, msg.Subject, 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}

tui/composer.go 🔗

@@ -37,6 +37,7 @@ const (
 	focusTo
 	focusSubject
 	focusBody
+	focusSignature
 	focusAttachment
 	focusSend
 )
@@ -47,6 +48,7 @@ type Composer struct {
 	toInput        textinput.Model
 	subjectInput   textinput.Model
 	bodyInput      textarea.Model
+	signatureInput textarea.Model
 	attachmentPath string
 	width          int
 	height         int
@@ -102,6 +104,16 @@ func NewComposer(from, to, subject, body string) *Composer {
 	m.bodyInput.SetHeight(10)
 	m.bodyInput.SetCursor(0)
 
+	m.signatureInput = textarea.New()
+	m.signatureInput.Cursor.Style = cursorStyle
+	m.signatureInput.Placeholder = "Signature (optional)..."
+	m.signatureInput.Prompt = "> "
+	m.signatureInput.SetHeight(3)
+	// Load default signature
+	if sig, err := config.LoadSignature(); err == nil && sig != "" {
+		m.signatureInput.SetValue(sig)
+	}
+
 	// Start focus on To field (From is selectable but not a text input)
 	m.focusIndex = focusTo
 	m.toInput.Focus()
@@ -164,6 +176,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.toInput.Width = inputWidth
 		m.subjectInput.Width = inputWidth
 		m.bodyInput.SetWidth(inputWidth)
+		m.signatureInput.SetWidth(inputWidth)
 
 	case SetComposerCursorToStartMsg:
 		m.bodyInput.SetCursor(0)
@@ -272,6 +285,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.toInput.Blur()
 			m.subjectInput.Blur()
 			m.bodyInput.Blur()
+			m.signatureInput.Blur()
 
 			switch m.focusIndex {
 			case focusTo:
@@ -281,6 +295,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			case focusBody:
 				cmds = append(cmds, m.bodyInput.Focus())
 				cmds = append(cmds, func() tea.Msg { return SetComposerCursorToStartMsg{} })
+			case focusSignature:
+				cmds = append(cmds, m.signatureInput.Focus())
 			}
 			return m, tea.Batch(cmds...)
 
@@ -309,6 +325,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 						QuotedText:     m.quotedText,
 						InReplyTo:      m.inReplyTo,
 						References:     m.references,
+						Signature:      m.signatureInput.Value(),
 					}
 				}
 			}
@@ -339,6 +356,9 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case focusBody:
 		m.bodyInput, cmd = m.bodyInput.Update(msg)
 		cmds = append(cmds, cmd)
+	case focusSignature:
+		m.signatureInput, cmd = m.signatureInput.Update(msg)
+		cmds = append(cmds, cmd)
 	}
 
 	return m, tea.Batch(cmds...)
@@ -399,12 +419,22 @@ func (m *Composer) View() string {
 		toFieldView = toFieldView + "\n" + suggestionBoxStyle.Render(strings.TrimSuffix(suggestionsBuilder.String(), "\n"))
 	}
 
+	// Signature field label
+	var signatureLabel string
+	if m.focusIndex == focusSignature {
+		signatureLabel = focusedStyle.Render("Signature:")
+	} else {
+		signatureLabel = blurredStyle.Render("Signature:")
+	}
+
 	composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left,
 		"Compose New Email",
 		fromField,
 		toFieldView,
 		m.subjectInput.View(),
 		m.bodyInput.View(),
+		signatureLabel,
+		m.signatureInput.View(),
 		attachmentStyle.Render(attachmentField),
 		button,
 		helpStyle.Render("Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit"),
@@ -502,6 +532,11 @@ func (m *Composer) GetAttachmentPath() string {
 	return m.attachmentPath
 }
 
+// GetSignature returns the current signature value.
+func (m *Composer) GetSignature() string {
+	return m.signatureInput.Value()
+}
+
 // SetReplyContext sets the reply context for the draft.
 func (m *Composer) SetReplyContext(inReplyTo string, references []string) {
 	m.inReplyTo = inReplyTo

tui/composer_test.go 🔗

@@ -36,18 +36,25 @@ func TestComposerUpdate(t *testing.T) {
 			t.Errorf("After two Tabs, focusIndex should be %d (focusBody), got %d", focusBody, composer.focusIndex)
 		}
 
+		// Simulate pressing Tab again to move to the 'Signature' field.
+		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
+		composer = model.(*Composer)
+		if composer.focusIndex != focusSignature {
+			t.Errorf("After three Tabs, focusIndex should be %d (focusSignature), got %d", focusSignature, composer.focusIndex)
+		}
+
 		// Simulate pressing Tab again to move to the 'Attachment' field.
 		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusAttachment {
-			t.Errorf("After three Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex)
+			t.Errorf("After four Tabs, focusIndex should be %d (focusAttachment), got %d", focusAttachment, composer.focusIndex)
 		}
 
 		// Simulate pressing Tab again to move to the 'Send' button.
 		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusSend {
-			t.Errorf("After four Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
+			t.Errorf("After five Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex)
 		}
 
 		// Simulate one more Tab to wrap around.
@@ -55,7 +62,7 @@ func TestComposerUpdate(t *testing.T) {
 		model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab})
 		composer = model.(*Composer)
 		if composer.focusIndex != focusTo {
-			t.Errorf("After five Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
+			t.Errorf("After six Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex)
 		}
 	})
 
@@ -168,12 +175,14 @@ func TestComposerUpdate(t *testing.T) {
 			t.Errorf("Initial focusIndex should be %d (focusTo), got %d", focusTo, multiComposer.focusIndex)
 		}
 
-		// Tab through all fields: To -> Subject -> Body -> Attachment -> Send -> From (wrap)
+		// Tab through all fields: To -> Subject -> Body -> Signature -> Attachment -> Send -> From (wrap)
 		model, _ := multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // To -> Subject
 		multiComposer = model.(*Composer)
 		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Subject -> Body
 		multiComposer = model.(*Composer)
-		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Body -> Attachment
+		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Body -> Signature
+		multiComposer = model.(*Composer)
+		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Signature -> Attachment
 		multiComposer = model.(*Composer)
 		model, _ = multiComposer.Update(tea.KeyMsg{Type: tea.KeyTab}) // Attachment -> Send
 		multiComposer = model.(*Composer)
@@ -182,7 +191,7 @@ func TestComposerUpdate(t *testing.T) {
 
 		// With multiple accounts, From field should be included in tab order
 		if multiComposer.focusIndex != focusFrom {
-			t.Errorf("After five Tabs with multi-account, focusIndex should wrap to %d (focusFrom), got %d", focusFrom, multiComposer.focusIndex)
+			t.Errorf("After six Tabs with multi-account, focusIndex should wrap to %d (focusFrom), got %d", focusFrom, multiComposer.focusIndex)
 		}
 
 		// One more Tab should go to To

tui/messages.go 🔗

@@ -28,6 +28,7 @@ type SendEmailMsg struct {
 	References     []string
 	AccountID      string // ID of the account to send from
 	QuotedText     string // Hidden quoted text appended when sending
+	Signature      string // Signature to append to email body
 }
 
 type Credentials struct {
@@ -72,6 +73,8 @@ type GoToSendMsg struct {
 
 type GoToSettingsMsg struct{}
 
+type GoToSignatureEditorMsg struct{}
+
 type FetchMoreEmailsMsg struct {
 	Offset    uint32
 	AccountID string

tui/settings.go 🔗

@@ -70,8 +70,8 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.cursor--
 			}
 		case "down", "j":
-			// +1 for "Add Account" option
-			if m.cursor < len(m.accounts) {
+			// +2 for "Add Account" and "Signature" options
+			if m.cursor < len(m.accounts)+1 {
 				m.cursor++
 			}
 		case "d":
@@ -84,6 +84,10 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			if m.cursor == len(m.accounts) {
 				return m, func() tea.Msg { return GoToAddAccountMsg{} }
 			}
+			// If cursor is on "Signature"
+			if m.cursor == len(m.accounts)+1 {
+				return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
+			}
 		case "esc":
 			return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
 		}
@@ -132,6 +136,18 @@ func (m *Settings) View() string {
 	} else {
 		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", addAccountText)))
 	}
+	b.WriteString("\n")
+
+	// Signature option
+	signatureText := "Edit Signature"
+	if config.HasSignature() {
+		signatureText = "Edit Signature (configured)"
+	}
+	if m.cursor == len(m.accounts)+1 {
+		b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", signatureText)))
+	} else {
+		b.WriteString(accountItemStyle.Render(fmt.Sprintf("  %s", signatureText)))
+	}
 	b.WriteString("\n\n")
 
 	b.WriteString(helpStyle.Render("↑/↓: navigate • enter: select • d: delete account • esc: back"))

tui/signature.go 🔗

@@ -0,0 +1,80 @@
+package tui
+
+import (
+	"github.com/charmbracelet/bubbles/textarea"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/floatpane/matcha/config"
+)
+
+// SignatureEditor displays the signature editing screen.
+type SignatureEditor struct {
+	textarea textarea.Model
+	width    int
+	height   int
+}
+
+// NewSignatureEditor creates a new signature editor model.
+func NewSignatureEditor() *SignatureEditor {
+	ta := textarea.New()
+	ta.Placeholder = "Enter your email signature...\n\nExample:\nBest regards,\nDrew"
+	ta.SetHeight(10)
+	ta.Focus()
+
+	// Load existing signature
+	if sig, err := config.LoadSignature(); err == nil && sig != "" {
+		ta.SetValue(sig)
+	}
+
+	return &SignatureEditor{
+		textarea: ta,
+	}
+}
+
+// Init initializes the signature editor model.
+func (m *SignatureEditor) Init() tea.Cmd {
+	return textarea.Blink
+}
+
+// Update handles messages for the signature editor model.
+func (m *SignatureEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.width = msg.Width
+		m.height = msg.Height
+		m.textarea.SetWidth(msg.Width - 4)
+		m.textarea.SetHeight(msg.Height - 10)
+		return m, nil
+
+	case tea.KeyMsg:
+		switch msg.Type {
+		case tea.KeyCtrlC:
+			return m, tea.Quit
+		case tea.KeyEsc:
+			// Save and go back to settings
+			signature := m.textarea.Value()
+			go config.SaveSignature(signature)
+			return m, func() tea.Msg { return GoToSettingsMsg{} }
+		}
+	}
+
+	m.textarea, cmd = m.textarea.Update(msg)
+	return m, cmd
+}
+
+// View renders the signature editor screen.
+func (m *SignatureEditor) View() string {
+	title := titleStyle.Render("Email Signature")
+	hint := accountEmailStyle.Render("This signature will be appended to your emails.")
+
+	return lipgloss.JoinVertical(lipgloss.Left,
+		title,
+		hint,
+		"",
+		m.textarea.View(),
+		"",
+		helpStyle.Render("esc: save & back"),
+	)
+}