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