import: an email matches by its mailbox, at its own domain
Two addresses at different domains are different people however alike
their mailboxes read, so the domain has to agree; the part before the @
is what the edit distance weighs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vb8rg9WqWhQesDht1LL6t
change
commit 8618c455a694c0517793a11d8fcf5acd6965d5f5
author Claude <noreply@anthropic.com>
date
parent a33fe642
diff --git a/docs/plans/2026-09-23-merging-on-import.md b/docs/plans/2026-09-23-merging-on-import.md
index 7ee36ba..cde01ad 100644
--- a/docs/plans/2026-09-23-merging-on-import.md
+++ b/docs/plans/2026-09-23-merging-on-import.md
@@ -26,11 +26,12 @@ card over the three things a person is recognised by:
   written two ways, and an edit distance over the spellings would call
   them strangers.
 - **Each email address**, by Levenshtein distance over the part before
-  its @, without case. The domain is shared by everyone at it: over
-  whole addresses, "ada@example.com" is four edits in sixteen from
-  "mary@example.com", which offered the fixture book's Ada for a card
-  that was Mary's. The same handle at two providers is still one
-  person.
+  its @, and only between addresses at the same domain. Another domain
+  is another mailbox, however alike the names on them, so the domain
+  has to agree. It does not count toward the distance, because it is
+  shared by everyone at it: over whole addresses, "ada@example.com" is
+  four edits in sixteen from "mary@example.com", which offered the
+  fixture book's Ada for a card that was Mary's.
 - **Each phone number**, by Levenshtein distance over its last ten
   digits. A country code or a trunk prefix on one side and not the
   other is still one number, and a digit mistyped is one edit in ten.
diff --git a/lib/pro_tacts/import/match.rb b/lib/pro_tacts/import/match.rb
index 9aebbc4..0dcf103 100644
--- a/lib/pro_tacts/import/match.rb
+++ b/lib/pro_tacts/import/match.rb
@@ -52,7 +52,7 @@ module ProTacts
       def self.likeness(arriving, stored)
         [
           closest(names(arriving), names(stored)),
-          closest(emails(arriving), emails(stored)),
+          closest_address(emails(arriving), emails(stored)),
           closest(phones(arriving), phones(stored)),
         ]
       end
@@ -104,17 +104,27 @@ module ProTacts
         [contact.name.to_s.downcase.scan(/[[:alnum:]]+/).sort.join(" ")].reject(&:empty?)
       end
 
-      # An address as the part before its @, without case. The domain
-      # is shared by everyone at it, so over whole addresses
-      # "ada@example.com" is four edits in sixteen from
-      # "mary@example.com" — two people alike for the length of a
-      # domain — while "jane.booles" at two providers is one person.
+      # An address's case is no part of it.
       #: (Contact contact) -> Array[String]
       def self.emails(contact)
-        contact.emails.map {
-          address = it.value.strip.downcase
-          address.include?("@") ? address.rpartition("@").first : address
-        }.reject(&:empty?)
+        contact.emails.map { it.value.strip.downcase }.reject(&:empty?)
+      end
+
+      # Two addresses as alike as the parts before their @, at one
+      # domain, and not alike at all at two: "jane@booles.family" and
+      # "jane@work.example" are different mailboxes, however alike the
+      # names on them. The domain has to agree rather than count
+      # toward the distance, because it is shared by everyone at it —
+      # over whole addresses "ada@example.com" is four edits in
+      # sixteen from "mary@example.com", two people alike for the
+      # length of a provider.
+      #: (Array[String] ours, Array[String] theirs) -> Float
+      def self.closest_address(ours, theirs)
+        ours.product(theirs).map { |a, b|
+          local, _, domain = a.rpartition("@")
+          other, _, other_domain = b.rpartition("@")
+          domain == other_domain ? likeness_of(local, other) : 0.0
+        }.max || 0.0
       end
 
       #: (Contact contact) -> Array[String]
@@ -122,7 +132,7 @@ module ProTacts
         contact.phones.map { phone(it.value) }.reject(&:empty?)
       end
 
-      private_class_method :closest, :likeness_of, :names, :emails, :phones
+      private_class_method :closest, :closest_address, :likeness_of, :names, :emails, :phones
     end
   end
 end
diff --git a/test/pro_tacts/import/test_match.rb b/test/pro_tacts/import/test_match.rb
index acb73cf..0d485a6 100644
--- a/test/pro_tacts/import/test_match.rb
+++ b/test/pro_tacts/import/test_match.rb
@@ -42,14 +42,15 @@ class ImportMatchTest < Minitest::Test
     assert_equal [jane], Match.candidates(arriving, [jane])
   end
 
-  # The part of an address that names someone is the part before its
-  # @: everyone at a domain shares the rest.
-  def test_an_address_is_alike_by_the_part_before_its_domain
+  # Two addresses are alike by the part before the @, and only at one
+  # domain: another domain is another mailbox, and a shared one says
+  # nothing about the people at it.
+  def test_an_address_is_alike_by_its_mailbox_at_the_same_domain
     jane = contact("jane", "Jane Booles", "EMAIL:jane.booles@example.com")
     ada = contact("ada", "Ada Lovelace", "EMAIL:ada@example.com")
 
     assert_equal [jane], Match.candidates(contact("0", "J", "EMAIL:jane.boole@example.com"), [jane, ada])
-    assert_equal [jane], Match.candidates(contact("0", "J", "EMAIL:jane.booles@elsewhere.org"), [jane, ada])
+    assert_empty Match.candidates(contact("0", "J", "EMAIL:jane.booles@elsewhere.org"), [jane])
     assert_empty Match.candidates(contact("0", "M", "EMAIL:mary@example.com"), [ada])
   end