106:def validate_rows(records: List[dict], suppression: set, bounced: set, 107- existing_emails: Optional[set] = None, max_recipients: int = 2000) -> ImportReport: 108- """Validate parsed rows against all rules. Returns an ImportReport.""" 109- rpt = ImportReport() 110- seen: set = set() 111- for rec in records: 112- rpt.rows_seen += 1 113- email = (rec.get("email") or rec.get("customer_email") or "").strip().lower() 114- row = rec.get("_row") 115- base = {k: v for k, v in rec.items() if k not in ("_row", "custom_fields")} 116- if not email: 117- rpt.invalid.append({**base, "reason": "Missing email address", "row": row}) 118- continue 119- if not is_valid_email(email): 120- rpt.invalid.append({**base, "email": email, "reason": "Invalid email syntax", "row": row}) 121- continue 122- if email in seen or (existing_emails and email in existing_emails): 123- rpt.duplicates.append({**base, "email": email, "reason": "Duplicate address in import", "row": row}) 124- continue 125- if email in suppression: 126- rpt.suppressed.append({**base, "email": email, "reason": "On suppression list", "row": row}) 127- continue 128- if email in bounced: 129- rpt.bounced.append({**base, "email": email, "reason": "Previously bounced", "row": row}) 130- continue 131- seen.add(email) 132- rpt.valid.append({ 133- "email": email, 134- "first_name": (rec.get("first_name") or "").strip(), 135- "last_name": (rec.get("last_name") or "").strip(), 136- "company_name": (rec.get("company_name") or "").strip(), 137- "company_phone": (rec.get("company_phone") or "").strip(), 138- "customer_email": (rec.get("customer_email") or rec.get("email") or "").strip(), 139- "custom_fields": rec.get("custom_fields", {}), 140- }) 141- if len(rpt.valid) > max_recipients: 142- rpt.errors.append(f"Import exceeds maximum of {max_recipients} recipients; only first {max_recipients} kept.") 143- rpt.valid = rpt.valid[:max_recipients] 144- return rpt 145- 146- async def import_commit(list_id: int = Form(...), file: UploadFile = File(...), user: User = Depends(get_current_user_csrf), db: AsyncSession = Depends(get_db), request: Request = None): """Commit an import into the given list (same validation as preview).""" rl = await db.get(RecipientList, list_id) if not rl or rl.user_id != user.id: raise HTTPException(404, "Recipient list not found") content = await file.read() if len(content) > settings.MAX_UPLOAD_MB * 1024 * 1024: raise HTTPException(413, "File too large") fname = (file.filename or "").lower() if fname.endswith(".json"): raise HTTPException(400, "JSON recipient files are not accepted. Use CSV or TXT.") if not (fname.endswith(".csv") or fname.endswith(".txt")): raise HTTPException(400, "Only .csv and .txt files are accepted") if fname.endswith(".txt"): lines = recipient_service.parse_txt(content.decode("utf-8", errors="replace")) records = [] for ln in lines: parts = [p.strip() for p in ln.split(",")] rec = {"email": parts[0]} if len(parts) > 1: rec["first_name"] = parts[1] if len(parts) > 2: rec["last_name"] = parts[2] records.append(rec) else: records, _, _ = recipient_service.parse_csv(content.decode("utf-8", errors="replace")) sup, bounced = await _suppression_and_bounces(db) existing = set((await db.execute(select(Recipient.email).where( Recipient.list_id == list_id))).scalars().all()) rpt = recipient_service.validate_rows(records, sup, bounced, existing, settings.MAX_RECIPIENTS_PER_CAMPAIGN) for v in rpt.valid: db.add(Recipient(list_id=list_id, email=v["email"], first_name=v["first_name"], last_name=v["last_name"], company_name=v["company_name"], company_phone=v["company_phone"], customer_email=v["customer_email"] or v["email"], custom_fields=v.get("custom_fields", {}), unsub_token=recipient_service.make_unsub_token())) rl.row_count += len(rpt.valid) rl.source = "txt" if fname.endswith(".txt") else "csv" await db.commit() await log_audit(db, user.id, "recipients.import", "recipient_list", list_id, rpt.summary(), request.client.host if request else None) return {"ok": True, **rpt.summary(), "list_id": list_id}