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
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