From 773c443079ba0b09a2b162472791aa60fd492811 Mon Sep 17 00:00:00 2001 From: justin Date: Thu, 11 Jun 2026 13:24:10 -0500 Subject: [PATCH] legal: permanent do-not-contact for dataspindle.com + close re-import gap David Sgro (PA OAG complaint BCP-26-05-025816) opted out 2026-04-13; response emailed to the AG 2026-06-11. To make the suppression bulletproof and keep the response's representations true: - Added a legal do-not-contact list (DO_NOT_CONTACT_DOMAINS/_EMAILS) to _email_exclusions.py with dataspindle.com / dave@dataspindle.com; folded into BLOCKED_EMAIL_DOMAINS and is_blocked(). - listmonk_import.upsert_subscriber now refuses to import/re-confirm any suppressed address. This closes the exact gap that re-added him on 2026-04-26: the duplicate-import branch re-added an existing unsubscribed subscriber to lists with status=confirmed, overriding the opt-out. --- .../legal/AG-response-David-Sgro-SENT-NOTE.md | 20 ++++++++++++++++++ scripts/_email_exclusions.py | 17 ++++++++++++++- scripts/workers/listmonk_import.py | 21 ++++++++++++++++++- 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 docs/legal/AG-response-David-Sgro-SENT-NOTE.md diff --git a/docs/legal/AG-response-David-Sgro-SENT-NOTE.md b/docs/legal/AG-response-David-Sgro-SENT-NOTE.md new file mode 100644 index 0000000..3e1683f --- /dev/null +++ b/docs/legal/AG-response-David-Sgro-SENT-NOTE.md @@ -0,0 +1,20 @@ +# PA AG Complaint BCP-26-05-025816 (David Sgro / dataspindle.com) — Status + +- **Complaint filed:** 2026-04-13 by David Sgro (Quakertown, PA) with PA OAG + Bureau of Consumer Protection, agent Brett W. Mauser. +- **AG mediation letter:** dated 2026-05-07, 21-day response window. +- **Response sent:** emailed to the AG on 2026-06-11 (per Justin). + +## Suppression status (must remain true) +- dave@dataspindle.com is `blocklisted` in the main listmonk (global, excluded + from all campaigns). Not present in the HC listmonk. +- dataspindle.com + dave@dataspindle.com added to the legal do-not-contact list + in `scripts/_email_exclusions.py` (DO_NOT_CONTACT_DOMAINS / _EMAILS). +- `listmonk_import.upsert_subscriber` now refuses to (re-)import or re-confirm + any suppressed/do-not-contact address — closes the duplicate-import gap that + re-added him to FCC lists on 2026-04-26. + +## Root-cause of the re-contact +A duplicate import in `listmonk_import.py` re-added an existing (previously +unsubscribed) subscriber to lists with status="confirmed", overriding the +opt-out. Fixed by the do-not-contact gate above. diff --git a/scripts/_email_exclusions.py b/scripts/_email_exclusions.py index fe8e0a5..7d0a9db 100644 --- a/scripts/_email_exclusions.py +++ b/scripts/_email_exclusions.py @@ -57,10 +57,24 @@ MICROSOFT_CONSUMER_DOMAINS: frozenset[str] = frozenset({ "hotmail.fr", "live.co.uk", "outlook.es", "passport.com", "windowslive.com", }) +# Legal / complaint do-not-contact list. Addresses and domains here must NEVER +# be cold-mailed or re-imported, independent of consumer-domain reputation +# rules. Add a domain or a specific address when someone makes a formal +# do-not-contact / opt-out demand we are honoring (e.g. a regulator complaint). +# dataspindle.com / dave@dataspindle.com -- David Sgro, PA OAG complaint +# BCP-26-05-025816; opted out 2026-04-13, permanently suppressed. +DO_NOT_CONTACT_DOMAINS: frozenset[str] = frozenset({ + "dataspindle.com", +}) +DO_NOT_CONTACT_EMAILS: frozenset[str] = frozenset({ + "dave@dataspindle.com", +}) + # The full set of consumer domains we refuse to cold-mail. Extend here as we # discover other reputation-sensitive providers. BLOCKED_EMAIL_DOMAINS: frozenset[str] = ( YAHOO_FAMILY_DOMAINS | GOOGLE_CONSUMER_DOMAINS | MICROSOFT_CONSUMER_DOMAINS + | DO_NOT_CONTACT_DOMAINS ) @@ -72,4 +86,5 @@ def domain_of(email: str) -> str: def is_blocked(email: str) -> bool: - return domain_of(email) in BLOCKED_EMAIL_DOMAINS + e = (email or "").strip().lower() + return e in DO_NOT_CONTACT_EMAILS or domain_of(e) in BLOCKED_EMAIL_DOMAINS diff --git a/scripts/workers/listmonk_import.py b/scripts/workers/listmonk_import.py index cc739f9..502615e 100644 --- a/scripts/workers/listmonk_import.py +++ b/scripts/workers/listmonk_import.py @@ -75,8 +75,27 @@ def api_post(path: str, payload: dict, session: requests.Session) -> Optional[di def upsert_subscriber(email: str, name: str, lists: list[int], attribs: dict, session: requests.Session) -> Optional[int]: """Create or update a Listmonk subscriber. Returns subscriber ID or None.""" + clean = (email or "").lower().strip() + + # Do-not-contact / legal suppression gate. NEVER (re-)import or re-confirm a + # suppressed address. This is the guard that was missing when a duplicate + # import re-added a previously-unsubscribed contact to lists with + # status="confirmed" (the David Sgro / dataspindle.com case). is_blocked + # covers both consumer-domain reputation blocks and the legal do-not-contact + # list in scripts/_email_exclusions.py. + try: + from scripts._email_exclusions import is_blocked + except Exception: + try: + from _email_exclusions import is_blocked # type: ignore + except Exception: + is_blocked = lambda _e: False # noqa: E731 (fail-open only if module missing) + if is_blocked(clean): + LOG.info("Skipping suppressed/do-not-contact address: %s", clean) + return None + payload = { - "email": email.lower().strip(), + "email": clean, "name": (name or "").strip() or email.split("@")[0], "status": "enabled", "lists": lists,