"""Recipient import & validation: CSV, TXT, manual. NEVER accepts JSON recipient files. Validates email syntax, duplicates, empty/invalid rows, suppression list and prior bounces. """ import csv import io import re import secrets from typing import List, Optional from email_validator import validate_email, EmailNotValidError # Recognized CSV headers (case-insensitive); mapped to model fields. HEADER_ALIASES = { "email": "email", "e-mail": "email", "emailaddress": "email", "email_address": "email", "firstname": "first_name", "first_name": "first_name", "fname": "first_name", "givenname": "first_name", "lastname": "last_name", "last_name": "last_name", "lname": "last_name", "surname": "last_name", "companyname": "company_name", "company_name": "company_name", "company": "company_name", "companyphone": "company_phone", "company_phone": "company_phone", "phone": "company_phone", "customeremail": "customer_email", "customer_email": "customer_email", } EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") class ImportReport: def __init__(self): self.rows_seen = 0 self.valid: List[dict] = [] self.invalid: List[dict] = [] self.duplicates: List[dict] = [] self.suppressed: List[dict] = [] self.bounced: List[dict] = [] self.errors: List[str] = [] def summary(self) -> dict: return { "total_imported": self.rows_seen, "valid_recipients": len(self.valid), "invalid_recipients": len(self.invalid), "duplicate_recipients": len(self.duplicates), "suppressed_recipients": len(self.suppressed), "bounced_recipients": len(self.bounced), "errors": self.errors, } def is_valid_email(addr: str) -> bool: if not addr or len(addr) > 320: return False try: v = validate_email(addr, check_deliverability=False) return True except EmailNotValidError: return EMAIL_RE.match(addr) is not None def parse_txt(content: str) -> List[str]: """One email per line (optionally 'email, First, Last' comma-separated).""" def is_valid_email(addr: str) -> bool: if not addr or len(addr) > 320: return False try: v = validate_email(addr, check_deliverability=False) return True except EmailNotValidError: return EMAIL_RE.match(addr) is not None def parse_txt(content: str) -> List[str]: """One email per line (optionally 'email, First, Last' comma-separated).""" rows = [] for line in content.splitlines(): line = line.strip().strip(",") if not line or line.startswith("#"): continue rows.append(line) return rows def parse_csv(content: str) -> tuple: """Parse CSV with header mapping. Returns (records, headers, errors).""" reader = csv.reader(io.StringIO(content)) rows = [r for r in reader] if not rows: return [], [], ["File is empty"] header = [h.strip() for h in rows[0]] norm = {h.lower().replace(" ", "_").replace("-", "_"): h for h in header if h} field_map = {} custom_cols = [] for norm_name, orig in norm.items(): if norm_name in HEADER_ALIASES: field_map[orig] = HEADER_ALIASES[norm_name] else: custom_cols.append(orig) records, errors = [], [] for i, row in enumerate(rows[1:], start=2): if not any(cell.strip() for cell in row): errors.append(f"Row {i}: empty row skipped") continue rec = {} for idx, orig in enumerate(header): val = row[idx].strip() if idx < len(row) else "" target = field_map.get(orig) if target: rec[target] = val elif orig: cf = rec.setdefault("custom_fields", {}) cf[orig] = val rec["_row"] = i records.append(rec) return records, header, errors def validate_rows(records: List[dict], suppression: set, bounced: set, existing_emails: Optional[set] = None, max_recipients: int = 2000) -> ImportReport: """Validate parsed rows against all rules. Returns an ImportReport.""" rpt = ImportReport() seen: set = set() for rec in records: rpt.rows_seen += 1 email = (rec.get("email") or rec.get("customer_email") or "").strip().lower() row = rec.get("_row") base = {k: v for k, v in rec.items() if k not in ("_row", "custom_fields")} if not email: rpt.invalid.append({**base, "reason": "Missing email address", "row": row}) continue if not is_valid_email(email): rpt.invalid.append({**base, "email": email, "reason": "Invalid email syntax", "row": row}) continue if email in seen or (existing_emails and email in existing_emails): rpt.duplicates.append({**base, "email": email, "reason": "Duplicate address in import", "row": row}) continue if email in suppression: rpt.suppressed.append({**base, "email": email, "reason": "On suppression list", "row": row}) continue if email in bounced: rpt.bounced.append({**base, "email": email, "reason": "Previously bounced", "row": row}) continue seen.add(email) rpt.valid.append({ "email": email, "first_name": (rec.get("first_name") or "").strip(), "last_name": (rec.get("last_name") or "").strip(), "company_name": (rec.get("company_name") or "").strip(), "company_phone": (rec.get("company_phone") or "").strip(), "customer_email": (rec.get("customer_email") or rec.get("email") or "").strip(), "custom_fields": rec.get("custom_fields", {}), }) if len(rpt.valid) > max_recipients: rpt.errors.append(f"Import exceeds maximum of {max_recipients} recipients; only first {max_recipients} kept.") rpt.valid = rpt.valid[:max_recipients] return rpt def make_unsub_token() -> str: