diff --git a/config/cache.go b/config/cache.go index 6852a3999bcbcb845742cfd1555ca96463728ac3..af259335cbb761222c467595f0c9ef83f2d8e3e5 100644 --- a/config/cache.go +++ b/config/cache.go @@ -124,10 +124,16 @@ type ContactUsage struct { } // Contact stores a contact's name, email address, and per-account usage. +// +// For regular contacts, Email holds a single address and Addresses is empty. +// For mailing-list virtual contacts emitted by SearchContacts, Email is empty +// and Addresses holds the expanded list of recipients. Callers that need to +// distinguish the two cases should check len(Addresses) > 0. type Contact struct { - Name string `json:"name"` - Email string `json:"email"` - Usage map[string]ContactUsage `json:"usage_by_account"` + Name string `json:"name"` + Email string `json:"email"` + Addresses []string `json:"addresses,omitempty"` + Usage map[string]ContactUsage `json:"usage_by_account"` } // UnmarshalJSON accepts both the current usage_by_account format and the @@ -322,10 +328,14 @@ func SearchContactsForAccount(query, accountID string) []Contact { if err == nil { for _, list := range cfg.MailingLists { if strings.Contains(strings.ToLower(list.Name), query) { - // Convert mailing list to a virtual contact + // Convert mailing list to a virtual contact. Addresses are + // stored in a dedicated slice so the Email field keeps its + // single-address invariant -- avoids corruption by + // normalizeContactEmail and exact-match lookups in callers. + addresses := append([]string(nil), list.Addresses...) matches = append(matches, Contact{ - Name: list.Name, - Email: strings.Join(list.Addresses, ", "), + Name: list.Name, + Addresses: addresses, Usage: map[string]ContactUsage{ accountID: { UseCount: 9999, // Ensure lists appear at the top diff --git a/tui/composer.go b/tui/composer.go index 9d2cecb66e9df005f3c84ea7639b7148920d4144..07ace935b551e7f90c8f87fbdc4b128e5a5f8f8e 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -275,6 +275,38 @@ func (m *Composer) removeSelectedAttachment() { m.clampAttachmentCursor() } +func suggestionDisplay(s config.Contact, suggestionWidth int) string { + display := s.Email + if len(s.Addresses) > 0 { + display = fmt.Sprintf("%s (%s)", s.Name, strings.Join(s.Addresses, ", ")) + return truncateSuggestionDisplay(display, suggestionWidth) + } else if s.Name != "" && s.Name != s.Email { + display = fmt.Sprintf("%s <%s>", s.Name, s.Email) + } + return display +} + +func suggestionDisplayWidth(width int) int { + if width > 12 { + return width - 6 + } + return 40 +} + +func truncateSuggestionDisplay(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + if maxLen <= 0 { + return "" + } + if maxLen <= 3 { + return string(runes[:maxLen]) + } + return string(runes[:maxLen-3]) + "..." +} + func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd @@ -347,9 +379,9 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { selected := m.suggestions[m.selectedSuggestion] 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 + if len(selected.Addresses) > 0 { + // Mailing list: emit just the addresses to maintain valid email formatting + newEmail = strings.Join(selected.Addresses, ", ") } else if selected.Name != "" && selected.Name != selected.Email { newEmail = fmt.Sprintf("%s <%s>", selected.Name, selected.Email) } else { @@ -699,11 +731,9 @@ func (m *Composer) View() tea.View { toFieldView := m.toInput.View() if m.showSuggestions && len(m.suggestions) > 0 { var suggestionsBuilder strings.Builder + suggestionWidth := suggestionDisplayWidth(m.width) for i, s := range m.suggestions { - display := s.Email - if s.Name != "" && s.Name != s.Email { - display = fmt.Sprintf("%s <%s>", s.Name, s.Email) - } + display := suggestionDisplay(s, suggestionWidth) if i == m.selectedSuggestion { suggestionsBuilder.WriteString(selectedSuggestionStyle.Render("> "+display) + "\n") } else { diff --git a/tui/composer_test.go b/tui/composer_test.go index ffc10e0be32663a85e82533b069ed858cd17e705..6dc91574e81ff41a4adef40298ce40bf2a5c1a85 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "os" "path/filepath" "testing" @@ -9,6 +10,35 @@ import ( "github.com/floatpane/matcha/config" ) +func TestMailingListSuggestionTruncates(t *testing.T) { + composer := NewComposer("", "", "", "", false) + composer.width = 60 + + addresses := make([]string, 20) + for i := range addresses { + addresses[i] = fmt.Sprintf("very.long.recipient.%02d@example.com", i) + } + + display := suggestionDisplay(config.Contact{ + Name: "Team", + Addresses: addresses, + }, suggestionDisplayWidth(composer.width)) + + if got, want := len([]rune(display)), suggestionDisplayWidth(composer.width); got > want { + t.Fatalf("Expected mailing-list suggestion to be at most %d runes, got %d: %q", want, got, display) + } + + singleAddress := config.Contact{ + Name: "Very Long Contact Name That Should Stay Fully Visible", + Email: "very.long.single.address.that.exceeds.width@example.com", + } + singleDisplay := suggestionDisplay(singleAddress, suggestionDisplayWidth(composer.width)) + expected := fmt.Sprintf("%s <%s>", singleAddress.Name, singleAddress.Email) + if singleDisplay != expected { + t.Fatalf("Expected single-address suggestion to stay untruncated, got %q", singleDisplay) + } +} + // TestComposerUpdate verifies the state transitions in the email composer. func TestComposerUpdate(t *testing.T) { // Initialize a new composer with accounts.