feat: mailing lists (#147) (#214)

Drew Smirnoff created

Change summary

config/cache.go                |  17 ++++
config/config.go               |  19 +++-
docs/docs/Configuration.md     |   6 +
docs/docs/Features/CONTACTS.md |  17 ++++
main.go                        |  27 +++++++
tui/composer.go                |  39 +++++++++-
tui/mailing_list.go            | 123 +++++++++++++++++++++++++++++++++
tui/messages.go                |   9 ++
tui/settings.go                | 134 +++++++++++++++++++++++++++++++++++
9 files changed, 379 insertions(+), 12 deletions(-)

Detailed changes

config/cache.go 🔗

@@ -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) {

config/config.go 🔗

@@ -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,

docs/docs/Configuration.md 🔗

@@ -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"]
+    }
   ]
 }
 ```

docs/docs/Features/CONTACTS.md 🔗

@@ -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.

main.go 🔗

@@ -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})

tui/composer.go 🔗

@@ -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 {

tui/mailing_list.go 🔗

@@ -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"),
+	))
+}

tui/messages.go 🔗

@@ -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

tui/settings.go 🔗

@@ -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