Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import time | |
| from datetime import datetime | |
| from typing import List, Dict, Any, Optional | |
| from pydantic import BaseModel, Field, EmailStr, validator | |
| from fastapi import FastAPI, HTTPException, Query, Depends, Request | |
| from fastapi.responses import JSONResponse, Response | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.openapi.utils import get_openapi | |
| import httpx | |
| from dotenv import load_dotenv | |
| # LangChain and OpenAI imports | |
| try: | |
| from langchain_openai import ChatOpenAI | |
| from langchain.prompts import ChatPromptTemplate | |
| LANGCHAIN_AVAILABLE = True | |
| except ImportError: | |
| LANGCHAIN_AVAILABLE = False | |
| print("Warning: LangChain not available. Install with: pip install langchain langchain-openai") | |
| load_dotenv() | |
| # Configuration | |
| SMARTLEAD_API_KEY = os.getenv("SMARTLEAD_API_KEY") | |
| SMARTLEAD_BASE_URL = "https://server.smartlead.ai/api/v1" | |
| # Initialize FastAPI app | |
| app = FastAPI( | |
| title="Smartlead API - Complete Integration", | |
| version="2.0.0", | |
| description="Comprehensive FastAPI wrapper for Smartlead email automation platform", | |
| docs_url="/docs", | |
| redoc_url="/redoc" | |
| ) | |
| # Add CORS middleware | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ============================================================================ | |
| # DATA MODELS | |
| # ============================================================================ | |
| class CreateCampaignRequest(BaseModel): | |
| name: str = Field(..., description="Campaign name") | |
| client_id: Optional[int] = Field(None, description="Client ID (leave null if no client)") | |
| class CampaignScheduleRequest(BaseModel): | |
| timezone: str = Field(..., description="Timezone for the campaign schedule (e.g., 'America/Los_Angeles')") | |
| days_of_the_week: List[int] = Field(..., description="Days of the week for scheduling [0=Sunday, 1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday]") | |
| start_hour: str = Field(..., description="Start hour for sending emails in HH:MM format (e.g., '09:00')") | |
| end_hour: str = Field(..., description="End hour for sending emails in HH:MM format (e.g., '18:00')") | |
| min_time_btw_emails: int = Field(..., description="Minimum time in minutes between sending emails") | |
| max_new_leads_per_day: int = Field(..., description="Maximum number of new leads to process per day") | |
| schedule_start_time: str = Field(..., description="Schedule start time in ISO 8601 format (e.g., '2023-04-25T07:29:25.978Z')") | |
| class CampaignSettingsRequest(BaseModel): | |
| track_settings: List[str] = Field(..., description="Tracking settings array (allowed values: DONT_TRACK_EMAIL_OPEN, DONT_TRACK_LINK_CLICK, DONT_TRACK_REPLY_TO_AN_EMAIL)") | |
| stop_lead_settings: str = Field(..., description="Settings for stopping leads (allowed values: CLICK_ON_A_LINK, OPEN_AN_EMAIL)") | |
| unsubscribe_text: str = Field(..., description="Text for the unsubscribe link") | |
| send_as_plain_text: bool = Field(..., description="Whether emails should be sent as plain text") | |
| follow_up_percentage: int = Field(ge=0, le=100, description="Follow-up percentage (max 100, min 0)") | |
| client_id: Optional[int] = Field(None, description="Client ID (leave as null if not needed)") | |
| enable_ai_esp_matching: bool = Field(False, description="Enable AI ESP matching (by default is false)") | |
| class LeadInput(BaseModel): | |
| first_name: Optional[str] = Field(None, description="Lead's first name") | |
| last_name: Optional[str] = Field(None, description="Lead's last name") | |
| email: str = Field(..., description="Lead's email address") | |
| phone_number: Optional[str] = Field(None, description="Lead's phone number") | |
| company_name: Optional[str] = Field(None, description="Lead's company name") | |
| website: Optional[str] = Field(None, description="Lead's website") | |
| location: Optional[str] = Field(None, description="Lead's location") | |
| custom_fields: Optional[Dict[str, str]] = Field(None, description="Custom fields as key-value pairs") | |
| linkedin_profile: Optional[str] = Field(None, description="Lead's LinkedIn profile URL") | |
| company_url: Optional[str] = Field(None, description="Company website URL") | |
| class LeadSettings(BaseModel): | |
| ignore_global_block_list: bool = Field(True, description="Ignore leads if they are in the global block list") | |
| ignore_unsubscribe_list: bool = Field(True, description="Ignore leads if they are in the unsubscribe list") | |
| ignore_duplicate_leads_in_other_campaign: bool = Field(False, description="Allow leads to be added even if they are duplicates in other campaigns") | |
| class AddLeadsRequest(BaseModel): | |
| lead_list: List[LeadInput] = Field(..., max_items=100, description="List of leads to add (maximum 100 leads)") | |
| settings: Optional[LeadSettings] = Field(None, description="Settings for lead processing") | |
| class AddLeadsResponse(BaseModel): | |
| ok: bool = Field(..., description="Indicates if the operation was successful") | |
| upload_count: int = Field(..., description="Number of leads successfully uploaded") | |
| total_leads: int = Field(..., description="Total number of leads attempted to upload") | |
| already_added_to_campaign: int = Field(..., description="Number of leads already present in the campaign") | |
| duplicate_count: int = Field(..., description="Number of duplicate emails found") | |
| invalid_email_count: int = Field(..., description="Number of leads with invalid email format") | |
| unsubscribed_leads: Any = Field(..., description="Number of leads that had previously unsubscribed (can be int or empty list)") | |
| class SeqDelayDetails(BaseModel): | |
| delay_in_days: int = Field(..., description="Delay in days before sending this sequence") | |
| class SeqVariant(BaseModel): | |
| subject: str = Field(..., description="Email subject line") | |
| email_body: str = Field(..., description="Email body content (HTML format)") | |
| variant_label: str = Field(..., description="Variant label (A, B, C, etc.)") | |
| id: Optional[int] = Field(None, description="Variant ID (only for updating, not for creating)") | |
| class CampaignSequence(BaseModel): | |
| id: Optional[int] = Field(None, description="Sequence ID (only for updating, not for creating)") | |
| seq_number: int = Field(..., description="Sequence number (1, 2, 3, etc.)") | |
| seq_delay_details: SeqDelayDetails = Field(..., description="Delay details for this sequence") | |
| seq_variants: Optional[List[SeqVariant]] = Field(None, description="Email variants for A/B testing") | |
| subject: Optional[str] = Field("", description="Subject line (blank for follow-up in same thread)") | |
| email_body: Optional[str] = Field(None, description="Email body content (HTML format)") | |
| class SaveSequencesRequest(BaseModel): | |
| sequences: List[CampaignSequence] = Field(..., description="List of campaign sequences") | |
| class GenerateSequencesRequest(BaseModel): | |
| job_description: str = Field(..., description="Job description to generate sequences for") | |
| class Campaign(BaseModel): | |
| id: int = Field(..., description="The unique ID of the campaign") | |
| user_id: int = Field(..., description="The ID of the user who owns the campaign") | |
| created_at: datetime = Field(..., description="Timestamp of when the campaign was created") | |
| updated_at: datetime = Field(..., description="Timestamp of the last update to the campaign") | |
| status: str = Field(..., description="Current status of the campaign (DRAFTED, ACTIVE, COMPLETED, STOPPED, PAUSED)") | |
| name: str = Field(..., description="The name of the campaign") | |
| track_settings: str = Field(..., description="Tracking settings string (e.g., 'DONT_REPLY_TO_AN_EMAIL')") | |
| scheduler_cron_value: Optional[str] = Field(None, description="Scheduling details as JSON string") | |
| min_time_btwn_emails: int = Field(..., description="Minimum time between emails in minutes") | |
| max_leads_per_day: int = Field(..., description="Maximum number of leads to process per day") | |
| stop_lead_settings: str = Field(..., description="Settings for stopping leads (REPLY_TO_AN_EMAIL, CLICK_ON_ANY_LINK, etc.)") | |
| enable_ai_esp_matching: bool = Field(..., description="Indicates if AI ESP matching is enabled") | |
| send_as_plain_text: bool = Field(..., description="Indicates if emails for this campaign are sent as plain text") | |
| follow_up_percentage: str = Field(..., description="The percentage of follow-up emails allocated (e.g., '40%')") | |
| unsubscribe_text: Optional[str] = Field(None, description="The text used for the unsubscribe link") | |
| parent_campaign_id: Optional[int] = Field(None, description="Parent campaign ID if this is a child campaign") | |
| client_id: Optional[int] = Field(None, description="The ID of the client associated with the campaign (null if not attached to a client)") | |
| def parse_scheduler_cron_value(cls, v): | |
| """Parse scheduler_cron_value from JSON string to dict if needed""" | |
| if isinstance(v, str) and v: | |
| try: | |
| import json | |
| return json.loads(v) | |
| except json.JSONDecodeError: | |
| return v | |
| return v | |
| def parse_follow_up_percentage(cls, v): | |
| """Ensure follow_up_percentage is returned as string with % sign""" | |
| if isinstance(v, int): | |
| return f"{v}%" | |
| return v | |
| class Lead(BaseModel): | |
| id: int | |
| email: EmailStr | |
| first_name: Optional[str] | |
| last_name: Optional[str] | |
| company: Optional[str] | |
| position: Optional[str] | |
| phone_number: Optional[str] | |
| linkedin_url: Optional[str] | |
| status: Optional[str] | |
| # Additional models for new endpoints | |
| class LeadCategoryUpdateRequest(BaseModel): | |
| category_id: int = Field(..., description="Category ID to assign to the lead") | |
| pause_lead: bool = Field(False, description="Whether to pause the lead after category update") | |
| class CampaignStatusUpdateRequest(BaseModel): | |
| status: str = Field(..., description="New campaign status (PAUSED, STOPPED, START)") | |
| class ResumeLeadRequest(BaseModel): | |
| resume_lead_with_delay_days: Optional[int] = Field(None, description="Delay in days before resuming (defaults to 0)") | |
| class DomainBlockListRequest(BaseModel): | |
| domain_block_list: List[str] = Field(..., description="List of domains/emails to block") | |
| client_id: Optional[int] = Field(None, description="Client ID if blocking is client-specific") | |
| class WebhookRequest(BaseModel): | |
| id: Optional[int] = Field(None, description="Webhook ID (null for creating new)") | |
| name: str = Field(..., description="Webhook name") | |
| webhook_url: str = Field(..., description="Webhook URL") | |
| event_types: List[str] = Field(..., description="List of event types to listen for") | |
| categories: Optional[List[str]] = Field(None, description="List of categories to filter by") | |
| class WebhookDeleteRequest(BaseModel): | |
| id: int = Field(..., description="Webhook ID to delete") | |
| class ClientRequest(BaseModel): | |
| name: str = Field(..., description="Client name") | |
| email: str = Field(..., description="Client email") | |
| permission: List[str] = Field(..., description="List of permissions") | |
| logo: Optional[str] = Field(None, description="Client logo text") | |
| logo_url: Optional[str] = Field(None, description="Client logo URL") | |
| password: str = Field(..., description="Client password") | |
| class MessageHistoryRequest(BaseModel): | |
| email_stats_id: str = Field(..., description="Email stats ID for the specific email") | |
| email_body: str = Field(..., description="Reply message email body") | |
| reply_message_id: str = Field(..., description="Message ID to reply to") | |
| reply_email_time: str = Field(..., description="Time of the message being replied to") | |
| reply_email_body: str = Field(..., description="Body of the message being replied to") | |
| cc: Optional[str] = Field(None, description="CC recipients") | |
| bcc: Optional[str] = Field(None, description="BCC recipients") | |
| add_signature: bool = Field(True, description="Whether to add signature") | |
| # ============================================================================ | |
| # HELPER FUNCTIONS | |
| # ============================================================================ | |
| def _get_smartlead_url(endpoint: str) -> str: | |
| return f"{SMARTLEAD_BASE_URL}/{endpoint.lstrip('/')}" | |
| async def call_smartlead_api(method: str, endpoint: str, data: Any = None, params: Dict[str, Any] = None) -> Any: | |
| if SMARTLEAD_API_KEY == "your-api-key-here": | |
| raise HTTPException(status_code=400, detail="Smartlead API key not configured") | |
| if params is None: | |
| params = {} | |
| params['api_key'] = SMARTLEAD_API_KEY | |
| url = _get_smartlead_url(endpoint) | |
| try: | |
| async with httpx.AsyncClient(timeout=30.0) as client: | |
| if method.upper() in ("GET", "DELETE"): | |
| resp = await client.request(method, url, params=params) | |
| else: | |
| resp = await client.request(method, url, params=params, json=data) | |
| if resp.status_code >= 400: | |
| # Try to parse error response as JSON for better error handling | |
| try: | |
| error_data = resp.json() | |
| error_message = error_data.get('message', error_data.get('error', 'Unknown error')) | |
| error_detail = error_data.get('detail', error_data.get('description', '')) | |
| raise HTTPException( | |
| status_code=resp.status_code, | |
| detail={ | |
| "error": True, | |
| "message": error_message, | |
| "detail": error_detail, | |
| "endpoint": endpoint, | |
| "method": method, | |
| "status_code": resp.status_code | |
| } | |
| ) | |
| except (ValueError, KeyError): | |
| # Fallback to raw text if JSON parsing fails | |
| raise HTTPException( | |
| status_code=resp.status_code, | |
| detail={ | |
| "error": True, | |
| "message": "API request failed", | |
| "detail": resp.text, | |
| "endpoint": endpoint, | |
| "method": method, | |
| "status_code": resp.status_code | |
| } | |
| ) | |
| return resp.json() | |
| except httpx.TimeoutException: | |
| raise HTTPException( | |
| status_code=408, | |
| detail={ | |
| "error": True, | |
| "message": "Request timeout", | |
| "detail": "The request to Smartlead API timed out", | |
| "endpoint": endpoint, | |
| "method": method | |
| } | |
| ) | |
| except httpx.RequestError as e: | |
| raise HTTPException( | |
| status_code=503, | |
| detail={ | |
| "error": True, | |
| "message": "Service unavailable", | |
| "detail": f"Failed to connect to Smartlead API: {str(e)}", | |
| "endpoint": endpoint, | |
| "method": method | |
| } | |
| ) | |
| # ============================================================================ | |
| # CAMPAIGN ENDPOINTS | |
| # ============================================================================ | |
| async def create_campaign(campaign: CreateCampaignRequest): | |
| """Create a new campaign in Smartlead""" | |
| return await call_smartlead_api("POST", "campaigns/create", data=campaign.dict()) | |
| async def list_campaigns(): | |
| """Fetch all campaigns from Smartlead API""" | |
| campaigns = await call_smartlead_api("GET", "campaigns") | |
| return {"campaigns": campaigns, "total": len(campaigns), "source": "smartlead"} | |
| async def get_campaign(campaign_id: int): | |
| """Get Campaign By Id""" | |
| return await call_smartlead_api("GET", f"campaigns/{campaign_id}") | |
| async def update_campaign_settings(campaign_id: int, settings: CampaignSettingsRequest): | |
| """Update Campaign General Settings""" | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/settings", data=settings.dict()) | |
| async def schedule_campaign(campaign_id: int, schedule: CampaignScheduleRequest): | |
| """Update Campaign Schedule""" | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/schedule", data=schedule.dict()) | |
| async def delete_campaign(campaign_id: int): | |
| """Delete Campaign""" | |
| return await call_smartlead_api("DELETE", f"campaigns/{campaign_id}") | |
| async def patch_campaign_status(campaign_id: int, request: CampaignStatusUpdateRequest): | |
| """Patch campaign status""" | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/status", data=request.dict()) | |
| async def campaign_analytics(campaign_id: int): | |
| """Fetch analytics for a campaign""" | |
| return await call_smartlead_api("GET", f"campaigns/{campaign_id}/analytics") | |
| async def fetch_campaign_statistics_by_campaign_id( | |
| campaign_id: int, | |
| offset: int = 0, | |
| limit: int = 100, | |
| email_sequence_number: Optional[int] = None, | |
| email_status: Optional[str] = None | |
| ): | |
| """Fetch Campaign Statistics By Campaign Id""" | |
| params = {"offset": offset, "limit": limit} | |
| if email_sequence_number: | |
| params["email_sequence_number"] = email_sequence_number | |
| if email_status: | |
| params["email_status"] = email_status | |
| return await call_smartlead_api("GET", f"campaigns/{campaign_id}/statistics", params=params) | |
| async def fetch_campaign_statistics_by_date_range( | |
| campaign_id: int, | |
| start_date: str, | |
| end_date: str | |
| ): | |
| """Fetch Campaign Statistics By Campaign Id And Date Range""" | |
| params = {"start_date": start_date, "end_date": end_date} | |
| return await call_smartlead_api("GET", f"campaigns/{campaign_id}/analytics-by-date", params=params) | |
| # ============================================================================ | |
| # LEAD MANAGEMENT ENDPOINTS | |
| # ============================================================================ | |
| async def get_campaign_leads(campaign_id: int, offset: int = 0, limit: int = 100): | |
| """List all leads by campaign id""" | |
| params = {"offset": offset, "limit": limit} | |
| return await call_smartlead_api("GET", f"campaigns/{campaign_id}/leads", params=params) | |
| async def add_leads_to_campaign(campaign_id: int, request: AddLeadsRequest): | |
| """Add leads to a campaign by ID""" | |
| request_data = request.dict() | |
| # Clean up the data - remove None values and empty strings | |
| for lead in request_data.get("lead_list", []): | |
| lead_cleaned = {k: v for k, v in lead.items() if v is not None and v != ""} | |
| if "custom_fields" in lead_cleaned: | |
| custom_fields = lead_cleaned["custom_fields"] | |
| if custom_fields: | |
| custom_fields_cleaned = {k: v for k, v in custom_fields.items() if v is not None and v != ""} | |
| if custom_fields_cleaned: | |
| lead_cleaned["custom_fields"] = custom_fields_cleaned | |
| else: | |
| lead_cleaned.pop("custom_fields", None) | |
| else: | |
| lead_cleaned.pop("custom_fields", None) | |
| lead.clear() | |
| lead.update(lead_cleaned) | |
| request_data["lead_list"] = [lead for lead in request_data["lead_list"] if lead] | |
| if not request_data["lead_list"]: | |
| raise HTTPException(status_code=400, detail="No valid leads to add. Please provide at least one lead with an email address.") | |
| if "settings" not in request_data or request_data["settings"] is None: | |
| request_data["settings"] = { | |
| "ignore_global_block_list": True, | |
| "ignore_unsubscribe_list": True, | |
| "ignore_duplicate_leads_in_other_campaign": False | |
| } | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/leads", data=request_data) | |
| async def add_bulk_leads(campaign_id: int, leads: List[LeadInput]): | |
| """Add multiple leads to a Smartlead campaign (legacy endpoint)""" | |
| request = AddLeadsRequest(lead_list=leads) | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/leads", data=request.dict()) | |
| async def resume_lead_by_campaign_id(campaign_id: int, lead_id: int, request: ResumeLeadRequest): | |
| """Resume Lead By Campaign ID""" | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/leads/{lead_id}/resume", data=request.dict()) | |
| async def pause_lead_by_campaign_id(campaign_id: int, lead_id: int): | |
| """Pause Lead By Campaign ID""" | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/leads/{lead_id}/pause") | |
| async def delete_lead_by_campaign_id(campaign_id: int, lead_id: int): | |
| """Delete Lead By Campaign ID""" | |
| return await call_smartlead_api("DELETE", f"campaigns/{campaign_id}/leads/{lead_id}") | |
| async def unsubscribe_lead_from_campaign(campaign_id: int, lead_id: int): | |
| """Unsubscribe/Pause Lead From Campaign""" | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/leads/{lead_id}/unsubscribe") | |
| async def unsubscribe_lead_from_all_campaigns(lead_id: int): | |
| """Unsubscribe Lead From All Campaigns""" | |
| return await call_smartlead_api("POST", f"leads/{lead_id}/unsubscribe") | |
| async def update_lead(lead_id: int, lead_data: Dict[str, Any]): | |
| """Update lead using the Lead ID""" | |
| return await call_smartlead_api("POST", f"leads/{lead_id}", data=lead_data) | |
| async def update_lead_category_by_campaign(campaign_id: int, lead_id: int, request: LeadCategoryUpdateRequest): | |
| """Update a lead's category based on their campaign""" | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/leads/{lead_id}/category", data=request.dict()) | |
| async def add_domain_to_global_block_list(request: DomainBlockListRequest): | |
| """Add Lead/Domain to Global Block List""" | |
| return await call_smartlead_api("POST", "leads/add-domain-block-list", data=request.dict()) | |
| async def fetch_lead_categories(): | |
| """Fetch lead categories""" | |
| return await call_smartlead_api("GET", "leads/fetch-categories") | |
| async def fetch_lead_by_email_address(email: str): | |
| """Fetch lead by email address""" | |
| return await call_smartlead_api("GET", "leads", params={"email": email}) | |
| async def campaigns_for_lead(lead_id: int): | |
| """Fetch all campaigns that a lead belongs to""" | |
| return await call_smartlead_api("GET", f"leads/{lead_id}/campaigns") | |
| async def check_lead_in_campaign(campaign_id: int, email: str): | |
| """Check if a lead exists in a campaign using efficient indexed lookups""" | |
| try: | |
| # Step 1: Get the global lead_id for the email address | |
| lead_response = await call_smartlead_api("GET", "leads", params={"email": email}) | |
| if not lead_response or "id" not in lead_response: | |
| return { | |
| "exists": False, | |
| "message": "Lead not found", | |
| "email": email, | |
| "campaign_id": campaign_id | |
| } | |
| lead_id = lead_response["id"] | |
| # Step 2: Get all campaigns for this lead | |
| campaigns_response = await call_smartlead_api("GET", f"leads/{lead_id}/campaigns") | |
| if not campaigns_response: | |
| return { | |
| "exists": False, | |
| "message": "No campaigns found for lead", | |
| "email": email, | |
| "campaign_id": campaign_id, | |
| "lead_id": lead_id | |
| } | |
| # Step 3: Check if the target campaign_id exists in the lead's campaigns | |
| campaign_exists = any(campaign.get("id") == campaign_id for campaign in campaigns_response) | |
| return { | |
| "exists": campaign_exists, | |
| "message": "Lead found in campaign" if campaign_exists else "Lead not found in campaign", | |
| "email": email, | |
| "campaign_id": campaign_id, | |
| "lead_id": lead_id, | |
| "total_campaigns_for_lead": len(campaigns_response) | |
| } | |
| except HTTPException as e: | |
| if e.status_code == 404: | |
| return { | |
| "exists": False, | |
| "message": "Lead not found", | |
| "email": email, | |
| "campaign_id": campaign_id | |
| } | |
| raise e | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Error checking lead in campaign: {str(e)}") | |
| async def export_data_from_campaign(campaign_id: int): | |
| """Export data from a campaign as CSV""" | |
| if SMARTLEAD_API_KEY == "your-api-key-here": | |
| raise HTTPException(status_code=400, detail="Smartlead API key not configured") | |
| url = _get_smartlead_url(f"campaigns/{campaign_id}/leads-export") | |
| params = {"api_key": SMARTLEAD_API_KEY} | |
| try: | |
| async with httpx.AsyncClient(timeout=30.0) as client: | |
| resp = await client.get(url, params=params) | |
| if resp.status_code >= 400: | |
| # Try to parse error response as JSON for better error handling | |
| try: | |
| error_data = resp.json() | |
| error_message = error_data.get('message', error_data.get('error', 'Unknown error')) | |
| error_detail = error_data.get('detail', error_data.get('description', '')) | |
| raise HTTPException( | |
| status_code=resp.status_code, | |
| detail={ | |
| "error": True, | |
| "message": error_message, | |
| "detail": error_detail, | |
| "endpoint": f"campaigns/{campaign_id}/leads-export", | |
| "method": "GET", | |
| "status_code": resp.status_code | |
| } | |
| ) | |
| except (ValueError, KeyError): | |
| # Fallback to raw text if JSON parsing fails | |
| raise HTTPException( | |
| status_code=resp.status_code, | |
| detail={ | |
| "error": True, | |
| "message": "API request failed", | |
| "detail": resp.text, | |
| "endpoint": f"campaigns/{campaign_id}/leads-export", | |
| "method": "GET", | |
| "status_code": resp.status_code | |
| } | |
| ) | |
| # Return CSV data with proper headers | |
| return Response( | |
| content=resp.text, | |
| media_type="text/csv", | |
| headers={ | |
| "Content-Disposition": f"attachment; filename=campaign_{campaign_id}_leads.csv" | |
| } | |
| ) | |
| except httpx.TimeoutException: | |
| raise HTTPException( | |
| status_code=408, | |
| detail={ | |
| "error": True, | |
| "message": "Request timeout", | |
| "detail": "The request to Smartlead API timed out", | |
| "endpoint": f"campaigns/{campaign_id}/leads-export", | |
| "method": "GET" | |
| } | |
| ) | |
| except httpx.RequestError as e: | |
| raise HTTPException( | |
| status_code=503, | |
| detail={ | |
| "error": True, | |
| "message": "Service unavailable", | |
| "detail": f"Failed to connect to Smartlead API: {str(e)}", | |
| "endpoint": f"campaigns/{campaign_id}/leads-export", | |
| "method": "GET" | |
| } | |
| ) | |
| # ============================================================================ | |
| # SEQUENCE ENDPOINTS | |
| # ============================================================================ | |
| async def get_campaign_sequences(campaign_id: int): | |
| """Fetch email sequences for a campaign""" | |
| return await call_smartlead_api("GET", f"campaigns/{campaign_id}/sequences") | |
| async def save_campaign_sequences(campaign_id: int, request: SaveSequencesRequest): | |
| """Save Campaign Sequence""" | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/sequences", data=request.dict()) | |
| async def generate_campaign_sequences(campaign_id: int, request: GenerateSequencesRequest): | |
| """Generate Campaign Sequences using LLM""" | |
| job_description = request.job_description | |
| generated_sequences = await generate_sequences_with_llm(job_description) | |
| save_request = SaveSequencesRequest(sequences=generated_sequences) | |
| result = await call_smartlead_api("POST", f"campaigns/{campaign_id}/sequences", data=save_request.dict()) | |
| return { | |
| "ok": True, | |
| "message": "Sequences generated and saved successfully", | |
| "generated_sequences": [seq.dict() for seq in generated_sequences], | |
| "save_result": result | |
| } | |
| # ============================================================================ | |
| # WEBHOOK ENDPOINTS | |
| # ============================================================================ | |
| async def fetch_webhooks_by_campaign_id(campaign_id: int): | |
| """Fetch Webhooks By Campaign ID""" | |
| return await call_smartlead_api("GET", f"campaigns/{campaign_id}/webhooks") | |
| async def add_update_campaign_webhook(campaign_id: int, request: WebhookRequest): | |
| """Add / Update Campaign Webhook""" | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/webhooks", data=request.dict()) | |
| async def delete_campaign_webhook(campaign_id: int, request: WebhookDeleteRequest): | |
| """Delete Campaign Webhook""" | |
| return await call_smartlead_api("DELETE", f"campaigns/{campaign_id}/webhooks", data=request.dict()) | |
| # ============================================================================ | |
| # CLIENT MANAGEMENT ENDPOINTS | |
| # ============================================================================ | |
| async def add_client_to_system(request: ClientRequest): | |
| """Add Client To System (Whitelabel or not)""" | |
| return await call_smartlead_api("POST", "client/save", data=request.dict()) | |
| async def fetch_all_clients(): | |
| """Fetch all clients""" | |
| return await call_smartlead_api("GET", "client") | |
| # ============================================================================ | |
| # MESSAGE HISTORY AND REPLY ENDPOINTS | |
| # ============================================================================ | |
| async def fetch_lead_message_history_based_on_campaign(campaign_id: int, lead_id: int): | |
| """Fetch Lead Message History Based On Campaign""" | |
| return await call_smartlead_api("GET", f"campaigns/{campaign_id}/leads/{lead_id}/message-history") | |
| async def reply_to_lead_from_master_inbox(campaign_id: int, request: MessageHistoryRequest): | |
| """Reply To Lead From Master Inbox via API""" | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/reply-email-thread", data=request.dict()) | |
| # ============================================================================ | |
| # EMAIL ACCOUNT ENDPOINTS | |
| # ============================================================================ | |
| async def list_email_accounts(offset: int = 0, limit: int = 100): | |
| """List all email accounts with optional pagination""" | |
| params = {"offset": offset, "limit": limit} | |
| return await call_smartlead_api("GET", "email-accounts", params=params) | |
| async def save_email_account(account: Dict[str, Any]): | |
| """Create an Email Account""" | |
| return await call_smartlead_api("POST", "email-accounts/save", data=account) | |
| async def get_email_account(account_id: int): | |
| """Fetch Email Account By ID""" | |
| return await call_smartlead_api("GET", f"email-accounts/{account_id}") | |
| async def update_email_account(account_id: int, payload: Dict[str, Any]): | |
| """Update Email Account""" | |
| return await call_smartlead_api("POST", f"email-accounts/{account_id}", data=payload) | |
| async def set_warmup(account_id: int, payload: Dict[str, Any]): | |
| """Add/Update Warmup To Email Account""" | |
| return await call_smartlead_api("POST", f"email-accounts/{account_id}/warmup", data=payload) | |
| async def get_warmup_stats(account_id: int): | |
| """Fetch Warmup Stats By Email Account ID""" | |
| return await call_smartlead_api("GET", f"email-accounts/{account_id}/warmup-stats") | |
| async def list_campaign_email_accounts(campaign_id: int): | |
| """List all email accounts per campaign""" | |
| return await call_smartlead_api("GET", f"campaigns/{campaign_id}/email-accounts") | |
| async def add_campaign_email_accounts(campaign_id: int, payload: Dict[str, Any]): | |
| """Add Email Account To A Campaign""" | |
| return await call_smartlead_api("POST", f"campaigns/{campaign_id}/email-accounts", data=payload) | |
| async def remove_campaign_email_accounts(campaign_id: int, payload: Dict[str, Any]): | |
| """Remove Email Account From A Campaign""" | |
| return await call_smartlead_api("DELETE", f"campaigns/{campaign_id}/email-accounts", data=payload) | |
| async def reconnect_failed_email_accounts(): | |
| """Reconnect failed email accounts""" | |
| return await call_smartlead_api("POST", "email-accounts/reconnect-failed-email-accounts") | |
| # ============================================================================ | |
| # UTILITY ENDPOINTS | |
| # ============================================================================ | |
| async def health_check(): | |
| """Health check endpoint to verify API connectivity""" | |
| try: | |
| campaigns = await call_smartlead_api("GET", "campaigns") | |
| return { | |
| "status": "healthy", | |
| "message": "Smartlead API is accessible", | |
| "campaigns_count": len(campaigns) if isinstance(campaigns, list) else 0, | |
| "timestamp": datetime.now().isoformat() | |
| } | |
| except Exception as e: | |
| return { | |
| "status": "unhealthy", | |
| "message": f"Smartlead API connection failed: {str(e)}", | |
| "timestamp": datetime.now().isoformat() | |
| } | |
| async def api_info(): | |
| """Get information about the API and available endpoints""" | |
| return { | |
| "name": "Smartlead API - Complete Integration", | |
| "version": "2.0.0", | |
| "description": "Comprehensive FastAPI wrapper for Smartlead email automation platform", | |
| "base_url": SMARTLEAD_BASE_URL, | |
| "available_endpoints": [ | |
| "Campaign Management", | |
| "Lead Management", | |
| "Sequence Management", | |
| "Webhook Management", | |
| "Client Management", | |
| "Message History & Reply", | |
| "Analytics", | |
| "Email Account Management" | |
| ], | |
| "documentation": "Based on Smartlead API documentation", | |
| "timestamp": datetime.now().isoformat() | |
| } | |
| # ============================================================================ | |
| # AI SEQUENCE GENERATION FUNCTIONS | |
| # ============================================================================ | |
| async def generate_sequences_with_llm(job_description: str) -> List[CampaignSequence]: | |
| """Generate email sequences using LangChain and OpenAI based on job description""" | |
| if not LANGCHAIN_AVAILABLE: | |
| return await generate_template_sequences(job_description) | |
| try: | |
| openai_api_key = os.getenv("OPENAI_API_KEY") | |
| if not openai_api_key: | |
| print("Warning: OPENAI_API_KEY not set. Using template sequences.") | |
| return await generate_template_sequences(job_description) | |
| llm = ChatOpenAI( | |
| model="gpt-4", | |
| temperature=0.7, | |
| openai_api_key=openai_api_key | |
| ) | |
| system_prompt = """You are an expert email sequence generator for recruitment campaigns. | |
| Generate ONLY the subject lines and email body content for 3 professional email sequences. | |
| Email Sequence Structure: | |
| 1. INTRODUCTION (Day 1): Ask for consent and interest in the role | |
| 2. OUTREACH (Day 3): Provide detailed job information | |
| 3. FOLLOW-UP (Day 5): Follow up on updates and next steps | |
| Requirements: | |
| - First sequence needs 2 A/B testing variants (A and B) | |
| - Second and third sequences are follow-ups (no subject line needed) | |
| - All emails should be HTML formatted with proper <br> tags | |
| - Professional but friendly tone | |
| - Include clear call-to-actions | |
| - Focus on building consent and trust | |
| Respond with ONLY a JSON object containing the email content: | |
| { | |
| "sequence1_variant_a": { | |
| "subject": "Subject line for variant A", | |
| "body": "HTML formatted email body for variant A" | |
| }, | |
| "sequence1_variant_b": { | |
| "subject": "Subject line for variant B", | |
| "body": "HTML formatted email body for variant B" | |
| }, | |
| "sequence2": { | |
| "body": "HTML formatted email body for outreach" | |
| }, | |
| "sequence3": { | |
| "body": "HTML formatted email body for follow-up" | |
| } | |
| } | |
| IMPORTANT: Respond with ONLY valid JSON. No additional text.""" | |
| prompt_template = ChatPromptTemplate.from_messages([ | |
| ("system", system_prompt), | |
| ("human", "Generate email content for this job description: {job_description}") | |
| ]) | |
| messages = prompt_template.format_messages(job_description=job_description) | |
| response = await llm.ainvoke(messages) | |
| try: | |
| content = response.content.strip() | |
| if content.startswith("```json"): | |
| content = content[7:] | |
| if content.endswith("```"): | |
| content = content[:-3] | |
| content = content.strip() | |
| parsed_data = json.loads(content) | |
| sequences = create_sequences_from_content(parsed_data, job_description) | |
| return sequences | |
| except Exception as parse_error: | |
| print(f"JSON parsing failed: {parse_error}") | |
| return await generate_template_sequences(job_description) | |
| except Exception as e: | |
| print(f"Error generating sequences with LLM: {str(e)}") | |
| return await generate_template_sequences(job_description) | |
| def create_sequences_from_content(content: dict, job_description: str) -> List[CampaignSequence]: | |
| """Create CampaignSequence objects from parsed LLM content""" | |
| sequences = [] | |
| # Sequence 1: Introduction with A/B variants | |
| if "sequence1_variant_a" in content and "sequence1_variant_b" in content: | |
| variants = [] | |
| if "sequence1_variant_a" in content: | |
| var_a = content["sequence1_variant_a"] | |
| variants.append(SeqVariant( | |
| subject=var_a.get("subject", f"Quick question about {job_description}"), | |
| email_body=var_a.get("body", ""), | |
| variant_label="A" | |
| )) | |
| if "sequence1_variant_b" in content: | |
| var_b = content["sequence1_variant_b"] | |
| variants.append(SeqVariant( | |
| subject=var_b.get("subject", f"Interested in {job_description} opportunities?"), | |
| email_body=var_b.get("body", ""), | |
| variant_label="B" | |
| )) | |
| sequences.append(CampaignSequence( | |
| seq_number=1, | |
| seq_delay_details=SeqDelayDetails(delay_in_days=1), | |
| seq_variants=variants | |
| )) | |
| # Sequence 2: Outreach | |
| if "sequence2" in content: | |
| seq2_body = content["sequence2"].get("body", "") | |
| sequences.append(CampaignSequence( | |
| seq_number=2, | |
| seq_delay_details=SeqDelayDetails(delay_in_days=3), | |
| subject="", | |
| email_body=seq2_body | |
| )) | |
| # Sequence 3: Follow-up | |
| if "sequence3" in content: | |
| seq3_body = content["sequence3"].get("body", "") | |
| sequences.append(CampaignSequence( | |
| seq_number=3, | |
| seq_delay_details=SeqDelayDetails(delay_in_days=5), | |
| subject="", | |
| email_body=seq3_body | |
| )) | |
| # Fill with templates if needed | |
| while len(sequences) < 3: | |
| if len(sequences) == 0: | |
| sequences.append(CampaignSequence( | |
| seq_number=1, | |
| seq_delay_details=SeqDelayDetails(delay_in_days=1), | |
| seq_variants=[ | |
| SeqVariant( | |
| subject=f"Quick question about {job_description}", | |
| email_body=f"""<p>Hi there,<br><br> | |
| I came across your profile and noticed your experience in {job_description}. | |
| I'm reaching out because we have some exciting opportunities that might be a great fit for your background.<br><br> | |
| Before I share more details, I wanted to ask: Are you currently open to exploring new opportunities in this space?<br><br> | |
| Would you be interested in hearing more about the roles we have available?<br><br> | |
| Best regards,<br> | |
| [Your Name]</p>""", | |
| variant_label="A" | |
| ), | |
| SeqVariant( | |
| subject=f"Interested in {job_description} opportunities?", | |
| email_body=f"""<p>Hello,<br><br> | |
| I hope this message finds you well. I'm a recruiter specializing in {job_description} positions.<br><br> | |
| I'd love to connect and share some opportunities that align with your expertise. | |
| Are you currently open to exploring new roles in this space?<br><br> | |
| If so, I can send you specific details about the positions we have available.<br><br> | |
| Thanks,<br> | |
| [Your Name]</p>""", | |
| variant_label="B" | |
| ) | |
| ] | |
| )) | |
| elif len(sequences) == 1: | |
| sequences.append(CampaignSequence( | |
| seq_number=2, | |
| seq_delay_details=SeqDelayDetails(delay_in_days=3), | |
| subject="", | |
| email_body=f"""<p>Hi,<br><br> | |
| Thanks for your interest! Here are more details about the {job_description} opportunities:<br><br> | |
| <strong>Role Details:</strong><br> | |
| • [Specific responsibilities]<br> | |
| • [Required skills and experience]<br> | |
| • [Team and company information]<br><br> | |
| <strong>Benefits:</strong><br> | |
| • [Compensation and benefits]<br> | |
| • [Growth opportunities]<br> | |
| • [Work environment]<br><br> | |
| Would you be interested in a quick call to discuss this role in more detail?<br><br> | |
| Best regards,<br> | |
| [Your Name]</p>""" | |
| )) | |
| elif len(sequences) == 2: | |
| sequences.append(CampaignSequence( | |
| seq_number=3, | |
| seq_delay_details=SeqDelayDetails(delay_in_days=5), | |
| subject="", | |
| email_body=f"""<p>Hi,<br><br> | |
| Just wanted to follow up on the {job_description} opportunity I shared.<br><br> | |
| Have you had a chance to review the information? I'd love to hear your thoughts and answer any questions.<br><br> | |
| If you're interested, I can help schedule next steps. If not, no worries at all!<br><br> | |
| Thanks for your time!<br> | |
| [Your Name]</p>""" | |
| )) | |
| return sequences | |
| async def generate_template_sequences(job_description: str) -> List[CampaignSequence]: | |
| """Generate template-based sequences as fallback""" | |
| sequences = [ | |
| CampaignSequence( | |
| seq_number=1, | |
| seq_delay_details=SeqDelayDetails(delay_in_days=1), | |
| seq_variants=[ | |
| SeqVariant( | |
| subject=f"Quick question about {job_description}", | |
| email_body=f"""<p>Hi there,<br><br> | |
| I came across your profile and noticed your experience in {job_description}. | |
| I'm reaching out because we have some exciting opportunities that might be a great fit for your background.<br><br> | |
| Before I share more details, I wanted to ask: Are you currently open to exploring new opportunities in this space?<br><br> | |
| Would you be interested in hearing more about the roles we have available?<br><br> | |
| Best regards,<br> | |
| [Your Name]</p>""", | |
| variant_label="A" | |
| ), | |
| SeqVariant( | |
| subject=f"Interested in {job_description} opportunities?", | |
| email_body=f"""<p>Hello,<br><br> | |
| I hope this message finds you well. I'm a recruiter specializing in {job_description} positions.<br><br> | |
| I'd love to connect and share some opportunities that align with your expertise. | |
| Are you currently open to exploring new roles in this space?<br><br> | |
| If so, I can send you specific details about the positions we have available.<br><br> | |
| Thanks,<br> | |
| [Your Name]</p>""", | |
| variant_label="B" | |
| ) | |
| ] | |
| ), | |
| CampaignSequence( | |
| seq_number=2, | |
| seq_delay_details=SeqDelayDetails(delay_in_days=3), | |
| subject="", | |
| email_body=f"""<p>Hi,<br><br> | |
| Thanks for your interest! Here are more details about the {job_description} opportunities:<br><br> | |
| <strong>Role Details:</strong><br> | |
| • [Specific responsibilities]<br> | |
| • [Required skills and experience]<br> | |
| • [Team and company information]<br><br> | |
| <strong>Benefits:</strong><br> | |
| • [Compensation and benefits]<br> | |
| • [Growth opportunities]<br> | |
| • [Work environment]<br><br> | |
| Would you be interested in a quick call to discuss this role in more detail?<br><br> | |
| Best regards,<br> | |
| [Your Name]</p>""" | |
| ), | |
| CampaignSequence( | |
| seq_number=3, | |
| seq_delay_details=SeqDelayDetails(delay_in_days=5), | |
| subject="", | |
| email_body=f"""<p>Hi,<br><br> | |
| Just wanted to follow up on the {job_description} opportunity I shared.<br><br> | |
| Have you had a chance to review the information? I'd love to hear your thoughts and answer any questions.<br><br> | |
| If you're interested, I can help schedule next steps. If not, no worries at all!<br><br> | |
| Thanks for your time!<br> | |
| [Your Name]</p>""" | |
| ) | |
| ] | |
| return sequences | |
| # ============================================================================ | |
| # RATE LIMITING MIDDLEWARE | |
| # ============================================================================ | |
| class RateLimiter: | |
| def __init__(self, max_requests: int = 10, window_seconds: int = 2): | |
| self.max_requests = max_requests | |
| self.window_seconds = window_seconds | |
| self.requests = [] | |
| def is_allowed(self) -> bool: | |
| now = time.time() | |
| # Remove old requests outside the window | |
| self.requests = [req_time for req_time in self.requests if now - req_time < self.window_seconds] | |
| if len(self.requests) >= self.max_requests: | |
| return False | |
| self.requests.append(now) | |
| return True | |
| # Global rate limiter instance | |
| rate_limiter = RateLimiter(max_requests=10, window_seconds=2) | |
| async def rate_limit_middleware(request: Request, call_next): | |
| """Rate limiting middleware to respect Smartlead's API limits""" | |
| if not rate_limiter.is_allowed(): | |
| return JSONResponse( | |
| status_code=429, | |
| content={ | |
| "error": "Rate limit exceeded", | |
| "message": "Too many requests. Please wait before making another request.", | |
| "retry_after": 2 | |
| } | |
| ) | |
| response = await call_next(request) | |
| return response | |
| # ============================================================================ | |
| # ERROR HANDLING | |
| # ============================================================================ | |
| async def http_exception_handler(request: Request, exc: HTTPException): | |
| """Custom HTTP exception handler""" | |
| return JSONResponse( | |
| status_code=exc.status_code, | |
| content={ | |
| "error": True, | |
| "message": exc.detail, | |
| "status_code": exc.status_code, | |
| "timestamp": datetime.now().isoformat() | |
| } | |
| ) | |
| async def general_exception_handler(request: Request, exc: Exception): | |
| """General exception handler""" | |
| return JSONResponse( | |
| status_code=500, | |
| content={ | |
| "error": True, | |
| "message": "Internal server error", | |
| "detail": str(exc) if os.getenv("DEBUG", "false").lower() == "true" else "An unexpected error occurred", | |
| "timestamp": datetime.now().isoformat() | |
| } | |
| ) | |
| # ============================================================================ | |
| # CUSTOM OPENAPI SCHEMA | |
| # ============================================================================ | |
| def custom_openapi(): | |
| if app.openapi_schema: | |
| return app.openapi_schema | |
| openapi_schema = get_openapi( | |
| title="Smartlead API - Complete Integration", | |
| version="2.0.0", | |
| description=""" | |
| # Smartlead API - Complete Integration | |
| A comprehensive FastAPI wrapper for the Smartlead email automation platform. | |
| ## Features | |
| - **Campaign Management**: Create, update, and manage email campaigns | |
| - **Lead Management**: Add, update, and manage leads across campaigns | |
| - **Sequence Management**: Create and manage email sequences with AI generation | |
| - **Webhook Management**: Set up webhooks for real-time notifications | |
| - **Analytics**: Get detailed campaign analytics and statistics | |
| - **Email Account Management**: Manage email accounts and warmup | |
| - **Client Management**: Handle client accounts and permissions | |
| ## Authentication | |
| All requests require a Smartlead API key passed as a query parameter: `?api_key=YOUR_API_KEY` | |
| ## Rate Limits | |
| - 10 requests per 2 seconds (enforced automatically) | |
| ## Base URL | |
| - Smartlead API: `https://server.smartlead.ai/api/v1` | |
| """, | |
| routes=app.routes, | |
| ) | |
| # Add custom tags | |
| openapi_schema["tags"] = [ | |
| {"name": "Campaigns", "description": "Campaign management operations"}, | |
| {"name": "Leads", "description": "Lead management operations"}, | |
| {"name": "Sequences", "description": "Email sequence management"}, | |
| {"name": "Webhooks", "description": "Webhook management"}, | |
| {"name": "Clients", "description": "Client account management"}, | |
| {"name": "Messages", "description": "Message history and reply operations"}, | |
| {"name": "Analytics", "description": "Campaign analytics and statistics"}, | |
| {"name": "Email Accounts", "description": "Email account management"}, | |
| {"name": "Utilities", "description": "Utility endpoints"} | |
| ] | |
| app.openapi_schema = openapi_schema | |
| return app.openapi_schema | |
| app.openapi = custom_openapi | |
| # ============================================================================ | |
| # MAIN APPLICATION ENTRY POINT | |
| # ============================================================================ | |
| if __name__ == "__main__": | |
| import uvicorn | |
| print("�� Starting Smartlead API - Complete Integration") | |
| print(f"�� API Documentation: http://localhost:8000/docs") | |
| print(f"📖 ReDoc Documentation: http://localhost:8000/redoc") | |
| print(f"�� Smartlead Base URL: {SMARTLEAD_BASE_URL}") | |
| print(f"⚡ Rate Limit: 10 requests per 2 seconds") | |
| uvicorn.run( | |
| "final:app", | |
| host="0.0.0.0", | |
| port=8000, | |
| reload=True, | |
| log_level="info" | |
| ) |