Skip to content

Commit 4a9a70b

Browse files
authored
feat: mailing lists (#147) (#214)
1 parent f5595b5 commit 4a9a70b

9 files changed

Lines changed: 379 additions & 12 deletions

File tree

config/cache.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,23 @@ func SearchContacts(query string) []Contact {
202202
}
203203

204204
var matches []Contact
205+
206+
// Add mailing lists to matches if they match the query
207+
cfg, err := LoadConfig()
208+
if err == nil {
209+
for _, list := range cfg.MailingLists {
210+
if strings.Contains(strings.ToLower(list.Name), query) {
211+
// Convert mailing list to a virtual contact
212+
matches = append(matches, Contact{
213+
Name: list.Name,
214+
Email: strings.Join(list.Addresses, ", "),
215+
UseCount: 9999, // Ensure lists appear at the top
216+
LastUsed: time.Now(),
217+
})
218+
}
219+
}
220+
}
221+
205222
for _, c := range cache.Contacts {
206223
if strings.Contains(strings.ToLower(c.Email), query) ||
207224
strings.Contains(strings.ToLower(c.Name), query) {

config/config.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,18 @@ type Account struct {
2929
SMTPPort int `json:"smtp_port,omitempty"`
3030
}
3131

32+
// MailingList represents a named group of email addresses.
33+
type MailingList struct {
34+
Name string `json:"name"`
35+
Addresses []string `json:"addresses"`
36+
}
37+
3238
// Config stores the user's email configuration with multiple accounts.
3339
type Config struct {
34-
Accounts []Account `json:"accounts"`
35-
DisableImages bool `json:"disable_images,omitempty"`
36-
HideTips bool `json:"hide_tips,omitempty"`
40+
Accounts []Account `json:"accounts"`
41+
DisableImages bool `json:"disable_images,omitempty"`
42+
HideTips bool `json:"hide_tips,omitempty"`
43+
MailingLists []MailingList `json:"mailing_lists,omitempty"`
3744
}
3845

3946
// GetIMAPServer returns the IMAP server address for the account.
@@ -165,8 +172,9 @@ func LoadConfig() (*Config, error) {
165172
SMTPPort int `json:"smtp_port,omitempty"`
166173
}
167174
type diskConfig struct {
168-
Accounts []rawAccount `json:"accounts"`
169-
DisableImages bool `json:"disable_images,omitempty"`
175+
Accounts []rawAccount `json:"accounts"`
176+
DisableImages bool `json:"disable_images,omitempty"`
177+
MailingLists []MailingList `json:"mailing_lists,omitempty"`
170178
}
171179

172180
var raw diskConfig
@@ -195,6 +203,7 @@ func LoadConfig() (*Config, error) {
195203
}
196204

197205
config.DisableImages = raw.DisableImages
206+
config.MailingLists = raw.MailingLists
198207
for _, rawAcc := range raw.Accounts {
199208
acc := Account{
200209
ID: rawAcc.ID,

docs/docs/Configuration.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ Configuration is stored in `~/.config/matcha/config.json`.
2727
"smtp_server": "smtp.company.com",
2828
"smtp_port": 587
2929
}
30+
],
31+
"mailing_lists": [
32+
{
33+
"name": "Team",
34+
"addresses": ["alice@example.com", "bob@example.com"]
35+
}
3036
]
3137
}
3238
```

docs/docs/Features/CONTACTS.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,20 @@ Matcha keeps your contacts organized and easily accessible.
88
- **🔍 Smart Search**: Fuzzy search through your contacts while composing.
99
- **⚡ Quick Autocomplete**: Contact suggestions appear as you type in the "To" field.
1010
- **💾 Persistent Storage**: Contacts are saved locally for offline access.
11+
12+
## Mailing Lists
13+
14+
You can easily define mailing lists to send emails to the same multiple recipients. These are added directly to your `~/.config/matcha/config.json`.
15+
16+
```json
17+
{
18+
"mailing_lists": [
19+
{
20+
"name": "Team",
21+
"addresses": ["alice@example.com", "bob@example.com"]
22+
}
23+
]
24+
}
25+
```
26+
27+
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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,33 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
544544
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
545545
return m, m.current.Init()
546546

547+
case tui.GoToAddMailingListMsg:
548+
m.current = tui.NewMailingListEditor()
549+
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
550+
return m, m.current.Init()
551+
552+
case tui.SaveMailingListMsg:
553+
if m.config != nil {
554+
var addrs []string
555+
for _, part := range strings.Split(msg.Addresses, ",") {
556+
if trimmed := strings.TrimSpace(part); trimmed != "" {
557+
addrs = append(addrs, trimmed)
558+
}
559+
}
560+
m.config.MailingLists = append(m.config.MailingLists, config.MailingList{
561+
Name: msg.Name,
562+
Addresses: addrs,
563+
})
564+
if err := config.SaveConfig(m.config); err != nil {
565+
log.Printf("could not save config: %v", err)
566+
}
567+
}
568+
// Return to settings
569+
m.current = tui.NewSettings(m.config)
570+
// Try to navigate to the mailing list view internally if possible, but NewSettings will go to SettingsMain by default.
571+
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
572+
return m, m.current.Init()
573+
547574
case tui.GoToSignatureEditorMsg:
548575
m.current = tui.NewSignatureEditor()
549576
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})

tui/composer.go

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,35 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
211211
case "tab", "enter":
212212
// Select the suggestion
213213
selected := m.suggestions[m.selectedSuggestion]
214-
if selected.Name != "" && selected.Name != selected.Email {
215-
m.toInput.SetValue(fmt.Sprintf("%s <%s>", selected.Name, selected.Email))
214+
215+
var newEmail string
216+
if strings.Contains(selected.Email, ",") {
217+
// It's a mailing list: insert just the addresses to maintain valid email formatting
218+
newEmail = selected.Email
219+
} else if selected.Name != "" && selected.Name != selected.Email {
220+
newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email)
221+
} else {
222+
newEmail = selected.Email
223+
}
224+
225+
parts := strings.Split(m.toInput.Value(), ",")
226+
if len(parts) > 0 {
227+
if len(parts) == 1 {
228+
parts[0] = newEmail
229+
} else {
230+
parts[len(parts)-1] = " " + newEmail
231+
}
216232
} else {
217-
m.toInput.SetValue(selected.Email)
233+
parts = []string{newEmail}
218234
}
235+
236+
finalValue := strings.Join(parts, ",")
237+
if !strings.HasSuffix(finalValue, ", ") {
238+
finalValue += ", "
239+
}
240+
241+
m.toInput.SetValue(finalValue)
242+
m.toInput.SetCursor(len(finalValue))
219243
m.lastToValue = m.toInput.Value()
220244
m.showSuggestions = false
221245
m.suggestions = nil
@@ -356,8 +380,13 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
356380
currentValue := m.toInput.Value()
357381
if currentValue != m.lastToValue {
358382
m.lastToValue = currentValue
359-
if len(currentValue) >= 2 {
360-
m.suggestions = config.SearchContacts(currentValue)
383+
384+
// Extract the last comma-separated part for searching
385+
parts := strings.Split(currentValue, ",")
386+
lastPart := strings.TrimSpace(parts[len(parts)-1])
387+
388+
if len(lastPart) >= 2 {
389+
m.suggestions = config.SearchContacts(lastPart)
361390
m.showSuggestions = len(m.suggestions) > 0
362391
m.selectedSuggestion = 0
363392
} else {

tui/mailing_list.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
6+
"charm.land/bubbles/v2/textinput"
7+
tea "charm.land/bubbletea/v2"
8+
"charm.land/lipgloss/v2"
9+
)
10+
11+
// MailingListEditor displays the screen to add a new mailing list.
12+
type MailingListEditor struct {
13+
nameInput textinput.Model
14+
addrInput textinput.Model
15+
focus int // 0 = name, 1 = addresses
16+
width int
17+
height int
18+
}
19+
20+
// NewMailingListEditor creates a new mailing list editor model.
21+
func NewMailingListEditor() *MailingListEditor {
22+
name := textinput.New()
23+
name.Placeholder = "e.g., Team"
24+
name.Focus()
25+
26+
addr := textinput.New()
27+
addr.Placeholder = "e.g., alice@example.com, bob@example.com"
28+
29+
return &MailingListEditor{
30+
nameInput: name,
31+
addrInput: addr,
32+
focus: 0,
33+
}
34+
}
35+
36+
// Init initializes the mailing list editor model.
37+
func (m *MailingListEditor) Init() tea.Cmd {
38+
return textinput.Blink
39+
}
40+
41+
// Update handles messages for the mailing list editor model.
42+
func (m *MailingListEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
43+
var cmd tea.Cmd
44+
45+
switch msg := msg.(type) {
46+
case tea.WindowSizeMsg:
47+
m.width = msg.Width
48+
m.height = msg.Height
49+
m.nameInput.SetWidth(msg.Width - 4)
50+
m.addrInput.SetWidth(msg.Width - 4)
51+
return m, nil
52+
53+
case tea.KeyPressMsg:
54+
switch msg.String() {
55+
case "ctrl+c":
56+
return m, tea.Quit
57+
case "esc":
58+
return m, func() tea.Msg { return GoToSettingsMsg{} }
59+
case "tab", "shift+tab", "up", "down":
60+
if m.focus == 0 {
61+
m.focus = 1
62+
m.nameInput.Blur()
63+
m.addrInput.Focus()
64+
} else {
65+
m.focus = 0
66+
m.addrInput.Blur()
67+
m.nameInput.Focus()
68+
}
69+
return m, nil
70+
case "enter":
71+
if m.focus == 0 {
72+
m.focus = 1
73+
m.nameInput.Blur()
74+
m.addrInput.Focus()
75+
return m, nil
76+
} else {
77+
// Submit on second field
78+
name := strings.TrimSpace(m.nameInput.Value())
79+
addrs := strings.TrimSpace(m.addrInput.Value())
80+
if name != "" && addrs != "" {
81+
return m, func() tea.Msg {
82+
return SaveMailingListMsg{
83+
Name: name,
84+
Addresses: addrs,
85+
}
86+
}
87+
}
88+
}
89+
}
90+
}
91+
92+
if m.focus == 0 {
93+
m.nameInput, cmd = m.nameInput.Update(msg)
94+
} else {
95+
m.addrInput, cmd = m.addrInput.Update(msg)
96+
}
97+
98+
return m, cmd
99+
}
100+
101+
// View renders the mailing list editor screen.
102+
func (m *MailingListEditor) View() tea.View {
103+
title := titleStyle.Render("Add Mailing List")
104+
105+
var nameView, addrView string
106+
if m.focus == 0 {
107+
nameView = focusedStyle.Render("Name:") + "\n" + m.nameInput.View()
108+
addrView = blurredStyle.Render("Addresses (comma-separated):") + "\n" + m.addrInput.View()
109+
} else {
110+
nameView = blurredStyle.Render("Name:") + "\n" + m.nameInput.View()
111+
addrView = focusedStyle.Render("Addresses (comma-separated):") + "\n" + m.addrInput.View()
112+
}
113+
114+
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
115+
title,
116+
"",
117+
nameView,
118+
"",
119+
addrView,
120+
"",
121+
helpStyle.Render("tab/↑/↓: switch fields • enter: submit • esc: back"),
122+
))
123+
}

tui/messages.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,15 @@ type EmailBodyFetchedMsg struct {
179179
// GoToAddAccountMsg signals navigation to the add account screen.
180180
type GoToAddAccountMsg struct{}
181181

182+
// GoToAddMailingListMsg signals navigation to the add mailing list screen.
183+
type GoToAddMailingListMsg struct{}
184+
185+
// SaveMailingListMsg signals that a new mailing list should be saved.
186+
type SaveMailingListMsg struct {
187+
Name string
188+
Addresses string
189+
}
190+
182191
// AddAccountMsg signals that a new account should be added.
183192
type AddAccountMsg struct {
184193
Credentials Credentials

0 commit comments

Comments
 (0)