Detailed changes
@@ -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 != ""
+}
@@ -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}
@@ -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
@@ -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
@@ -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
@@ -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"))
@@ -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"),
+ )
+}