From ac0c86ed476041db0da9a48c0d76ccda059abf5f Mon Sep 17 00:00:00 2001 From: drew Date: Sun, 25 Jan 2026 17:15:13 +0400 Subject: [PATCH 1/2] feat: signatures (#122) --- config/signature.go | 52 +++++++++++++++++++++++++++++ main.go | 11 ++++++- tui/composer.go | 35 ++++++++++++++++++++ tui/messages.go | 3 ++ tui/settings.go | 20 ++++++++++-- tui/signature.go | 80 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 config/signature.go create mode 100644 tui/signature.go diff --git a/config/signature.go b/config/signature.go new file mode 100644 index 0000000..e070f18 --- /dev/null +++ b/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 != "" +} diff --git a/main.go b/main.go index a677d0e..bdffa9c 100644 --- a/main.go +++ b/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} diff --git a/tui/composer.go b/tui/composer.go index 533700f..8cc8283 100644 --- a/tui/composer.go +++ b/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 diff --git a/tui/messages.go b/tui/messages.go index 45e862d..c9e175b 100644 --- a/tui/messages.go +++ b/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 diff --git a/tui/settings.go b/tui/settings.go index aef5cca..4b1237f 100644 --- a/tui/settings.go +++ b/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")) diff --git a/tui/signature.go b/tui/signature.go new file mode 100644 index 0000000..f9da04b --- /dev/null +++ b/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"), + ) +} From 8550ef19de40e55f7a871ea38a2b0365f9ad502b Mon Sep 17 00:00:00 2001 From: drew Date: Sun, 25 Jan 2026 17:27:22 +0400 Subject: [PATCH 2/2] fix(test): appended tab switch tests by 1 --- tui/composer_test.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tui/composer_test.go b/tui/composer_test.go index 257a1f1..9927955 100644 --- a/tui/composer_test.go +++ b/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