cold-outbound-sender.py
1 #!/usr/bin/env python3 2 """ 3 Cold Outbound Sender — sends approved emails via SMTP or a configured email CLI. 4 5 Reads from a JSON file of approved prospects, sends up to N/day, 6 logs to a history file. 7 8 Usage: 9 python3 cold-outbound-sender.py [--dry-run] [--max N] 10 python3 cold-outbound-sender.py --approved-file path/to/approved.json 11 python3 cold-outbound-sender.py --send-method smtp 12 13 Environment variables: 14 SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD — for SMTP sending 15 SENDER_EMAIL — sender email address 16 SENDER_NAME — sender display name 17 """ 18 19 import argparse 20 import json 21 import os 22 import smtplib 23 import subprocess 24 import sys 25 from datetime import datetime 26 from email.mime.text import MIMEText 27 from pathlib import Path 28 29 30 DEFAULT_MAX_PER_DAY = 10 31 DEFAULT_APPROVED_FILE = "./data/cold-outbound-approved.json" 32 DEFAULT_HISTORY_FILE = "./data/cold-outbound-history.json" 33 34 35 def validate_outbound(text): 36 """Basic validation for outbound content. Returns (ok, text).""" 37 if not text or not isinstance(text, str): 38 return False, text 39 # Check for common leaked credential patterns 40 suspicious_patterns = [ 41 r'sk-[a-zA-Z0-9]{20,}', # API keys 42 r'Bearer [a-zA-Z0-9\-_.]+', # Auth headers 43 r'/Users/[a-zA-Z]+/', # Local paths 44 r'password\s*[:=]\s*\S+', # Password patterns 45 ] 46 import re 47 for pattern in suspicious_patterns: 48 if re.search(pattern, text, re.IGNORECASE): 49 return False, text 50 return True, text 51 52 53 def load_history(history_path): 54 if os.path.exists(history_path): 55 try: 56 with open(history_path) as f: 57 return json.load(f) 58 except Exception: 59 pass 60 return [] 61 62 63 def save_history(history, history_path): 64 os.makedirs(os.path.dirname(history_path), exist_ok=True) 65 with open(history_path, 'w') as f: 66 json.dump(history, f, indent=2) 67 68 69 def count_sent_today(history): 70 today = datetime.now().strftime("%Y-%m-%d") 71 return sum(1 for h in history if h.get("sent_date", "").startswith(today)) 72 73 74 def send_email_smtp(to, subject, body, sender_email, sender_name, 75 smtp_host, smtp_port, smtp_user, smtp_password, dry_run=False): 76 """Send via SMTP.""" 77 ok_subj, subject = validate_outbound(subject) 78 ok_body, body = validate_outbound(body) 79 if not ok_subj or not ok_body: 80 print(f" 🛡️ Email to {to} BLOCKED by validation (suspicious content detected)") 81 return False 82 83 if dry_run: 84 print(f" [DRY RUN] Would send to {to}: {subject}") 85 return True 86 87 try: 88 msg = MIMEText(body, 'plain') 89 msg['Subject'] = subject 90 msg['From'] = f"{sender_name} <{sender_email}>" 91 msg['To'] = to 92 93 with smtplib.SMTP(smtp_host, int(smtp_port)) as server: 94 server.starttls() 95 server.login(smtp_user, smtp_password) 96 server.sendmail(sender_email, [to], msg.as_string()) 97 98 print(f" ✅ Sent to {to}: {subject}") 99 return True 100 except Exception as e: 101 print(f" ❌ Error sending to {to}: {e}", file=sys.stderr) 102 return False 103 104 105 def send_email_cli(to, subject, body, sender_email, sender_name, cli_command, dry_run=False): 106 """Send via a CLI tool (e.g., gog, msmtp, mailx).""" 107 ok_subj, subject = validate_outbound(subject) 108 ok_body, body = validate_outbound(body) 109 if not ok_subj or not ok_body: 110 print(f" 🛡️ Email to {to} BLOCKED by validation (suspicious content detected)") 111 return False 112 113 if dry_run: 114 print(f" [DRY RUN] Would send to {to}: {subject}") 115 return True 116 117 try: 118 # Default CLI pattern: gog gmail send 119 cmd = cli_command.split() + [ 120 "--to", to, 121 "--subject", subject, 122 "--body", body, 123 "--from", f"{sender_name} <{sender_email}>", 124 ] 125 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) 126 if result.returncode == 0: 127 print(f" ✅ Sent to {to}: {subject}") 128 return True 129 else: 130 print(f" ❌ Failed to send to {to}: {result.stderr}", file=sys.stderr) 131 return False 132 except Exception as e: 133 print(f" ❌ Error sending to {to}: {e}", file=sys.stderr) 134 return False 135 136 137 def main(): 138 parser = argparse.ArgumentParser(description="Cold Outbound Sender") 139 parser.add_argument("--dry-run", action="store_true", help="Don't actually send emails") 140 parser.add_argument("--max", type=int, default=DEFAULT_MAX_PER_DAY, 141 help=f"Max emails per day (default: {DEFAULT_MAX_PER_DAY})") 142 parser.add_argument("--approved-file", default=DEFAULT_APPROVED_FILE, 143 help="Path to approved prospects JSON file") 144 parser.add_argument("--history-file", default=DEFAULT_HISTORY_FILE, 145 help="Path to send history JSON file") 146 parser.add_argument("--send-method", choices=["smtp", "cli"], default="smtp", 147 help="Send method: smtp or cli (default: smtp)") 148 parser.add_argument("--cli-command", default="gog gmail send", 149 help="CLI command for sending (used with --send-method cli)") 150 args = parser.parse_args() 151 152 # Load config from env 153 sender_email = os.environ.get("SENDER_EMAIL", "") 154 sender_name = os.environ.get("SENDER_NAME", "") 155 smtp_host = os.environ.get("SMTP_HOST", "smtp.gmail.com") 156 smtp_port = os.environ.get("SMTP_PORT", "587") 157 smtp_user = os.environ.get("SMTP_USER", sender_email) 158 smtp_password = os.environ.get("SMTP_PASSWORD", "") 159 160 if not os.path.exists(args.approved_file): 161 print(f"No approved prospects file found at {args.approved_file}") 162 sys.exit(0) 163 164 with open(args.approved_file) as f: 165 approved = json.load(f) 166 167 history = load_history(args.history_file) 168 sent_today = count_sent_today(history) 169 remaining = args.max - sent_today 170 171 if remaining <= 0: 172 print(f"Already sent {sent_today} emails today (max {args.max}). Stopping.") 173 sys.exit(0) 174 175 sent_count = 0 176 for prospect in approved: 177 if sent_count >= remaining: 178 break 179 180 email = prospect.get("email") 181 if not email or email == "Unknown": 182 continue 183 184 # Check if already sent 185 if any(h.get("email") == email for h in history): 186 print(f" SKIP {email}: already in history") 187 continue 188 189 angle_key = prospect.get("approved_angle", "A") 190 drafts = prospect.get("angle_drafts", {}) 191 draft = drafts.get(angle_key, {}) 192 193 subject = draft.get("subject", f"Quick question for {prospect.get('company', 'you')}") 194 body = draft.get("body", "") 195 196 if not body: 197 print(f" SKIP {email}: no draft body for angle {angle_key}") 198 continue 199 200 if args.send_method == "smtp": 201 if not smtp_password and not args.dry_run: 202 print("ERROR: SMTP_PASSWORD env var required for smtp sending.") 203 sys.exit(1) 204 success = send_email_smtp( 205 email, subject, body, sender_email, sender_name, 206 smtp_host, smtp_port, smtp_user, smtp_password, args.dry_run 207 ) 208 else: 209 success = send_email_cli( 210 email, subject, body, sender_email, sender_name, 211 args.cli_command, args.dry_run 212 ) 213 214 if success: 215 history.append({ 216 "company": prospect.get("company", ""), 217 "contact_name": prospect.get("contact_name", ""), 218 "email": email, 219 "angle": angle_key, 220 "subject": subject, 221 "sent_date": datetime.now().isoformat(), 222 "score": prospect.get("score", 0), 223 }) 224 sent_count += 1 225 226 if not args.dry_run: 227 save_history(history, args.history_file) 228 229 # Remove sent prospects from approved file 230 if not args.dry_run and sent_count > 0: 231 sent_emails = {h["email"] for h in history} 232 remaining_approved = [p for p in approved if p.get("email") not in sent_emails] 233 with open(args.approved_file, 'w') as f: 234 json.dump(remaining_approved, f, indent=2) 235 236 print(f"\nSent {sent_count} emails ({'dry run' if args.dry_run else 'live'}). Total today: {sent_today + sent_count}") 237 238 239 if __name__ == "__main__": 240 main()