EMAILOUT / backend /app /smartlead_client.py
Seth
update
f485041
import os
import requests
import time
from typing import Dict, List, Optional
from datetime import datetime
class SmartleadClient:
"""Client for Smartlead API integration"""
BASE_URL = "https://server.smartlead.ai/api/v1"
def __init__(self, api_key: Optional[str] = None, client_api_key: Optional[str] = None):
"""
Initialize Smartlead client
Args:
api_key: Admin API key (required for campaign management operations)
client_api_key: Client API key (optional, may be required for certain operations)
"""
self.api_key = api_key or os.getenv("SMARTLEAD_API_KEY")
self.client_api_key = client_api_key or os.getenv("SMARTLEAD_CLIENT_API_KEY")
if not self.api_key:
raise ValueError("SMARTLEAD_API_KEY environment variable is required (Admin API key)")
# Smartlead API uses query parameter authentication, not Bearer token
# According to docs: https://api.smartlead.ai/reference/authentication
self.headers = {
"Content-Type": "application/json"
}
def _make_request(self, method: str, endpoint: str, data: Optional[Dict] = None, max_retries: int = 3) -> Dict:
"""Make API request with retry logic for 429/5xx errors"""
# Smartlead API requires api_key as query parameter
# Format: ?api_key=yourApiKey
url = f"{self.BASE_URL}{endpoint}?api_key={self.api_key}"
# If client API key is provided, add it as well
if self.client_api_key:
url += f"&client_api_key={self.client_api_key}"
for attempt in range(max_retries):
try:
response = requests.request(
method=method,
url=url,
headers=self.headers,
json=data,
timeout=30
)
# Handle authentication errors with detailed message
if response.status_code == 401:
try:
error_data = response.json()
error_msg = error_data.get('message') or error_data.get('error') or response.text or "Unauthorized"
except:
error_msg = response.text or "Unauthorized"
raise Exception(f"Smartlead API authentication failed (401): {error_msg}. Please verify your API key is correctly set in SMARTLEAD_API_KEY environment variable. If using client API key, also set SMARTLEAD_CLIENT_API_KEY.")
# Handle rate limiting
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
if attempt < max_retries - 1:
time.sleep(retry_after)
continue
# Handle client errors (4xx) - don't retry, but provide detailed error
if 400 <= response.status_code < 500:
try:
error_data = response.json()
# Try multiple possible error message fields
error_msg = (
error_data.get('message') or
error_data.get('error') or
error_data.get('detail') or
error_data.get('msg') or
str(error_data) or
response.text or
f"Client error {response.status_code}"
)
except:
error_msg = response.text or f"Client error {response.status_code}"
# Include URL and status code for debugging
raise Exception(f"Smartlead API client error ({response.status_code}): {error_msg}. URL: {url}")
# Handle server errors
if response.status_code >= 500:
if attempt < max_retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
response.raise_for_status()
return response.json() if response.content else {}
except requests.exceptions.HTTPError as e:
if response.status_code == 401:
error_msg = response.text or 'Unauthorized'
raise Exception(f"Smartlead API authentication failed (401): {error_msg}. Please verify your API key is correctly set in SMARTLEAD_API_KEY environment variable.")
if attempt == max_retries - 1:
raise Exception(f"Smartlead API error after {max_retries} attempts: {str(e)}")
if response.status_code < 500:
raise # Don't retry on 4xx errors except 429
time.sleep(2 ** attempt)
except requests.exceptions.RequestException as e:
if attempt == max_retries - 1:
raise Exception(f"Smartlead API error after {max_retries} attempts: {str(e)}")
time.sleep(2 ** attempt)
raise Exception("Failed to make request after retries")
def get_campaigns(self) -> List[Dict]:
"""Get list of all campaigns from Smartlead
Returns list of campaigns with their IDs and names
"""
try:
response = self._make_request("GET", "/campaigns")
# Handle different response structures
if isinstance(response, list):
return response
elif isinstance(response, dict):
# Check for common response structures
campaigns = response.get('campaigns') or response.get('data') or response.get('results') or []
if isinstance(campaigns, list):
return campaigns
# If it's a dict with campaign info, wrap it
if response.get('campaign_id') or response.get('id'):
return [response]
return []
except Exception as e:
# If endpoint doesn't exist or fails, return empty list
return []
def save_campaign_sequence(self, campaign_id: str, sequences: List[Dict]) -> Dict:
"""Save campaign sequence steps"""
data = {
"sequences": sequences
}
return self._make_request("POST", f"/campaigns/{campaign_id}/sequences", data)
def add_leads_to_campaign(self, campaign_id: str, leads: List[Dict]) -> Dict:
"""Add leads to a campaign
Smartlead API expects "lead_list" parameter, not "leads"
"""
data = {
"lead_list": leads # API expects "lead_list", not "leads"
}
return self._make_request("POST", f"/campaigns/{campaign_id}/leads", data)
def get_lead_by_email(self, campaign_id: str, email: str) -> Optional[Dict]:
"""Get lead information by email address
This may use different endpoint patterns:
- GET /campaigns/{campaign_id}/leads?email={email}
- GET /campaigns/{campaign_id}/leads/{email}
"""
endpoints = [
f"/campaigns/{campaign_id}/leads?email={email}",
f"/campaigns/{campaign_id}/leads",
]
for endpoint in endpoints:
try:
response = self._make_request("GET", endpoint)
# Response might be a list of leads or a single lead
if isinstance(response, list):
for lead in response:
if lead.get('email') == email or lead.get('email_address') == email:
return lead
elif isinstance(response, dict):
if response.get('email') == email or response.get('email_address') == email:
return response
# Check if response has a 'leads' or 'data' array
leads_list = response.get('leads') or response.get('data') or []
for lead in leads_list:
if lead.get('email') == email or lead.get('email_address') == email:
return lead
return None
except Exception as e:
# If 404 or other error, try next endpoint
if "404" in str(e):
continue
# For other errors, log and continue
continue
return None
def update_campaign_settings(self, campaign_id: str, settings: Dict) -> Dict:
"""Update campaign settings"""
return self._make_request("POST", f"/campaigns/{campaign_id}/settings", settings)
def update_lead(self, campaign_id: str, lead_id: int, custom_variables: Dict) -> Dict:
"""Update a lead's custom variables after adding to campaign
Smartlead API requires lead_id (number) to update leads.
"""
# Try different endpoint patterns with lead_id
endpoints = [
f"/campaigns/{campaign_id}/leads/{lead_id}",
f"/campaigns/{campaign_id}/leads/{lead_id}/update",
f"/campaigns/{campaign_id}/leads/update"
]
# Try different payload structures
payloads = [
custom_variables, # Direct custom variables
{"custom_variables": custom_variables},
{"variables": custom_variables},
{"customFields": custom_variables},
{"lead_id": lead_id, **custom_variables}, # Include lead_id with custom vars
]
last_error = None
for endpoint in endpoints:
for payload in payloads:
try:
if endpoint.endswith("/update"):
return self._make_request("POST", endpoint, payload)
else:
return self._make_request("PUT", endpoint, payload)
except Exception as e:
last_error = e
# If it's a 400 error, try next variation
if "400" in str(e) or "404" in str(e):
continue
# For other errors, re-raise
raise
# If all variations failed, raise the last error
raise last_error if last_error else Exception(f"Failed to update lead {lead_id}: All endpoint variations failed")
def build_sequences(self, steps_count: int) -> List[Dict]:
"""Build sequence templates for campaign
Smartlead campaign steps use variables:
- Step N subject template: {{subject_N}}
- Step N body template: Hi {{first_name}},\n\n{{body_N}}\n\n
The actual per-contact content is injected via custom_variables when adding leads.
seq_delay_details is required by Smartlead API and specifies delay between emails.
Format: delay in days (e.g., 2 days between emails)
"""
sequences = []
for i in range(1, steps_count + 1):
# Default delay: 2 days between emails (can be customized)
# First email (step 1) typically has no delay, subsequent emails have 2-day delay
delay_days = 0 if i == 1 else 2
sequences.append({
"seq_number": i, # Required by Smartlead API (not "step")
"subject": f"{{{{subject_{i}}}}}", # Double braces escape to produce {{subject_1}}, etc.
"email_body": f"Hi {{{{first_name}}}},\n\n{{{{body_{i}}}}}\n\n", # Produces: Hi {{first_name}},\n\n{{body_1}}\n\n
"seq_delay_details": {
"delay_in_days": delay_days # Required by Smartlead API
}
})
return sequences