/ outbound-engine / scripts / instantly-audit.py
instantly-audit.py
  1  #!/usr/bin/env python3
  2  """
  3  instantly-audit.py
  4  Pulls campaign data, account inventory, and warmup scores from the Instantly v2 API.
  5  
  6  Usage:
  7      python3 instantly-audit.py --api-key YOUR_KEY
  8      python3 instantly-audit.py  # uses INSTANTLY_API_KEY env var
  9      python3 instantly-audit.py --api-key YOUR_KEY --output report.md
 10      python3 instantly-audit.py --api-key YOUR_KEY --json  # raw JSON output
 11  
 12  Instantly v2 API docs: https://developer.instantly.ai/
 13  """
 14  
 15  import argparse
 16  import json
 17  import os
 18  import sys
 19  import time
 20  from datetime import datetime
 21  
 22  try:
 23      import requests
 24  except ImportError:
 25      print("ERROR: 'requests' not installed. Run: pip install requests")
 26      sys.exit(1)
 27  
 28  BASE_URL = "https://api.instantly.ai/api/v2"
 29  
 30  
 31  def get_headers(api_key: str) -> dict:
 32      return {
 33          "Authorization": f"Bearer {api_key}",
 34          "Content-Type": "application/json",
 35      }
 36  
 37  
 38  def paginate(url: str, headers: dict, params: dict = None, limit: int = 100) -> list:
 39      """Handle Instantly v2 cursor-based pagination."""
 40      results = []
 41      params = params or {}
 42      params["limit"] = limit
 43      starting_after = None
 44  
 45      while True:
 46          if starting_after:
 47              params["starting_after"] = starting_after
 48  
 49          try:
 50              resp = requests.get(url, headers=headers, params=params, timeout=30)
 51          except requests.exceptions.RequestException as e:
 52              print(f"  āš ļø  Request failed: {e}")
 53              break
 54  
 55          if resp.status_code == 429:
 56              retry_after = int(resp.headers.get("Retry-After", 5))
 57              print(f"  ā³ Rate limited. Waiting {retry_after}s...")
 58              time.sleep(retry_after)
 59              continue
 60  
 61          if resp.status_code == 401:
 62              print("  šŸ”“ Authentication failed. Check your API key.")
 63              sys.exit(1)
 64  
 65          if not resp.ok:
 66              print(f"  āš ļø  API error {resp.status_code}: {resp.text[:200]}")
 67              break
 68  
 69          data = resp.json()
 70          items = data.get("items", data if isinstance(data, list) else [])
 71          results.extend(items)
 72  
 73          next_cursor = data.get("next_starting_after") or data.get("next_cursor")
 74          if not next_cursor or len(items) < limit:
 75              break
 76          starting_after = next_cursor
 77  
 78      return results
 79  
 80  
 81  def fetch_campaigns(headers: dict) -> list:
 82      """Fetch all campaigns with analytics."""
 83      print("šŸ“‹ Fetching campaigns...")
 84      campaigns = paginate(f"{BASE_URL}/campaigns", headers)
 85      print(f"   Found {len(campaigns)} campaigns")
 86      return campaigns
 87  
 88  
 89  def fetch_campaign_analytics(headers: dict, campaign_ids: list) -> dict:
 90      """Fetch analytics summary for campaigns."""
 91      if not campaign_ids:
 92          return {}
 93  
 94      print("šŸ“Š Fetching campaign analytics...")
 95      analytics = {}
 96  
 97      for i in range(0, len(campaign_ids), 10):
 98          batch = campaign_ids[i:i+10]
 99          try:
100              resp = requests.get(
101                  f"{BASE_URL}/campaigns/analytics/overview",
102                  headers=headers,
103                  params={"campaign_id": batch},
104                  timeout=30,
105              )
106              if resp.ok:
107                  data = resp.json()
108                  if isinstance(data, dict):
109                      analytics.update(data)
110                  elif isinstance(data, list):
111                      for item in data:
112                          cid = item.get("campaign_id") or item.get("id")
113                          if cid:
114                              analytics[cid] = item
115          except requests.exceptions.RequestException as e:
116              print(f"  āš ļø  Analytics fetch failed for batch: {e}")
117  
118          time.sleep(0.3)
119  
120      return analytics
121  
122  
123  def fetch_accounts(headers: dict) -> list:
124      """Fetch all sending accounts with warmup status."""
125      print("šŸ“§ Fetching sending accounts...")
126      accounts = paginate(f"{BASE_URL}/accounts", headers)
127      print(f"   Found {len(accounts)} accounts")
128      return accounts
129  
130  
131  def fetch_warmup_scores(headers: dict, account_emails: list) -> dict:
132      """Fetch warmup analytics for accounts."""
133      if not account_emails:
134          return {}
135  
136      print("šŸ”„ Fetching warmup scores...")
137      warmup_data = {}
138  
139      for email in account_emails:
140          try:
141              resp = requests.get(
142                  f"{BASE_URL}/accounts/{email}/warmup/analytics",
143                  headers=headers,
144                  timeout=30,
145              )
146              if resp.ok:
147                  warmup_data[email] = resp.json()
148              elif resp.status_code == 404:
149                  warmup_data[email] = {"score": None, "status": "no_warmup_data"}
150              time.sleep(0.1)
151          except requests.exceptions.RequestException:
152              warmup_data[email] = {"score": None, "status": "fetch_error"}
153  
154      return warmup_data
155  
156  
157  def assess_warmup_readiness(account: dict, warmup: dict) -> tuple:
158      """Return (ready: bool, issues: list) for an account."""
159      issues = []
160  
161      score = (warmup.get("warmup_score") or warmup.get("score")
162               or account.get("stat_warmup_score") or account.get("warmup_score"))
163      if score is None:
164          issues.append("No warmup data available")
165      elif score < 80:
166          issues.append(f"Warmup score {score} < 80 (minimum required)")
167  
168      warmup_start = account.get("warmup_start_date") or account.get("created_at")
169      if warmup_start:
170          try:
171              start_dt = datetime.fromisoformat(warmup_start.replace("Z", "+00:00"))
172              days_warmed = (datetime.now(start_dt.tzinfo) - start_dt).days
173              if days_warmed < 14:
174                  issues.append(f"Only {days_warmed} days warmed (need 14+)")
175          except (ValueError, AttributeError):
176              pass
177  
178      status = str(account.get("status", "")).lower()
179      if status in ("paused", "error", "suspended", "disabled"):
180          issues.append(f"Account status: {status}")
181  
182      ready = len(issues) == 0
183      return ready, issues
184  
185  
186  def format_pct(value, total, decimals=1) -> str:
187      if not total:
188          return "N/A"
189      return f"{(value / total * 100):.{decimals}f}%"
190  
191  
192  def generate_report(campaigns: list, analytics: dict, accounts: list, warmup_scores: dict) -> str:
193      lines = []
194      now = datetime.now().strftime("%Y-%m-%d %H:%M")
195  
196      lines.append(f"# Instantly Audit Report")
197      lines.append(f"Generated: {now}\n")
198  
199      # ── Account Inventory ──
200      lines.append("## Sending Account Inventory\n")
201  
202      ready_accounts = []
203      not_ready_accounts = []
204  
205      for acct in accounts:
206          email = acct.get("email", "unknown")
207          warmup = warmup_scores.get(email, {})
208          ready, issues = assess_warmup_readiness(acct, warmup)
209          score = (warmup.get("warmup_score") or warmup.get("score")
210                   or acct.get("stat_warmup_score") or acct.get("warmup_score") or "N/A")
211          daily_limit = acct.get("sending_limit") or acct.get("daily_limit", 30)
212  
213          row = {
214              "email": email,
215              "status": acct.get("status", "unknown"),
216              "warmup_score": score,
217              "daily_limit": daily_limit,
218              "ready": ready,
219              "issues": issues,
220          }
221  
222          if ready:
223              ready_accounts.append(row)
224          else:
225              not_ready_accounts.append(row)
226  
227      total_accounts = len(accounts)
228      total_ready = len(ready_accounts)
229  
230      lines.append(f"**Total accounts:** {total_accounts}")
231      lines.append(f"**Ready to send:** {total_ready} āœ…")
232      lines.append(f"**Not ready:** {len(not_ready_accounts)} āš ļø\n")
233  
234      if ready_accounts:
235          conservative_daily = total_ready * 30
236          aggressive_daily = total_ready * 50
237          conservative_monthly = conservative_daily * 22
238          aggressive_monthly = aggressive_daily * 22
239          lines.append("### Capacity Math (ready accounts only)")
240          lines.append(f"- Conservative (30/day/account): **{conservative_daily:,}/day → {conservative_monthly:,}/month**")
241          lines.append(f"- Aggressive (50/day/account): **{aggressive_daily:,}/day → {aggressive_monthly:,}/month**\n")
242  
243      lines.append("### āœ… Ready Accounts")
244      if ready_accounts:
245          lines.append("| Account | Status | Warmup Score | Daily Limit |")
246          lines.append("|---------|--------|-------------|------------|")
247          for a in ready_accounts:
248              lines.append(f"| {a['email']} | {a['status']} | {a['warmup_score']} | {a['daily_limit']} |")
249      else:
250          lines.append("_None — no accounts meet warmup requirements_")
251  
252      lines.append("\n### āš ļø Not Ready Accounts")
253      if not_ready_accounts:
254          lines.append("| Account | Status | Warmup Score | Issues |")
255          lines.append("|---------|--------|-------------|--------|")
256          for a in not_ready_accounts:
257              issues_str = "; ".join(a["issues"]) if a["issues"] else "unknown"
258              lines.append(f"| {a['email']} | {a['status']} | {a['warmup_score']} | {issues_str} |")
259      else:
260          lines.append("_None — all accounts are ready_")
261  
262      # ── Campaign Performance ──
263      lines.append("\n---\n## Campaign Performance\n")
264      lines.append(f"**Total campaigns:** {len(campaigns)}\n")
265  
266      if not campaigns:
267          lines.append("_No campaigns found_")
268      else:
269          lines.append("| Campaign | Status | Sent | Open Rate | Reply Rate | Positive Reply Rate |")
270          lines.append("|----------|--------|------|-----------|-----------|-------------------|")
271  
272          for c in campaigns:
273              cid = c.get("id", "")
274              name = c.get("name", "Unnamed")[:50]
275              status = c.get("status", "unknown")
276  
277              a = analytics.get(cid, {})
278              sent = a.get("emails_sent", 0) or c.get("emails_sent", 0)
279              opened = a.get("emails_opened", 0)
280              replied = a.get("emails_replied", 0)
281              positive = a.get("positive_replies", 0)
282  
283              open_rate = format_pct(opened, sent)
284              reply_rate = format_pct(replied, sent)
285              pos_rate = format_pct(positive, sent)
286  
287              lines.append(f"| {name} | {status} | {sent:,} | {open_rate} | {reply_rate} | {pos_rate} |")
288  
289      # ── Flags & Recommendations ──
290      lines.append("\n---\n## Flags & Recommendations\n")
291  
292      flags = []
293  
294      if total_ready == 0:
295          flags.append("šŸ”“ **BLOCKER:** No accounts are ready to send. All fail warmup requirements. Do not launch campaigns.")
296      elif total_ready < 3:
297          flags.append(f"āš ļø Only {total_ready} account(s) ready. Low volume capacity. Consider warming more accounts.")
298  
299      low_open = []
300      low_reply = []
301      for c in campaigns:
302          cid = c.get("id", "")
303          a = analytics.get(cid, {})
304          sent = a.get("emails_sent", 0)
305          if sent < 50:
306              continue
307          opened = a.get("emails_opened", 0)
308          replied = a.get("emails_replied", 0)
309          open_pct = (opened / sent * 100) if sent else 0
310          reply_pct = (replied / sent * 100) if sent else 0
311          if open_pct < 40:
312              low_open.append(c.get("name", cid))
313          if reply_pct < 3:
314              low_reply.append(c.get("name", cid))
315  
316      if low_open:
317          flags.append(f"āš ļø Low open rate (<40%) campaigns (subject line issue): {', '.join(low_open[:5])}")
318      if low_reply:
319          flags.append(f"āš ļø Low reply rate (<3%) campaigns (copy/offer issue): {', '.join(low_reply[:5])}")
320  
321      if not flags:
322          flags.append("āœ… No critical flags detected.")
323  
324      for f in flags:
325          lines.append(f"- {f}")
326  
327      lines.append(f"\n---\n_Audit complete. {total_accounts} accounts, {len(campaigns)} campaigns analyzed._")
328      return "\n".join(lines)
329  
330  
331  def main():
332      parser = argparse.ArgumentParser(description="Instantly v2 API Audit Tool")
333      parser.add_argument("--api-key", help="Instantly API key (or set INSTANTLY_API_KEY env var)")
334      parser.add_argument("--output", help="Write report to this file (default: print to stdout)")
335      parser.add_argument("--json", action="store_true", help="Output raw JSON instead of markdown report")
336      args = parser.parse_args()
337  
338      api_key = args.api_key or os.environ.get("INSTANTLY_API_KEY")
339      if not api_key:
340          api_key = input("Instantly API key: ").strip()
341      if not api_key:
342          print("ERROR: API key required. Set INSTANTLY_API_KEY env var or pass --api-key.")
343          sys.exit(1)
344  
345      headers = get_headers(api_key)
346  
347      print(f"\nšŸ” Starting Instantly audit...\n")
348  
349      campaigns = fetch_campaigns(headers)
350      campaign_ids = [c.get("id") for c in campaigns if c.get("id")]
351      analytics = fetch_campaign_analytics(headers, campaign_ids)
352  
353      accounts = fetch_accounts(headers)
354      account_emails = [a.get("email") for a in accounts if a.get("email")]
355      warmup_scores = fetch_warmup_scores(headers, account_emails)
356  
357      if args.json:
358          output = json.dumps({
359              "campaigns": campaigns,
360              "analytics": analytics,
361              "accounts": accounts,
362              "warmup_scores": warmup_scores,
363          }, indent=2, default=str)
364      else:
365          output = generate_report(campaigns, analytics, accounts, warmup_scores)
366  
367      if args.output:
368          with open(args.output, "w") as f:
369              f.write(output)
370          print(f"\nāœ… Report written to: {args.output}")
371      else:
372          print("\n" + output)
373  
374  
375  if __name__ == "__main__":
376      main()