diff --git a/config/cache.go b/config/cache.go index ace3b378c2e402fa9e6bb253c1fc19265fd77663..f3df4d07c44eb1b51a9b72022b33575da2428912 100644 --- a/config/cache.go +++ b/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) { diff --git a/config/config.go b/config/config.go index 5f483d57c8eea7833e97a888e737219dae89ebf4..0cb3e501b7e6cddceb3998e20cf62a2e6986aff5 100644 --- a/config/config.go +++ b/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, diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index 2a984af04b34fc849e7d3b699724049ec6a99471..a1f8523d4ee8cc899e6f890a5f2f53175be7ce7d 100644 --- a/docs/docs/Configuration.md +++ b/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"] + } ] } ``` diff --git a/docs/docs/Features/CONTACTS.md b/docs/docs/Features/CONTACTS.md index 2e096a63198062e7ae97628615ff16dd7870163f..ba7deabe81f372879129751b18a03a651a0b9f7d 100644 --- a/docs/docs/Features/CONTACTS.md +++ b/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. diff --git a/main.go b/main.go index a856e8f3a1ed89cfcf64626dc549b119089283ab..a66129af812fabe940fd78ba00d7e00870cad21c 100644 --- a/main.go +++ b/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}) diff --git a/tui/composer.go b/tui/composer.go index e523ea21c59da64815c834180168ec4421059ff4..85b9686de505c682def7163aaa44b7be956a856c 100644 --- a/tui/composer.go +++ b/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 { diff --git a/tui/mailing_list.go b/tui/mailing_list.go new file mode 100644 index 0000000000000000000000000000000000000000..5d18cbb1908e6c84d799fabb5715a5d464cdfd44 --- /dev/null +++ b/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"), + )) +} diff --git a/tui/messages.go b/tui/messages.go index 340c27d918f87682a562139c5a9985befb6b1469..7f68317107d7fe15bd66768ae42312d5af7ed64f 100644 --- a/tui/messages.go +++ b/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 diff --git a/tui/settings.go b/tui/settings.go index 40908910cd195de5ec6f6ae45a6fcb1c34557bef..855703e0831a59b71693c0d7660c695fe89fc258 100644 --- a/tui/settings.go +++ b/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