Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions config/signature.go
Original file line number Diff line number Diff line change
@@ -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 != ""
}
11 changes: 10 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
35 changes: 35 additions & 0 deletions tui/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const (
focusTo
focusSubject
focusBody
focusSignature
focusAttachment
focusSend
)
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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...)

Expand Down Expand Up @@ -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(),
}
}
}
Expand Down Expand Up @@ -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...)
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down
21 changes: 15 additions & 6 deletions tui/composer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,26 +36,33 @@ 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.
// With single account, From field is skipped, so it wraps to focusTo.
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)
}
})

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions tui/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -72,6 +73,8 @@ type GoToSendMsg struct {

type GoToSettingsMsg struct{}

type GoToSignatureEditorMsg struct{}

type FetchMoreEmailsMsg struct {
Offset uint32
AccountID string
Expand Down
20 changes: 18 additions & 2 deletions tui/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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{} }
}
Expand Down Expand Up @@ -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"))
Expand Down
80 changes: 80 additions & 0 deletions tui/signature.go
Original file line number Diff line number Diff line change
@@ -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"),
)
}