/ outbound-engine / scripts / cold-outbound-sender.py
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()