File size: 12,047 Bytes
c356b87
 
 
 
 
 
 
 
 
 
 
 
03708bb
 
 
 
 
 
 
 
c356b87
03708bb
 
c356b87
03708bb
c356b87
03708bb
 
c356b87
 
 
 
 
 
03708bb
 
 
 
 
 
 
c356b87
 
 
 
 
 
 
 
 
 
 
912d218
 
 
 
 
 
 
03708bb
912d218
c356b87
 
 
 
 
 
 
d154f53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c356b87
 
 
 
 
 
 
 
 
912d218
 
03708bb
 
912d218
 
 
 
 
c356b87
 
 
 
 
 
 
f485041
 
d154f53
f485041
d154f53
f485041
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c356b87
 
 
 
 
 
 
 
 
d8a52e7
 
 
 
c356b87
d8a52e7
c356b87
 
 
12ebb02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c356b87
 
 
 
f1bcbcc
a941701
 
f1bcbcc
a941701
f1bcbcc
a941701
f1bcbcc
 
a941701
 
 
 
 
 
 
 
 
f1bcbcc
a941701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1bcbcc
a941701
c356b87
d154f53
 
 
 
 
 
b53e47a
 
 
d154f53
c356b87
 
b53e47a
 
 
 
c356b87
797643d
bde7250
 
b53e47a
b6e218c
b53e47a
c356b87
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
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