fix: mailing-list addresses in a slice (#1277)

Matt Van Horn created

## What?

`SearchContacts` builds mailing-list virtual contacts by jamming
`strings.Join(list.Addresses, ", ")` into `Contact.Email`. That breaks
the field's single-address invariant: `normalizeContactEmail` lowercases
and trims commas off the joined string (corrupting the data), and any
exact-match lookup against the contact will never find it.

This adds a dedicated `Addresses []string` field to `Contact`, populates
it for mailing-list virtual contacts, and leaves `Email` empty so the
invariant holds. The composer learns to detect mailing lists via
`len(selected.Addresses) > 0` instead of "does the email field contain a
comma", joins the addresses at insertion time, and renders the
suggestion as `Name (addr1, addr2, ...)` so it's clear that a single
suggestion expands to multiple recipients.

The cache file format is backwards-compatible: the new field is
`omitempty`, and saved contacts (added via `AddContact`) never carry
`Addresses` because they're built from a single email parameter.
Mailing-list virtual contacts are constructed in-memory on every search
and never written to disk.

## Why?

Closes #1123. The reporter pointed out the data-integrity bug; this
implements option 2 of the two fixes they suggested (dedicated
`Addresses` slice, keep `Email` empty), which is the smaller of the two
and doesn't require a separate "virtual contact" type.

Change summary

config/cache.go      | 22 ++++++++++++++++------
tui/composer.go      | 44 +++++++++++++++++++++++++++++++++++++-------
tui/composer_test.go | 30 ++++++++++++++++++++++++++++++
3 files changed, 83 insertions(+), 13 deletions(-)

Detailed changes

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

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 {

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.