Detailed changes
@@ -70,6 +70,9 @@ type Account struct {
JMAPEndpoint string `json:"jmap_endpoint,omitempty"` // JMAP session URL (for protocol=jmap)
POP3Server string `json:"pop3_server,omitempty"` // POP3 server hostname (for protocol=pop3)
POP3Port int `json:"pop3_port,omitempty"` // POP3 server port (for protocol=pop3)
+
+ // Per-account signature (overrides global signature)
+ Signature string `json:"signature,omitempty"`
}
// MailingList represents a named group of email addresses.
@@ -5,7 +5,7 @@ import (
"path/filepath"
)
-// signatureFile returns the full path to the signature file.
+// signatureFile returns the full path to the global signature file.
func signatureFile() (string, error) {
dir, err := configDir()
if err != nil {
@@ -14,7 +14,16 @@ func signatureFile() (string, error) {
return filepath.Join(dir, "signature.txt"), nil
}
-// LoadSignature loads the signature from the signature file.
+// accountSignatureFile returns the path to the per-account signature file.
+func accountSignatureFile(accountID string) (string, error) {
+ dir, err := configDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, "signatures", accountID+".txt"), nil
+}
+
+// LoadSignature loads the signature from the global signature file.
func LoadSignature() (string, error) {
path, err := signatureFile()
if err != nil {
@@ -30,7 +39,43 @@ func LoadSignature() (string, error) {
return string(data), nil
}
-// SaveSignature saves the signature to the signature file.
+// LoadRawAccountSignature loads the per-account signature if one exists,
+// without falling back to the global signature.
+func LoadRawAccountSignature(account *Account) (string, error) {
+ if account == nil || account.ID == "" {
+ return "", nil
+ }
+
+ // Check for per-account signature file first
+ path, err := accountSignatureFile(account.ID)
+ if err != nil {
+ return "", err
+ }
+ data, err := SecureReadFile(path)
+ if err == nil && len(data) > 0 {
+ return string(data), nil
+ }
+
+ // Fall back to inline account signature
+ if account.Signature != "" {
+ return account.Signature, nil
+ }
+
+ return "", nil
+}
+
+// LoadSignatureForAccount loads the per-account signature if one exists,
+// otherwise falls back to the global signature.
+func LoadSignatureForAccount(account *Account) (string, error) {
+ sig, err := LoadRawAccountSignature(account)
+ if err == nil && sig != "" {
+ return sig, nil
+ }
+ // Fall back to global signature
+ return LoadSignature()
+}
+
+// SaveSignature saves the signature to the global signature file.
func SaveSignature(signature string) error {
path, err := signatureFile()
if err != nil {
@@ -42,7 +87,24 @@ func SaveSignature(signature string) error {
return SecureWriteFile(path, []byte(signature), 0600)
}
-// HasSignature checks if a signature file exists and is non-empty.
+// SaveSignatureForAccount saves a per-account signature file.
+func SaveSignatureForAccount(accountID, signature string) error {
+ path, err := accountSignatureFile(accountID)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return err
+ }
+ if signature == "" {
+ // Remove the file to fall back to global
+ os.Remove(path)
+ return nil
+ }
+ return SecureWriteFile(path, []byte(signature), 0600)
+}
+
+// HasSignature checks if a global signature file exists and is non-empty.
func HasSignature() bool {
sig, err := LoadSignature()
if err != nil {
@@ -50,3 +112,12 @@ func HasSignature() bool {
}
return sig != ""
}
+
+// HasAccountSignature checks if an account has its own signature (file or inline).
+func HasAccountSignature(account *Account) bool {
+ sig, err := LoadRawAccountSignature(account)
+ if err != nil {
+ return false
+ }
+ return sig != ""
+}
@@ -123,7 +123,7 @@
"title": "إعدادات الحسابات",
"no_accounts": "لم يتم تكوين حسابات.",
"add_account": "إضافة حساب جديد",
- "help": "↑/↓: التنقل • enter: تعديل إعدادات التشفير • e: تعديل الخادم • d: حذف"
+ "help": "↑/↓: التنقل • enter: تعديل إعدادات التشفير • e: تعديل الخادم • s: تعديل التوقيع • d: حذف"
},
"settings_theme": {
"title": "المظهر",
@@ -121,7 +121,7 @@
"title": "Kontoeinstellungen",
"no_accounts": "Keine Konten konfiguriert.",
"add_account": "Neues Konto Hinzufügen",
- "help": "↑/↓: navigieren • enter: Krypto-Konfig. bearbeiten • e: Server bearbeiten • d: löschen"
+ "help": "↑/↓: navigieren • enter: Krypto-Konfig. bearbeiten • e: Server bearbeiten • s: Signatur bearbeiten • d: löschen"
},
"settings_theme": {
"title": "Design",
@@ -121,7 +121,7 @@
"title": "Account Settings",
"no_accounts": "No accounts configured.",
"add_account": "Add New Account",
- "help": "↑/↓: navigate • enter: edit crypto config • e: edit server • d: delete"
+ "help": "↑/↓: navigate • enter: edit crypto config • e: edit server • s: edit signature • d: delete"
},
"settings_theme": {
"title": "Theme",
@@ -121,7 +121,7 @@
"title": "Configuración de Cuentas",
"no_accounts": "No hay cuentas configuradas.",
"add_account": "Agregar Nueva Cuenta",
- "help": "↑/↓: navegar • enter: editar config. de cifrado • e: editar servidor • d: eliminar"
+ "help": "↑/↓: navegar • enter: editar config. de cifrado • e: editar servidor • s: editar firma • d: eliminar"
},
"settings_theme": {
"title": "Tema",
@@ -121,7 +121,7 @@
"title": "Paramètres des Comptes",
"no_accounts": "Aucun compte configuré.",
"add_account": "Ajouter un Nouveau Compte",
- "help": "↑/↓: naviguer • entrée: modifier config. crypto • e: modifier serveur • d: supprimer"
+ "help": "↑/↓: naviguer • entrée: modifier config. crypto • e: modifier serveur • s: modifier signature • d: supprimer"
},
"settings_theme": {
"title": "Thème",
@@ -120,7 +120,7 @@
"title": "アカウント設定",
"no_accounts": "アカウントが設定されていません。",
"add_account": "新しいアカウントを追加",
- "help": "↑/↓: 移動 • enter: 暗号化設定を編集 • e: サーバーを編集 • d: 削除"
+ "help": "↑/↓: 移動 • enter: 暗号化設定を編集 • e: サーバーを編集 • s: 署名を編集 • d: 削除"
},
"settings_theme": {
"title": "テーマ",
@@ -123,7 +123,7 @@
"title": "Ustawienia Kont",
"no_accounts": "Brak skonfigurowanych kont.",
"add_account": "Dodaj Nowe Konto",
- "help": "↑/↓: nawigacja • enter: edytuj konfigurację szyfrowania • e: edytuj serwer • d: usuń"
+ "help": "↑/↓: nawigacja • enter: edytuj konfigurację szyfrowania • e: edytuj serwer • s: edytuj podpis • d: usuń"
},
"settings_theme": {
"title": "Motyw",
@@ -121,7 +121,7 @@
"title": "Configurações de Contas",
"no_accounts": "Nenhuma conta configurada.",
"add_account": "Adicionar Nova Conta",
- "help": "↑/↓: navegar • enter: editar config. de criptografia • e: editar servidor • d: excluir"
+ "help": "↑/↓: navegar • enter: editar config. de criptografia • e: editar servidor • s: editar assinatura • d: excluir"
},
"settings_theme": {
"title": "Tema",
@@ -123,7 +123,7 @@
"title": "Настройки Учётных Записей",
"no_accounts": "Учётные записи не настроены.",
"add_account": "Добавить Новую Учётную Запись",
- "help": "↑/↓: навигация • enter: редактировать конфигурацию шифрования • e: редактировать сервер • d: удалить"
+ "help": "↑/↓: навигация • enter: редактировать конфигурацию шифрования • e: редактировать сервер • s: редактировать подпись • d: удалить"
},
"settings_theme": {
"title": "Тема",
@@ -122,7 +122,7 @@
"title": "Налаштування облікових записів",
"no_accounts": "Облікові записи не налаштовано.",
"add_account": "Додати новий обліковий запис",
- "help": "↑/↓: навігація • enter: редагувати криптоконфіг • e: редагувати сервер • d: видалити"
+ "help": "↑/↓: навігація • enter: редагувати криптоконфіг • e: редагувати сервер • s: редагувати підпис • d: видалити"
},
"settings_theme": {
"title": "Тема",
@@ -120,7 +120,7 @@
"title": "账户设置",
"no_accounts": "未配置账户。",
"add_account": "添加新账户",
- "help": "↑/↓: 导航 • enter: 编辑加密配置 • e: 编辑服务器 • d: 删除"
+ "help": "↑/↓: 导航 • enter: 编辑加密配置 • e: 编辑服务器 • s: 编辑签名 • d: 删除"
},
"settings_theme": {
"title": "主题",
@@ -961,7 +961,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.current.Init()
case tui.GoToSignatureEditorMsg:
- m.current = tui.NewSignatureEditor()
+ m.current = tui.NewSignatureEditor(msg.AccountID)
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
return m, m.current.Init()
@@ -139,10 +139,7 @@ func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
m.signatureInput.Prompt = "> "
m.signatureInput.SetHeight(3)
m.signatureInput.SetStyles(taStyles)
- // Load default signature
- if sig, err := config.LoadSignature(); err == nil && sig != "" {
- m.signatureInput.SetValue(sig)
- }
+ m.updateSignature()
// Start focus on To field (From is selectable but not a text input)
m.focusIndex = focusTo
@@ -151,6 +148,22 @@ func NewComposer(from, to, subject, body string, hideTips bool) *Composer {
return m
}
+// updateSignature updates the signature input based on the current selected account.
+func (m *Composer) updateSignature() {
+ if len(m.accounts) > 0 && m.selectedAccountIdx < len(m.accounts) {
+ if sig, err := config.LoadSignatureForAccount(&m.accounts[m.selectedAccountIdx]); err == nil && sig != "" {
+ m.signatureInput.SetValue(sig)
+ return
+ }
+ }
+
+ if sig, err := config.LoadSignature(); err == nil && sig != "" {
+ m.signatureInput.SetValue(sig)
+ } else {
+ m.signatureInput.SetValue("")
+ }
+}
+
// NewComposerWithAccounts initializes a composer with multiple account support.
func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string, to, subject, body string, hideTips bool) *Composer {
m := NewComposer("", to, subject, body, hideTips)
@@ -163,6 +176,7 @@ func NewComposerWithAccounts(accounts []config.Account, selectedAccountID string
break
}
}
+ m.updateSignature()
return m
}
@@ -319,10 +333,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "up", "k":
if m.selectedAccountIdx > 0 {
m.selectedAccountIdx--
+ m.updateSignature()
}
case "down", "j":
if m.selectedAccountIdx < len(m.accounts)-1 {
m.selectedAccountIdx++
+ m.updateSignature()
}
case "enter":
m.showAccountPicker = false
@@ -698,6 +714,7 @@ func (m *Composer) SetAccounts(accounts []config.Account) {
if m.selectedAccountIdx >= len(accounts) {
m.selectedAccountIdx = 0
}
+ m.updateSignature()
}
// SetSelectedAccount sets the selected account by ID.
@@ -705,6 +722,7 @@ func (m *Composer) SetSelectedAccount(accountID string) {
for i, acc := range m.accounts {
if acc.ID == accountID {
m.selectedAccountIdx = i
+ m.updateSignature()
return
}
}
@@ -102,7 +102,9 @@ type GoToSettingsMsg struct{}
type GoToTrashArchiveMsg struct{}
-type GoToSignatureEditorMsg struct{}
+type GoToSignatureEditorMsg struct {
+ AccountID string
+}
type FetchMoreEmailsMsg struct {
Offset uint32
@@ -7,6 +7,7 @@ import (
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
+ "github.com/floatpane/matcha/config"
)
func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
@@ -70,6 +71,10 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
}
}
+ case "s": // Edit account signature
+ if m.accountsCursor < len(m.cfg.Accounts) {
+ return m, func() tea.Msg { return GoToSignatureEditorMsg{AccountID: m.cfg.Accounts[m.accountsCursor].ID} }
+ }
case "c": // Quick shortcut to crypto config
if m.accountsCursor < len(m.cfg.Accounts) {
m.enterCryptoConfig()
@@ -139,6 +144,9 @@ func (m *Settings) viewAccounts() string {
if account.PGPPublicKey != "" && account.PGPPrivateKey != "" {
providerInfo += " [PGP Configured]"
}
+ if config.HasAccountSignature(&account) {
+ providerInfo += " [Signature]"
+ }
line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo))
@@ -9,58 +9,106 @@ import (
"github.com/floatpane/matcha/i18n"
)
+type generalOption struct {
+ labelKey string
+ value string
+ tip string
+ isAccountSig bool
+ accountID string
+}
+
+func (m *Settings) buildGeneralOptions() []generalOption {
+ opts := []generalOption{
+ {"settings_general.disable_images", onOff(m.cfg.DisableImages), "Prevent images from loading automatically in emails.", false, ""},
+ {"settings_general.hide_tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen.", false, ""},
+ {"settings_general.disable_notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail.", false, ""},
+ {"settings_general.date_format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed.", false, ""},
+ {"settings_general.language", getLanguageLabel(m.cfg.GetLanguage()), "Change the interface language. Changes apply instantly.", false, ""},
+ {"settings_general.signature", getSignatureStatus(), "Configure the global signature appended to your outgoing emails.", false, ""},
+ }
+
+ for _, acc := range m.cfg.Accounts {
+ status := t("settings_general.signature_not_configured")
+ accCopy := acc // capture for pointer safety
+ if config.HasAccountSignature(&accCopy) {
+ status = t("settings_general.signature_configured")
+ }
+ opts = append(opts, generalOption{
+ labelKey: fmt.Sprintf("Signature (%s)", acc.Email),
+ value: status,
+ tip: fmt.Sprintf("Configure the signature for %s", acc.Email),
+ isAccountSig: true,
+ accountID: acc.ID,
+ })
+ }
+
+ return opts
+}
+
func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ opts := m.buildGeneralOptions()
+
switch msg.String() {
case "up", "k":
if m.generalCursor > 0 {
m.generalCursor--
}
case "down", "j":
- if m.generalCursor < 5 {
+ if m.generalCursor < len(opts)-1 {
m.generalCursor++
}
case "enter", "space", "right", "l":
- switch m.generalCursor {
- case 0: // Image Display
- m.cfg.DisableImages = !m.cfg.DisableImages
- _ = config.SaveConfig(m.cfg)
- case 1: // Contextual Tips
- m.cfg.HideTips = !m.cfg.HideTips
- _ = config.SaveConfig(m.cfg)
- case 2: // Desktop Notifications
- m.cfg.DisableNotifications = !m.cfg.DisableNotifications
- _ = config.SaveConfig(m.cfg)
- case 3: // Date Format
- switch m.cfg.DateFormat {
- case config.DateFormatEU:
- m.cfg.DateFormat = config.DateFormatUS
- case config.DateFormatUS:
- m.cfg.DateFormat = config.DateFormatISO
- default: // or ISO
- m.cfg.DateFormat = config.DateFormatEU
- }
- _ = config.SaveConfig(m.cfg)
- case 4: // Language
- // Cycle through available languages
- langs := i18n.LanguageCodes()
- currentLang := m.cfg.GetLanguage()
- currentIdx := -1
- for i, lang := range langs {
- if lang == currentLang {
- currentIdx = i
- break
+ if m.generalCursor < len(opts) {
+ opt := opts[m.generalCursor]
+ if opt.isAccountSig {
+ if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" {
+ return m, func() tea.Msg { return GoToSignatureEditorMsg{AccountID: opt.accountID} }
}
+ return m, nil
}
- nextIdx := (currentIdx + 1) % len(langs)
- m.cfg.Language = langs[nextIdx]
- _ = config.SaveConfig(m.cfg)
- // Apply language change immediately
- i18n.GetManager().SetLanguage(m.cfg.Language)
- // Trigger full UI rebuild
- return m, func() tea.Msg { return LanguageChangedMsg{} }
- case 5: // Edit Signature
- if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" {
- return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
+
+ switch m.generalCursor {
+ case 0: // Image Display
+ m.cfg.DisableImages = !m.cfg.DisableImages
+ _ = config.SaveConfig(m.cfg)
+ case 1: // Contextual Tips
+ m.cfg.HideTips = !m.cfg.HideTips
+ _ = config.SaveConfig(m.cfg)
+ case 2: // Desktop Notifications
+ m.cfg.DisableNotifications = !m.cfg.DisableNotifications
+ _ = config.SaveConfig(m.cfg)
+ case 3: // Date Format
+ switch m.cfg.DateFormat {
+ case config.DateFormatEU:
+ m.cfg.DateFormat = config.DateFormatUS
+ case config.DateFormatUS:
+ m.cfg.DateFormat = config.DateFormatISO
+ default: // or ISO
+ m.cfg.DateFormat = config.DateFormatEU
+ }
+ _ = config.SaveConfig(m.cfg)
+ case 4: // Language
+ // Cycle through available languages
+ langs := i18n.LanguageCodes()
+ currentLang := m.cfg.GetLanguage()
+ currentIdx := -1
+ for i, lang := range langs {
+ if lang == currentLang {
+ currentIdx = i
+ break
+ }
+ }
+ nextIdx := (currentIdx + 1) % len(langs)
+ m.cfg.Language = langs[nextIdx]
+ _ = config.SaveConfig(m.cfg)
+ // Apply language change immediately
+ i18n.GetManager().SetLanguage(m.cfg.Language)
+ // Trigger full UI rebuild
+ return m, func() tea.Msg { return LanguageChangedMsg{} }
+ case 5: // Edit Signature
+ if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" {
+ return m, func() tea.Msg { return GoToSignatureEditorMsg{} }
+ }
}
}
}
@@ -72,18 +120,7 @@ func (m *Settings) viewGeneral() string {
b.WriteString(titleStyle.Render("General Settings") + "\n\n")
- options := []struct {
- labelKey string
- value string
- tip string
- }{
- {"settings_general.disable_images", onOff(m.cfg.DisableImages), "Prevent images from loading automatically in emails."},
- {"settings_general.hide_tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen."},
- {"settings_general.disable_notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail."},
- {"settings_general.date_format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed."},
- {"settings_general.language", getLanguageLabel(m.cfg.GetLanguage()), "Change the interface language. Changes apply instantly."},
- {"settings_general.signature", getSignatureStatus(), "Configure the signature appended to your outgoing emails."},
- }
+ options := m.buildGeneralOptions()
for i, opt := range options {
cursor := " "
@@ -93,9 +130,12 @@ func (m *Settings) viewGeneral() string {
style = selectedAccountItemStyle
}
- label := t(opt.labelKey)
+ label := opt.labelKey
+ if !opt.isAccountSig {
+ label = t(opt.labelKey)
+ }
text := fmt.Sprintf("%s: %s", label, opt.value)
- if opt.labelKey == "settings_general.signature" {
+ if opt.labelKey == "settings_general.signature" || opt.isAccountSig {
text = fmt.Sprintf("%s (%s)", label, opt.value)
}
@@ -9,13 +9,14 @@ import (
// SignatureEditor displays the signature editing screen.
type SignatureEditor struct {
- textarea textarea.Model
- width int
- height int
+ textarea textarea.Model
+ accountID string
+ width int
+ height int
}
// NewSignatureEditor creates a new signature editor model.
-func NewSignatureEditor() *SignatureEditor {
+func NewSignatureEditor(accountID string) *SignatureEditor {
ta := textarea.New()
ta.Placeholder = "Enter your email signature...\n\nExample:\nBest regards,\nDrew"
ta.SetHeight(10)
@@ -23,12 +24,19 @@ func NewSignatureEditor() *SignatureEditor {
ta.Focus()
// Load existing signature
- if sig, err := config.LoadSignature(); err == nil && sig != "" {
- ta.SetValue(sig)
+ if accountID != "" {
+ if sig, err := config.LoadRawAccountSignature(&config.Account{ID: accountID}); err == nil && sig != "" {
+ ta.SetValue(sig)
+ }
+ } else {
+ if sig, err := config.LoadSignature(); err == nil && sig != "" {
+ ta.SetValue(sig)
+ }
}
return &SignatureEditor{
- textarea: ta,
+ textarea: ta,
+ accountID: accountID,
}
}
@@ -56,7 +64,11 @@ func (m *SignatureEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "esc":
// Save and go back to settings
signature := m.textarea.Value()
- go config.SaveSignature(signature)
+ if m.accountID != "" {
+ go config.SaveSignatureForAccount(m.accountID, signature)
+ } else {
+ go config.SaveSignature(signature)
+ }
return m, func() tea.Msg { return GoToSettingsMsg{} }
}
}