Skip to content

Blacklist & Blocklist

Files: app/core/models/blacklist.py (legacy), app/core/models/organization_blocklist.py (new)

Overview

Transitioning from a legacy Blacklist system (recipient status = Blacklisted) to a dedicated Blocklist system (separate blocklist_entries table).

Blacklist (Legacy -- deprecated)

Simple dataclass with addresses and emails dicts for in-memory lookup. Marked DEPRECATED[ticket:PR-1612]. Being replaced by BlocklistLookup.

BlocklistEntry (New)

Field Description
id Primary key
organization_id Owning organization
email Lowercased email; empty string for address-only entries
address_hash Normalized {address1}{address2}{zip}; empty string for email-only entries
address_raw JSON of original address; NULL for email-only entries
reason Why blocked
created_at, updated_at Timestamps

At least one of email or address_hash must be non-empty (enforced by all callers, not by a DB constraint). The unique constraint (organization_id, email, address_hash) prevents exact duplicates but permits the same email to appear once email-only and once paired with an address.

BlocklistLookup: In-memory structure with emails and addresses dicts for O(1) lookups during recipient sync. Entries with empty email are excluded from the emails dict; entries with empty address_hash are excluded from the addresses dict (so an email-only entry cannot falsely match a recipient with an empty parsed address).

Direct DB lookups (is_email_blocked, is_address_blocked, get_all_blocked_for_recipient): empty inputs short-circuit so they cannot mass-match email-only or address-only entries. get_all_blocked_for_recipient with both inputs empty returns an empty list without querying.

Hybrid approach (during transition)

recipient_sync.py fetches BlocklistLookup from new table AND falls back to legacy campaign_recipients with Blacklisted status. Both combined into a single lookup for unified filtering.

API (routes/organization_blocklist_routes.py)

  • POST /blocklist -- Create entry from {email?, address?, reason?}. At least one of email or address must be provided. When address is provided it must include address1 and zip. Nullifies existing attributions for the email when supplied.
  • GET /blocklist -- List with pagination/filtering
  • DELETE /blocklist/{id} -- Remove entry
  • GET /blocklist/check -- Check if email/address is blocked
  • POST /blocklist/upload -- Bulk CSV upload. The CSV must contain either an email column or all of {address_1, postal_code, country} (or both). Each row is validated individually: a row needs an email OR a full address, otherwise it is counted as skipped_missing_data. Rows with email-only insert with address_hash = "".

Key files

  • Legacy: core/models/blacklist.py
  • New: core/models/organization_blocklist.py
  • Migration: scripts/migrate_blacklist_to_blocklist.py
  • Integration: methods/campaigns/recipient_sync.py