Skip to content

Commit ecff1b6

Browse files
authored
feat: detect duplicate recipients in CSV uploads (#3319) (#416)
* feat: detect duplicate recipients in CSV uploads (#3319) Adds duplicate-recipient detection to RecipientCSV so that admin can warn senders before a bulk send when their CSV contains the same recipient more than once. Detection is case-insensitive, ignores leading/trailing whitespace, and (for SMS) treats phone numbers in different formats as equivalent. The new properties (has_duplicate_recipients, count_of_unique_duplicate_recipients, count_of_duplicate_recipient_rows, rows_with_duplicate_recipients) are non-blocking: they do *not* affect has_errors. Admin can use them to render a warning banner and a download-duplicates link without preventing the user from sending. Refs: cds-snc/notification-planning#3319 * improve perf, exclude simulator email addresses * add excluded phone numbers
1 parent f0285bd commit ecff1b6

4 files changed

Lines changed: 329 additions & 2 deletions

File tree

.github/actions/waffles/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ docopt==0.6.2
22
Flask==2.3.3
33
markupsafe==2.1.5
44
setuptools==78.1.1 # required for distutils in Python 3.12
5-
git+https://github.com/cds-snc/notifier-utils.git@53.2.25#egg=notifications-utils
5+
git+https://github.com/cds-snc/notifier-utils.git@53.2.26#egg=notifications-utils

notifications_utils/recipients.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,30 @@
7878
"address line 6",
7979
}
8080

81+
# Email domains used for testing-only / synthetic recipients. Addresses on
82+
# these domains are excluded from duplicate-recipient detection because they
83+
# are intentionally re-used (e.g. AWS SES has a fixed set of mailboxes at
84+
# ``simulator.amazonses.com`` that always deliver, bounce, complain, etc.).
85+
DEDUPE_EXCLUDED_EMAIL_DOMAINS = frozenset(
86+
{
87+
"simulator.amazonses.com",
88+
}
89+
)
90+
91+
# Phone numbers used for testing-only / synthetic recipients. These are the
92+
# simulator numbers configured in ``notification-api`` (``SIMULATED_SMS_NUMBERS``):
93+
# the API short-circuits delivery for these so that they are intentionally
94+
# re-used in load and smoke tests. They should not be flagged as duplicates
95+
# when senders include them in a bulk-send CSV. Values are stored in E.164 so
96+
# they match what ``validate_phone_number`` returns.
97+
DEDUPE_EXCLUDED_PHONE_NUMBERS = frozenset(
98+
{
99+
"+16132532222",
100+
"+16132532223",
101+
"+16132532224",
102+
}
103+
)
104+
81105

82106
class RecipientCSV:
83107
def __init__(
@@ -311,6 +335,117 @@ def rows_with_combined_variable_content_too_long(self):
311335
if total_length > SMS_CHAR_COUNT_LIMIT:
312336
yield row
313337

338+
def _normalise_recipient_for_dedupe(self, recipient):
339+
"""
340+
Normalise a recipient value for case-insensitive, whitespace-insensitive
341+
duplicate detection. Phone numbers are normalised to E.164 when possible
342+
so that "+1 555-123-4567" and "5551234567" are treated as the same recipient.
343+
Returns ``None`` if the value cannot meaningfully be normalised (e.g. empty)
344+
or if the value is a known testing-only address (see
345+
``DEDUPE_EXCLUDED_EMAIL_DOMAINS`` / ``DEDUPE_EXCLUDED_PHONE_NUMBERS``)
346+
that should not be flagged as a duplicate.
347+
"""
348+
if recipient is None:
349+
return None
350+
normalised = strip_and_remove_obscure_whitespace(str(recipient)).strip().lower()
351+
if not normalised:
352+
return None
353+
if self.template_type == "email":
354+
# ``user@DOMAIN`` -> domain part is everything after the last ``@``.
355+
# We only need to skip dedupe for synthetic test mailboxes, so a
356+
# cheap suffix check on the lowercased value is sufficient.
357+
domain = normalised.rpartition("@")[2]
358+
if domain in DEDUPE_EXCLUDED_EMAIL_DOMAINS:
359+
return None
360+
if self.template_type == "sms":
361+
try:
362+
normalised_phone = validate_phone_number(recipient, international=self.international_sms)
363+
except InvalidPhoneError:
364+
return normalised
365+
if normalised_phone in DEDUPE_EXCLUDED_PHONE_NUMBERS:
366+
return None
367+
return normalised_phone
368+
return normalised
369+
370+
def _compute_duplicate_recipient_summary(self):
371+
"""
372+
Single-pass computation of everything we need to report duplicate
373+
recipients. Iterating ``self.rows`` and (for SMS) calling
374+
``validate_phone_number`` per row is expensive on large uploads, so all
375+
of the public ``*_duplicate_*`` properties read from this cached result
376+
rather than recomputing.
377+
"""
378+
DuplicateSummary = namedtuple("DuplicateSummary", ("row_indices", "unique_count"))
379+
if self.template_type == "letter":
380+
return DuplicateSummary(row_indices=frozenset(), unique_count=0)
381+
382+
seen: Dict[str, int] = {}
383+
duplicate_indices = set()
384+
for row in self.rows:
385+
if row is None:
386+
continue
387+
if row.has_bad_recipient or row.recipient is None:
388+
continue
389+
normalised = self._normalise_recipient_for_dedupe(row.recipient)
390+
if normalised is None:
391+
continue
392+
previous_count = seen.get(normalised, 0)
393+
seen[normalised] = previous_count + 1
394+
if previous_count:
395+
duplicate_indices.add(row.index)
396+
397+
unique_count = sum(1 for count in seen.values() if count > 1)
398+
return DuplicateSummary(row_indices=frozenset(duplicate_indices), unique_count=unique_count)
399+
400+
@property
401+
def _duplicate_recipient_summary(self):
402+
# Cached on the instance so all of the public duplicate-recipient
403+
# properties cost only one full pass over the rows in total.
404+
if not hasattr(self, "_duplicate_recipient_summary_cache"):
405+
self._duplicate_recipient_summary_cache = self._compute_duplicate_recipient_summary()
406+
return self._duplicate_recipient_summary_cache
407+
408+
@property
409+
def _duplicate_recipient_row_indices(self):
410+
"""
411+
Returns a set of row indices for rows whose recipient value has already
412+
appeared in an earlier row. The first occurrence of each recipient is
413+
not flagged. Rows with bad or missing recipients are skipped, and
414+
duplicate detection is disabled for letter templates (where multiple
415+
recipients can legitimately share an address).
416+
"""
417+
return self._duplicate_recipient_summary.row_indices
418+
419+
@property
420+
def rows_with_duplicate_recipients(self):
421+
"""
422+
Yields rows whose recipient is a duplicate of an earlier row. The first
423+
occurrence of each recipient is *not* yielded; only the subsequent
424+
copies are yielded so callers can highlight or export them.
425+
"""
426+
duplicate_indices = self._duplicate_recipient_row_indices
427+
if not duplicate_indices:
428+
return
429+
for row in self.rows:
430+
if row is None:
431+
continue
432+
if row.index in duplicate_indices:
433+
yield row
434+
435+
@property
436+
def has_duplicate_recipients(self):
437+
return bool(self._duplicate_recipient_summary.row_indices)
438+
439+
@property
440+
def count_of_duplicate_recipient_rows(self):
441+
"""Total number of duplicate rows (i.e. extra copies beyond the first)."""
442+
return len(self._duplicate_recipient_summary.row_indices)
443+
444+
@property
445+
def count_of_unique_duplicate_recipients(self):
446+
"""Number of distinct recipients that appear more than once."""
447+
return self._duplicate_recipient_summary.unique_count
448+
314449
@property
315450
def initial_rows_with_errors(self):
316451
return islice(self.rows_with_errors, self.max_errors_shown)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "notifications-utils"
3-
version = "53.2.25"
3+
version = "53.2.26"
44
description = "Shared python code for Notification - Provides logging utils etc."
55
authors = ["Canadian Digital Service"]
66
license = "MIT license"

tests/test_recipient_csv.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,3 +1152,195 @@ def test_multi_line_placeholders_work():
11521152
)
11531153

11541154
assert recipients.rows[0].personalisation["data"] == "a\nb\n\nc"
1155+
1156+
1157+
class TestDuplicateRecipients:
1158+
"""Duplicate-recipient detection (issue #3319).
1159+
1160+
The detection should be case-insensitive, ignore leading/trailing
1161+
whitespace, and treat phone numbers as equivalent when they normalise to
1162+
the same E.164 form. It should be a non-blocking warning -- ``has_errors``
1163+
must remain ``False`` when the only issue is duplicate recipients.
1164+
"""
1165+
1166+
def test_no_duplicates_when_all_emails_unique(self):
1167+
recipients = RecipientCSV(
1168+
"""
1169+
email address
1170+
alice@example.com
1171+
bob@example.com
1172+
carol@example.com
1173+
""",
1174+
template_type="email",
1175+
)
1176+
assert recipients.has_duplicate_recipients is False
1177+
assert recipients.count_of_duplicate_recipient_rows == 0
1178+
assert recipients.count_of_unique_duplicate_recipients == 0
1179+
assert list(recipients.rows_with_duplicate_recipients) == []
1180+
assert recipients.has_errors is False
1181+
1182+
def test_detects_exact_duplicate_emails(self):
1183+
recipients = RecipientCSV(
1184+
"""
1185+
email address
1186+
alice@example.com
1187+
bob@example.com
1188+
alice@example.com
1189+
""",
1190+
template_type="email",
1191+
)
1192+
assert recipients.has_duplicate_recipients is True
1193+
assert recipients.count_of_duplicate_recipient_rows == 1
1194+
assert recipients.count_of_unique_duplicate_recipients == 1
1195+
duplicates = list(recipients.rows_with_duplicate_recipients)
1196+
# Only the *second* occurrence is flagged; the first is kept.
1197+
assert [row.index for row in duplicates] == [2]
1198+
# Duplicates are a warning, not a hard error.
1199+
assert recipients.has_errors is False
1200+
1201+
def test_email_dedupe_is_case_insensitive_and_trims_whitespace(self):
1202+
# Build the CSV explicitly so leading/trailing whitespace and case
1203+
# differences are preserved without tripping the linter.
1204+
file_contents = "email address\n" "Alice@Example.com\n" " alice@example.COM \n" "ALICE@EXAMPLE.COM\n"
1205+
recipients = RecipientCSV(file_contents, template_type="email")
1206+
assert recipients.count_of_duplicate_recipient_rows == 2
1207+
assert recipients.count_of_unique_duplicate_recipients == 1
1208+
1209+
def test_counts_unique_duplicate_recipients(self):
1210+
recipients = RecipientCSV(
1211+
"""
1212+
email address
1213+
alice@example.com
1214+
bob@example.com
1215+
alice@example.com
1216+
bob@example.com
1217+
carol@example.com
1218+
bob@example.com
1219+
""",
1220+
template_type="email",
1221+
)
1222+
# alice appears twice (1 extra), bob appears 3 times (2 extra) -> 3 duplicate rows
1223+
assert recipients.count_of_duplicate_recipient_rows == 3
1224+
# Two distinct recipients have duplicates.
1225+
assert recipients.count_of_unique_duplicate_recipients == 2
1226+
1227+
def test_detects_duplicate_phone_numbers_in_different_formats(self):
1228+
recipients = RecipientCSV(
1229+
"""
1230+
phone number
1231+
6502532222
1232+
+1 650-253-2222
1233+
650 253 2222
1234+
6502532223
1235+
""",
1236+
template_type="sms",
1237+
international_sms=True,
1238+
)
1239+
assert recipients.count_of_duplicate_recipient_rows == 2
1240+
assert recipients.count_of_unique_duplicate_recipients == 1
1241+
1242+
def test_skips_rows_with_bad_or_missing_recipients(self):
1243+
file_contents = "email address\n" "alice@example.com\n" "not-an-email\n" "\n" "alice@example.com\n"
1244+
recipients = RecipientCSV(file_contents, template_type="email")
1245+
# The blank row and bad-email row should not be considered for dedupe.
1246+
assert recipients.count_of_duplicate_recipient_rows == 1
1247+
1248+
def test_duplicate_detection_disabled_for_letters(self):
1249+
recipients = RecipientCSV(
1250+
"""
1251+
address line 1, address line 2, address line 3, address line 4, address line 5, address line 6, postcode
1252+
A, B, C, , , , X1A0A1
1253+
A, B, C, , , , X1A0A1
1254+
""",
1255+
template_type="letter",
1256+
)
1257+
# Letters can legitimately share an address, so we don't flag duplicates.
1258+
assert recipients.has_duplicate_recipients is False
1259+
assert recipients.count_of_duplicate_recipient_rows == 0
1260+
assert recipients.count_of_unique_duplicate_recipients == 0
1261+
1262+
def test_duplicates_do_not_make_has_errors_true(self):
1263+
recipients = RecipientCSV(
1264+
"""
1265+
email address
1266+
alice@example.com
1267+
alice@example.com
1268+
""",
1269+
template_type="email",
1270+
)
1271+
assert recipients.has_duplicate_recipients is True
1272+
# Critically: duplicates are a non-blocking warning, not an error.
1273+
assert recipients.has_errors is False
1274+
assert list(recipients.rows_with_errors) == []
1275+
1276+
def test_ses_simulator_addresses_are_not_flagged_as_duplicates(self):
1277+
# The ``simulator.amazonses.com`` mailboxes (success@, bounce@,
1278+
# complaint@, etc.) are deliberately re-used for load/smoke testing,
1279+
# so a CSV full of them should not produce a "duplicate recipients"
1280+
# warning.
1281+
recipients = RecipientCSV(
1282+
"""
1283+
email address
1284+
success@simulator.amazonses.com
1285+
SUCCESS@simulator.amazonses.com
1286+
bounce@simulator.amazonses.com
1287+
bounce@simulator.amazonses.com
1288+
alice@example.com
1289+
alice@example.com
1290+
""",
1291+
template_type="email",
1292+
)
1293+
# Only the real duplicate (alice) is flagged; the simulator addresses
1294+
# are excluded even though they appear multiple times.
1295+
assert recipients.count_of_unique_duplicate_recipients == 1
1296+
assert recipients.count_of_duplicate_recipient_rows == 1
1297+
duplicate_rows = list(recipients.rows_with_duplicate_recipients)
1298+
assert len(duplicate_rows) == 1
1299+
assert duplicate_rows[0].recipient == "alice@example.com"
1300+
1301+
def test_simulator_phone_numbers_are_not_flagged_as_duplicates(self):
1302+
# The simulator numbers configured in ``notification-api``
1303+
# (``SIMULATED_SMS_NUMBERS``) are short-circuited and never actually
1304+
# delivered, so they are intentionally re-used in load/smoke tests
1305+
# and should not produce a duplicate warning. The exclusion is on the
1306+
# E.164 form, so different input formats of the same simulator number
1307+
# must also be excluded.
1308+
recipients = RecipientCSV(
1309+
"""
1310+
phone number
1311+
+16132532222
1312+
6132532222
1313+
(613) 253-2222
1314+
+16132532223
1315+
+16132532223
1316+
+16135551234
1317+
+16135551234
1318+
""",
1319+
template_type="sms",
1320+
)
1321+
# Only the real duplicate (+16135551234) is flagged.
1322+
assert recipients.count_of_unique_duplicate_recipients == 1
1323+
assert recipients.count_of_duplicate_recipient_rows == 1
1324+
duplicate_rows = list(recipients.rows_with_duplicate_recipients)
1325+
assert len(duplicate_rows) == 1
1326+
1327+
def test_duplicate_summary_is_cached(self):
1328+
# Re-reading any of the duplicate properties on a large upload should
1329+
# be cheap: the underlying single-pass computation must only run once.
1330+
recipients = RecipientCSV(
1331+
"""
1332+
email address
1333+
alice@example.com
1334+
alice@example.com
1335+
""",
1336+
template_type="email",
1337+
)
1338+
# Prime the cache.
1339+
first_indices = recipients._duplicate_recipient_row_indices
1340+
# Subsequent accesses (incl. via different public properties) should
1341+
# all return the exact same cached object.
1342+
assert recipients._duplicate_recipient_row_indices is first_indices
1343+
assert recipients._duplicate_recipient_summary.row_indices is first_indices
1344+
assert recipients.count_of_duplicate_recipient_rows == 1
1345+
assert recipients.count_of_unique_duplicate_recipients == 1
1346+
assert recipients.has_duplicate_recipients is True

0 commit comments

Comments
 (0)