Detailed changes
@@ -202,6 +202,23 @@ func SearchContacts(query string) []Contact {
}
var matches []Contact
+
+ // Add mailing lists to matches if they match the query
+ cfg, err := LoadConfig()
+ if err == nil {
+ for _, list := range cfg.MailingLists {
+ if strings.Contains(strings.ToLower(list.Name), query) {
+ // Convert mailing list to a virtual contact
+ matches = append(matches, Contact{
+ Name: list.Name,
+ Email: strings.Join(list.Addresses, ", "),
+ UseCount: 9999, // Ensure lists appear at the top
+ LastUsed: time.Now(),
+ })
+ }
+ }
+ }
+
for _, c := range cache.Contacts {
if strings.Contains(strings.ToLower(c.Email), query) ||
strings.Contains(strings.ToLower(c.Name), query) {
@@ -29,11 +29,18 @@ type Account struct {
SMTPPort int `json:"smtp_port,omitempty"`
}
+// MailingList represents a named group of email addresses.
+type MailingList struct {
+ Name string `json:"name"`
+ Addresses []string `json:"addresses"`
+}
+
// Config stores the user's email configuration with multiple accounts.
type Config struct {
- Accounts []Account `json:"accounts"`
- DisableImages bool `json:"disable_images,omitempty"`
- HideTips bool `json:"hide_tips,omitempty"`
+ Accounts []Account `json:"accounts"`
+ DisableImages bool `json:"disable_images,omitempty"`
+ HideTips bool `json:"hide_tips,omitempty"`
+ MailingLists []MailingList `json:"mailing_lists,omitempty"`
}
// GetIMAPServer returns the IMAP server address for the account.
@@ -165,8 +172,9 @@ func LoadConfig() (*Config, error) {
SMTPPort int `json:"smtp_port,omitempty"`
}
type diskConfig struct {
- Accounts []rawAccount `json:"accounts"`
- DisableImages bool `json:"disable_images,omitempty"`
+ Accounts []rawAccount `json:"accounts"`
+ DisableImages bool `json:"disable_images,omitempty"`
+ MailingLists []MailingList `json:"mailing_lists,omitempty"`
}
var raw diskConfig
@@ -195,6 +203,7 @@ func LoadConfig() (*Config, error) {
}
config.DisableImages = raw.DisableImages
+ config.MailingLists = raw.MailingLists
for _, rawAcc := range raw.Accounts {
acc := Account{
ID: rawAcc.ID,
@@ -27,6 +27,12 @@ Configuration is stored in `~/.config/matcha/config.json`.
"smtp_server": "smtp.company.com",
"smtp_port": 587
}
+ ],
+ "mailing_lists": [
+ {
+ "name": "Team",
+ "addresses": ["alice@example.com", "bob@example.com"]
+ }
]
}
```
@@ -8,3 +8,20 @@ Matcha keeps your contacts organized and easily accessible.
- **🔍 Smart Search**: Fuzzy search through your contacts while composing.
- **⚡ Quick Autocomplete**: Contact suggestions appear as you type in the "To" field.
- **💾 Persistent Storage**: Contacts are saved locally for offline access.
+
+## Mailing Lists
+
+You can easily define mailing lists to send emails to the same multiple recipients. These are added directly to your `~/.config/matcha/config.json`.
+
+```json
+{
+ "mailing_lists": [
+ {
+ "name": "Team",
+ "addresses": ["alice@example.com", "bob@example.com"]
+ }
+ ]
+}
+```
+
+Once defined, you can just type the name of your mailing list (e.g., `Team`) in the "To" field and hit `Tab` or `Enter` to auto-complete the list of addresses.
@@ -544,6 +544,33 @@ 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.GoToAddMailingListMsg:
+ m.current = tui.NewMailingListEditor()
+ m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
+ return m, m.current.Init()
+
+ case tui.SaveMailingListMsg:
+ if m.config != nil {
+ var addrs []string
+ for _, part := range strings.Split(msg.Addresses, ",") {
+ if trimmed := strings.TrimSpace(part); trimmed != "" {
+ addrs = append(addrs, trimmed)
+ }
+ }
+ m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
+ Name: msg.Name,
+ Addresses: addrs,
+ })
+ if err := config.SaveConfig(m.config); err != nil {
+ log.Printf("could not save config: %v", err)
+ }
+ }
+ // Return to settings
+ m.current = tui.NewSettings(m.config)
+ // Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
+ 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})
@@ -211,11 +211,35 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "tab", "enter":
// Select the suggestion
selected := m.suggestions[m.selectedSuggestion]
- if selected.Name != "" && selected.Name != selected.Email {
- m.toInput.SetValue(fmt.Sprintf("%s <%s>", selected.Name, selected.Email))
+
+ var newEmail string
+ if strings.Contains(selected.Email, ",") {
+ // It's a mailing list: insert just the addresses to maintain valid email formatting
+ newEmail = selected.Email
+ } else if selected.Name != "" && selected.Name != selected.Email {
+ newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
+ } else {
+ newEmail = selected.Email
+ }
+
+ parts := strings.Split(m.toInput.Value(), ",")
+ if len(parts) > 0 {
+ if len(parts) == 1 {
+ parts[0] = newEmail
+ } else {
+ parts[len(parts)-1] = " " + newEmail
+ }
} else {
- m.toInput.SetValue(selected.Email)
+ parts = []string{newEmail}
}
+
+ finalValue := strings.Join(parts, ",")
+ if !strings.HasSuffix(finalValue, ", ") {
+ finalValue += ", "
+ }
+
+ m.toInput.SetValue(finalValue)
+ m.toInput.SetCursor(len(finalValue))
m.lastToValue = m.toInput.Value()
m.showSuggestions = false
m.suggestions = nil
@@ -356,8 +380,13 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
currentValue := m.toInput.Value()
if currentValue != m.lastToValue {
m.lastToValue = currentValue
- if len(currentValue) >= 2 {
- m.suggestions = config.SearchContacts(currentValue)
+
+ // Extract the last comma-separated part for searching
+ parts := strings.Split(currentValue, ",")
+ lastPart := strings.TrimSpace(parts[len(parts)-1])
+
+ if len(lastPart) >= 2 {
+ m.suggestions = config.SearchContacts(lastPart)
m.showSuggestions = len(m.suggestions) > 0
m.selectedSuggestion = 0
} else {
@@ -0,0 +1,123 @@
+package tui
+
+import (
+ "strings"
+
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+)
+
+// MailingListEditor displays the screen to add a new mailing list.
+type MailingListEditor struct {
+ nameInput textinput.Model
+ addrInput textinput.Model
+ focus int // 0 = name, 1 = addresses
+ width int
+ height int
+}
+
+// NewMailingListEditor creates a new mailing list editor model.
+func NewMailingListEditor() *MailingListEditor {
+ name := textinput.New()
+ name.Placeholder = "e.g., Team"
+ name.Focus()
+
+ addr := textinput.New()
+ addr.Placeholder = "e.g., alice@example.com, bob@example.com"
+
+ return &MailingListEditor{
+ nameInput: name,
+ addrInput: addr,
+ focus: 0,
+ }
+}
+
+// Init initializes the mailing list editor model.
+func (m *MailingListEditor) Init() tea.Cmd {
+ return textinput.Blink
+}
+
+// Update handles messages for the mailing list editor model.
+func (m *MailingListEditor) 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.nameInput.SetWidth(msg.Width - 4)
+ m.addrInput.SetWidth(msg.Width - 4)
+ return m, nil
+
+ case tea.KeyPressMsg:
+ switch msg.String() {
+ case "ctrl+c":
+ return m, tea.Quit
+ case "esc":
+ return m, func() tea.Msg { return GoToSettingsMsg{} }
+ case "tab", "shift+tab", "up", "down":
+ if m.focus == 0 {
+ m.focus = 1
+ m.nameInput.Blur()
+ m.addrInput.Focus()
+ } else {
+ m.focus = 0
+ m.addrInput.Blur()
+ m.nameInput.Focus()
+ }
+ return m, nil
+ case "enter":
+ if m.focus == 0 {
+ m.focus = 1
+ m.nameInput.Blur()
+ m.addrInput.Focus()
+ return m, nil
+ } else {
+ // Submit on second field
+ name := strings.TrimSpace(m.nameInput.Value())
+ addrs := strings.TrimSpace(m.addrInput.Value())
+ if name != "" && addrs != "" {
+ return m, func() tea.Msg {
+ return SaveMailingListMsg{
+ Name: name,
+ Addresses: addrs,
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if m.focus == 0 {
+ m.nameInput, cmd = m.nameInput.Update(msg)
+ } else {
+ m.addrInput, cmd = m.addrInput.Update(msg)
+ }
+
+ return m, cmd
+}
+
+// View renders the mailing list editor screen.
+func (m *MailingListEditor) View() tea.View {
+ title := titleStyle.Render("Add Mailing List")
+
+ var nameView, addrView string
+ if m.focus == 0 {
+ nameView = focusedStyle.Render("Name:") + "\n" + m.nameInput.View()
+ addrView = blurredStyle.Render("Addresses (comma-separated):") + "\n" + m.addrInput.View()
+ } else {
+ nameView = blurredStyle.Render("Name:") + "\n" + m.nameInput.View()
+ addrView = focusedStyle.Render("Addresses (comma-separated):") + "\n" + m.addrInput.View()
+ }
+
+ return tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
+ title,
+ "",
+ nameView,
+ "",
+ addrView,
+ "",
+ helpStyle.Render("tab/↑/↓: switch fields • enter: submit • esc: back"),
+ ))
+}
@@ -179,6 +179,15 @@ type EmailBodyFetchedMsg struct {
// GoToAddAccountMsg signals navigation to the add account screen.
type GoToAddAccountMsg struct{}
+// GoToAddMailingListMsg signals navigation to the add mailing list screen.
+type GoToAddMailingListMsg struct{}
+
+// SaveMailingListMsg signals that a new mailing list should be saved.
+type SaveMailingListMsg struct {
+ Name string
+ Addresses string
+}
+
// AddAccountMsg signals that a new account should be added.
type AddAccountMsg struct {
Credentials Credentials
@@ -21,6 +21,7 @@ type SettingsState int
const (
SettingsMain SettingsState = iota
SettingsAccounts
+ SettingsMailingLists
)
// Settings displays the settings screen.
@@ -61,6 +62,8 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
if m.state == SettingsMain {
return m.updateMain(msg)
+ } else if m.state == SettingsMailingLists {
+ return m.updateMailingLists(msg)
} else {
return m.updateAccounts(msg)
}
@@ -75,8 +78,8 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.cursor--
}
case "down", "j":
- // Options: 0: Email Accounts, 1: Image Display, 2: Edit Signature, 3: Contextual Tips
- if m.cursor < 3 {
+ // Options: 0: Email Accounts, 1: Image Display, 2: Edit Signature, 3: Contextual Tips, 4: Mailing Lists
+ if m.cursor < 4 {
m.cursor++
}
case "enter":
@@ -97,6 +100,10 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
// Save config immediately
_ = config.SaveConfig(m.cfg)
return m, nil
+ case 4: // Mailing Lists
+ m.state = SettingsMailingLists
+ m.cursor = 0
+ return m, nil
}
case "esc":
return m, func() tea.Msg { return GoToChoiceMenuMsg{} }
@@ -150,10 +157,56 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
return m, nil
}
+func (m *Settings) updateMailingLists(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
+ if m.confirmingDelete {
+ switch msg.String() {
+ case "y", "Y":
+ if m.cursor < len(m.cfg.MailingLists) {
+ m.cfg.MailingLists = append(m.cfg.MailingLists[:m.cursor], m.cfg.MailingLists[m.cursor+1:]...)
+ _ = config.SaveConfig(m.cfg)
+ if m.cursor >= len(m.cfg.MailingLists) && m.cursor > 0 {
+ m.cursor--
+ }
+ m.confirmingDelete = false
+ }
+ case "n", "N", "esc":
+ m.confirmingDelete = false
+ return m, nil
+ }
+ return m, nil
+ }
+
+ switch msg.String() {
+ case "up", "k":
+ if m.cursor > 0 {
+ m.cursor--
+ }
+ case "down", "j":
+ if m.cursor < len(m.cfg.MailingLists) {
+ m.cursor++
+ }
+ case "d":
+ if m.cursor < len(m.cfg.MailingLists) && len(m.cfg.MailingLists) > 0 {
+ m.confirmingDelete = true
+ }
+ case "enter":
+ if m.cursor == len(m.cfg.MailingLists) {
+ return m, func() tea.Msg { return GoToAddMailingListMsg{} }
+ }
+ case "esc":
+ m.state = SettingsMain
+ m.cursor = 0
+ return m, nil
+ }
+ return m, nil
+}
+
// View renders the settings screen.
func (m *Settings) View() tea.View {
if m.state == SettingsMain {
return tea.NewView(m.viewMain())
+ } else if m.state == SettingsMailingLists {
+ return tea.NewView(m.viewMailingLists())
}
return tea.NewView(m.viewAccounts())
}
@@ -207,6 +260,15 @@ func (m *Settings) viewMain() string {
} else {
b.WriteString(accountItemStyle.Render(" " + tipsText))
}
+ b.WriteString("\n")
+
+ // Option 4: Mailing Lists
+ mailingListsText := "Mailing Lists"
+ if m.cursor == 4 {
+ b.WriteString(selectedAccountItemStyle.Render("> " + mailingListsText))
+ } else {
+ b.WriteString(accountItemStyle.Render(" " + mailingListsText))
+ }
b.WriteString("\n\n")
if !m.cfg.HideTips {
@@ -220,6 +282,8 @@ func (m *Settings) viewMain() string {
tip = "Configure the signature appended to your outgoing emails."
case 3:
tip = "Toggle displaying helpful contextual tips like this one."
+ case 4:
+ tip = "Manage groups of email addresses to quickly send to multiple people."
}
if tip != "" {
b.WriteString(TipStyle.Render("Tip: "+tip) + "\n\n")
@@ -311,6 +375,72 @@ func (m *Settings) viewAccounts() string {
return docStyle.Render(mainContent + helpView)
}
+func (m *Settings) viewMailingLists() string {
+ var b strings.Builder
+
+ b.WriteString(titleStyle.Render("Mailing Lists"))
+ b.WriteString("\n\n")
+
+ if len(m.cfg.MailingLists) == 0 {
+ b.WriteString(accountEmailStyle.Render(" No mailing lists configured.\n"))
+ b.WriteString("\n")
+ }
+
+ for i, list := range m.cfg.MailingLists {
+ displayName := list.Name
+
+ addrCount := fmt.Sprintf("%d address", len(list.Addresses))
+ if len(list.Addresses) != 1 {
+ addrCount += "es"
+ }
+
+ line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(addrCount))
+
+ if m.cursor == i {
+ b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", line)))
+ } else {
+ b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", line)))
+ }
+ b.WriteString("\n")
+ }
+
+ // Add Mailing List option
+ addListText := "Add New Mailing List"
+ if m.cursor == len(m.cfg.MailingLists) {
+ b.WriteString(selectedAccountItemStyle.Render(fmt.Sprintf("> %s", addListText)))
+ } else {
+ b.WriteString(accountItemStyle.Render(fmt.Sprintf(" %s", addListText)))
+ }
+ b.WriteString("\n")
+
+ helpView := helpStyle.Render("↑/↓: navigate • enter: select • d: delete • esc: back")
+ mainContent := b.String()
+
+ if m.height > 0 {
+ contentHeight := strings.Count(mainContent, "\n") + 1
+ gap := m.height - contentHeight - 2 // -2 for margins
+ if gap > 0 {
+ mainContent += strings.Repeat("\n", gap)
+ }
+ } else {
+ mainContent += "\n\n"
+ }
+
+ if m.confirmingDelete {
+ listName := m.cfg.MailingLists[m.cursor].Name
+ dialog := DialogBoxStyle.Render(
+ lipgloss.JoinVertical(lipgloss.Center,
+ dangerStyle.Render("Delete mailing list?"),
+ accountEmailStyle.Render(listName),
+ HelpStyle.Render("\n(y/n)"),
+ ),
+ )
+ return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog)
+ }
+
+ return docStyle.Render(mainContent + helpView)
+}
+
// UpdateConfig updates the configuration (used when accounts are deleted).
func (m *Settings) UpdateConfig(cfg *config.Config) {
m.cfg = cfg