""" email_sender.py - Sends a completion notification for a conflict check. Plain stdlib smtplib, all config from env (see config.py). Mirrors IronBid's graceful behavior: if SMTP is not configured, or a send fails, it logs and returns without ever crashing the pipeline. """ import os import ssl import smtplib from email.message import EmailMessage from typing import Dict, List, Optional from backend import config def _smtp_ready() -> bool: return bool(config.SMTP_HOST and config.SMTP_USER and config.SMTP_PASSWORD) def _send(msg: EmailMessage) -> bool: try: if config.SMTP_USE_SSL: ctx = ssl.create_default_context() with smtplib.SMTP_SSL(config.SMTP_HOST, config.SMTP_PORT, context=ctx) as s: s.login(config.SMTP_USER, config.SMTP_PASSWORD) s.send_message(msg) else: with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT) as s: if config.SMTP_USE_TLS: s.starttls(context=ssl.create_default_context()) s.login(config.SMTP_USER, config.SMTP_PASSWORD) s.send_message(msg) return True except Exception as e: print(f"[Email] Failed to send: {e}") return False def send_review_required(recipient_email: str, report: dict, review_url: str) -> bool: if not recipient_email or not _smtp_ready(): return False msg = EmailMessage() msg["Subject"] = f"Conflict Checker - review required - {report.get('source', 'drawing set')}" msg["From"] = config.SMTP_FROM or config.SMTP_USER msg["To"] = recipient_email review = report.get("summary", {}).get("review", {}) msg.set_content( "Agent analysis is complete and waiting for human review.\n\n" f"Required review items: {review.get('required', 0)}\n" f"Review URL: {review_url}\n" ) return _send(msg) def send_conflict_report( recipient_email: str, report: Dict, results_url: str = "", attachments: Optional[List[str]] = None, ) -> bool: """ Email the conflict-check summary to recipient_email, with the report files attached. Returns True if sent, False if skipped/failed. """ if not recipient_email: return False if not _smtp_ready(): print(f"[Email] SMTP not configured - skipping notification to {recipient_email}") return False s = report.get("summary", {}) source = report.get("source", "drawing set") sev = s.get("by_severity", {}) found = s.get("conflicts_found", 0) msg = EmailMessage() msg["Subject"] = f"Conflict Checker - {source} - {found} conflict(s) found" msg["From"] = config.SMTP_FROM or config.SMTP_USER msg["To"] = recipient_email lines = [ "Your conflict check is complete.", "", f"Drawing set: {source}", f"Sheets: {s.get('sheets_analyzed', 0)} ({', '.join(s.get('disciplines', [])) or 'none'})", f"Facts checked: {s.get('assertions_extracted', 0)} across {s.get('clusters_checked', 0)} location clusters", "", "RESULT", f" Conflicts found: {found}", f" High: {sev.get('high', 0)}", f" Medium: {sev.get('medium', 0)}", f" Low: {sev.get('low', 0)}", ] by_stage = s.get("by_stage") if by_stage: lines += [ "", "QAQC REVIEW", f" Full-set QAQC issues: {by_stage.get('qaqc', 0)}", f" Code / ADA issues: {by_stage.get('code', 0)}", f" Constructability: {by_stage.get('constructability', 0)}", f" Consolidated issues: {by_stage.get('validated', 0)}", f" RFIs drafted: {by_stage.get('rfis', 0)}", ] if s.get("cost_usd") is not None: lines += ["", f"LLM cost: ${s['cost_usd']:.4f} over {s.get('llm_calls', 0)} calls"] mu = s.get("models_used", {}) if mu: lines += ["", "AI MODELS"] if mu.get("vision"): lines += [f" Vision: {', '.join(mu['vision'])}"] if mu.get("text_local"): lines += [f" Text local: {', '.join(mu['text_local'])}"] if mu.get("text_cloud"): label = "Text cloud" if not mu.get("text_local") else "Text cloud (fallback)" lines += [f" {label}: {', '.join(mu['text_cloud'])}"] if mu.get("fallback_count"): lines += [f" Local->cloud fallbacks: {mu['fallback_count']}"] if results_url: lines += ["", "VIEW FULL REPORT", f" {results_url}"] lines += ["", "Generated by Conflict Checker"] msg.set_content("\n".join(lines)) for path in attachments or []: if not path or not os.path.isfile(path): continue with open(path, "rb") as f: data = f.read() name = os.path.basename(path) subtype = "json" if name.endswith(".json") else "plain" maintype = "application" if name.endswith(".json") else "text" msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=name) if _send(msg): print(f"[Email] Sent conflict report to {recipient_email}") return True return False