Skip to content

Commit 581e33d

Browse files
Sai Sridhar Tarraclaude
authored andcommitted
Feat: HubSpot + Salesforce CRM integrations (Priority 4 complete)
Backend: - services/hubspot_service.py: upsert contact + create note via Private App token - services/salesforce_service.py: OAuth ROPC auth, upsert Contact, create Task - routes/crm_integrations.py: connect/status/test/disconnect for both CRMs - ai_processor.py: Step 8 — fire CRM sync after email processing (skip spam/newsletter) - main.py: register crm_integrations router Frontend: - HubSpotIntegration + SalesforceIntegration components in settings/index.tsx - crmApi client in lib/api.ts (hubspot + salesforce namespaced methods) - Both cards shown in Settings → Integrations tab with connect/test/disconnect Migration: - migrations_v4.sql: hubspot_* + sf_* columns on user_profiles Note: Daily digest (8am UTC) and deadline reminders (9am UTC) were already fully built in digest_worker.py and email_listener.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 466af9a commit 581e33d

8 files changed

Lines changed: 777 additions & 3 deletions

File tree

backend/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from slowapi.util import get_remote_address
1616

1717
from config import settings
18-
from routes import auth, emails, actions, replies, integrations, settings as settings_routes, billing, contacts, outlook, calendar, teams, webhooks as webhooks_routes, autoassign
18+
from routes import auth, emails, actions, replies, integrations, settings as settings_routes, billing, contacts, outlook, calendar, teams, webhooks as webhooks_routes, autoassign, crm_integrations
1919
from workers.email_listener import start_email_listener, stop_email_listener
2020

2121
# ---------------------------------------------------------------------------
@@ -151,6 +151,7 @@ async def generic_exception_handler(request: Request, exc: Exception):
151151
app.include_router(teams.router, prefix="/api")
152152
app.include_router(webhooks_routes.router, prefix="/api")
153153
app.include_router(autoassign.router, prefix="/api")
154+
app.include_router(crm_integrations.router, prefix="/api")
154155

155156

156157
# ---------------------------------------------------------------------------

backend/routes/crm_integrations.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""
2+
CRM integration settings — HubSpot and Salesforce connection management.
3+
"""
4+
import logging
5+
from typing import Annotated, Optional
6+
7+
from fastapi import APIRouter, Depends, HTTPException
8+
from pydantic import BaseModel
9+
10+
from database import get_supabase
11+
from middleware.auth import get_current_user
12+
13+
logger = logging.getLogger(__name__)
14+
router = APIRouter(prefix="/integrations/crm", tags=["crm-integrations"])
15+
16+
17+
# ─── Pydantic models ──────────────────────────────────────────────────────────
18+
19+
class HubSpotSaveBody(BaseModel):
20+
api_key: str
21+
22+
23+
class SalesforceSaveBody(BaseModel):
24+
consumer_key: str
25+
consumer_secret: str
26+
username: str
27+
password: str
28+
security_token: str
29+
30+
31+
# ─── HubSpot endpoints ────────────────────────────────────────────────────────
32+
33+
@router.get("/hubspot/status")
34+
async def hubspot_status(current_user: Annotated[dict, Depends(get_current_user)]):
35+
uid = current_user["id"]
36+
supabase = get_supabase()
37+
row = supabase.table("user_profiles").select("hubspot_connected, hubspot_api_key").eq("id", uid).single().execute()
38+
data = row.data or {}
39+
return {
40+
"connected": bool(data.get("hubspot_connected")),
41+
"has_key": bool(data.get("hubspot_api_key")),
42+
}
43+
44+
45+
@router.post("/hubspot/connect")
46+
async def hubspot_connect(body: HubSpotSaveBody, current_user: Annotated[dict, Depends(get_current_user)]):
47+
"""Save HubSpot Private App token and verify it works."""
48+
from services.hubspot_service import test_connection
49+
50+
uid = current_user["id"]
51+
ok = await test_connection(body.api_key)
52+
if not ok:
53+
raise HTTPException(status_code=400, detail="Invalid HubSpot API key — connection test failed.")
54+
55+
supabase = get_supabase()
56+
supabase.table("user_profiles").update({
57+
"hubspot_api_key": body.api_key,
58+
"hubspot_connected": True,
59+
}).eq("id", uid).execute()
60+
return {"connected": True}
61+
62+
63+
@router.post("/hubspot/test")
64+
async def hubspot_test(current_user: Annotated[dict, Depends(get_current_user)]):
65+
"""Send a test ping to HubSpot."""
66+
from services.hubspot_service import test_connection
67+
68+
uid = current_user["id"]
69+
supabase = get_supabase()
70+
row = supabase.table("user_profiles").select("hubspot_api_key").eq("id", uid).single().execute()
71+
api_key = (row.data or {}).get("hubspot_api_key", "")
72+
if not api_key:
73+
raise HTTPException(status_code=400, detail="HubSpot not connected.")
74+
75+
ok = await test_connection(api_key)
76+
return {"success": ok}
77+
78+
79+
@router.delete("/hubspot/disconnect")
80+
async def hubspot_disconnect(current_user: Annotated[dict, Depends(get_current_user)]):
81+
uid = current_user["id"]
82+
supabase = get_supabase()
83+
supabase.table("user_profiles").update({
84+
"hubspot_api_key": None,
85+
"hubspot_connected": False,
86+
}).eq("id", uid).execute()
87+
return {"connected": False}
88+
89+
90+
# ─── Salesforce endpoints ─────────────────────────────────────────────────────
91+
92+
@router.get("/salesforce/status")
93+
async def salesforce_status(current_user: Annotated[dict, Depends(get_current_user)]):
94+
uid = current_user["id"]
95+
supabase = get_supabase()
96+
row = supabase.table("user_profiles").select("sf_connected, sf_username").eq("id", uid).single().execute()
97+
data = row.data or {}
98+
return {
99+
"connected": bool(data.get("sf_connected")),
100+
"username": data.get("sf_username"),
101+
}
102+
103+
104+
@router.post("/salesforce/connect")
105+
async def salesforce_connect(body: SalesforceSaveBody, current_user: Annotated[dict, Depends(get_current_user)]):
106+
"""Save Salesforce credentials and verify they work."""
107+
from services.salesforce_service import test_connection
108+
109+
uid = current_user["id"]
110+
ok = await test_connection(
111+
body.consumer_key, body.consumer_secret,
112+
body.username, body.password, body.security_token,
113+
)
114+
if not ok:
115+
raise HTTPException(status_code=400, detail="Salesforce authentication failed. Check your credentials.")
116+
117+
supabase = get_supabase()
118+
supabase.table("user_profiles").update({
119+
"sf_consumer_key": body.consumer_key,
120+
"sf_consumer_secret": body.consumer_secret,
121+
"sf_username": body.username,
122+
"sf_password": body.password,
123+
"sf_security_token": body.security_token,
124+
"sf_connected": True,
125+
}).eq("id", uid).execute()
126+
return {"connected": True}
127+
128+
129+
@router.post("/salesforce/test")
130+
async def salesforce_test(current_user: Annotated[dict, Depends(get_current_user)]):
131+
"""Test the saved Salesforce credentials."""
132+
from services.salesforce_service import test_connection
133+
134+
uid = current_user["id"]
135+
supabase = get_supabase()
136+
row = supabase.table("user_profiles").select(
137+
"sf_consumer_key, sf_consumer_secret, sf_username, sf_password, sf_security_token"
138+
).eq("id", uid).single().execute()
139+
d = row.data or {}
140+
if not d.get("sf_username"):
141+
raise HTTPException(status_code=400, detail="Salesforce not connected.")
142+
143+
ok = await test_connection(
144+
d.get("sf_consumer_key", ""), d.get("sf_consumer_secret", ""),
145+
d.get("sf_username", ""), d.get("sf_password", ""), d.get("sf_security_token", ""),
146+
)
147+
return {"success": ok}
148+
149+
150+
@router.delete("/salesforce/disconnect")
151+
async def salesforce_disconnect(current_user: Annotated[dict, Depends(get_current_user)]):
152+
uid = current_user["id"]
153+
supabase = get_supabase()
154+
supabase.table("user_profiles").update({
155+
"sf_consumer_key": None, "sf_consumer_secret": None,
156+
"sf_username": None, "sf_password": None, "sf_security_token": None,
157+
"sf_connected": False,
158+
}).eq("id", uid).execute()
159+
return {"connected": False}

backend/services/ai_processor.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,47 @@ async def process_email(email: dict) -> dict | None:
230230
)
231231

232232
# ------------------------------------------------------------------
233-
# Step 8 – Auto-assign rules
233+
# Step 8 – CRM sync (HubSpot / Salesforce)
234+
# ------------------------------------------------------------------
235+
try:
236+
crm_profile = supabase.table("user_profiles").select(
237+
"hubspot_connected, hubspot_api_key, "
238+
"sf_connected, sf_consumer_key, sf_consumer_secret, "
239+
"sf_username, sf_password, sf_security_token"
240+
).eq("id", user_id).single().execute()
241+
cp = crm_profile.data or {}
242+
243+
# Skip CRM sync for spam/newsletters
244+
email_category = analysis.get("category", "")
245+
if email_category not in ("spam", "newsletter"):
246+
if cp.get("hubspot_connected") and cp.get("hubspot_api_key"):
247+
from services.hubspot_service import sync_email_to_hubspot
248+
await sync_email_to_hubspot(
249+
api_key=cp["hubspot_api_key"],
250+
sender_email=sender,
251+
sender_name=email.get("from_name", ""),
252+
subject=subject,
253+
summary=analysis.get("summary", ""),
254+
)
255+
256+
if cp.get("sf_connected") and cp.get("sf_username"):
257+
from services.salesforce_service import sync_email_to_salesforce
258+
await sync_email_to_salesforce(
259+
consumer_key=cp.get("sf_consumer_key", ""),
260+
consumer_secret=cp.get("sf_consumer_secret", ""),
261+
username=cp.get("sf_username", ""),
262+
password=cp.get("sf_password", ""),
263+
security_token=cp.get("sf_security_token", ""),
264+
sender_email=sender,
265+
sender_name=email.get("from_name", ""),
266+
subject=subject,
267+
summary=analysis.get("summary", ""),
268+
)
269+
except Exception as exc:
270+
logger.warning("CRM sync failed for email_id=%s: %s", email_id, exc)
271+
272+
# ------------------------------------------------------------------
273+
# Step 9 – Auto-assign rules
234274
# ------------------------------------------------------------------
235275
try:
236276
_apply_auto_assign_rules(
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""
2+
HubSpot CRM integration — push contacts and email notes via Private App token.
3+
Docs: https://developers.hubspot.com/docs/api/crm/contacts
4+
"""
5+
import logging
6+
from typing import Optional
7+
8+
import httpx
9+
10+
logger = logging.getLogger(__name__)
11+
12+
HS_BASE = "https://api.hubapi.com"
13+
14+
15+
def _headers(api_key: str) -> dict:
16+
return {
17+
"Authorization": f"Bearer {api_key}",
18+
"Content-Type": "application/json",
19+
}
20+
21+
22+
async def test_connection(api_key: str) -> bool:
23+
"""Verify that the API key is valid by calling the /crm/v3/owners endpoint."""
24+
try:
25+
async with httpx.AsyncClient(timeout=10.0) as client:
26+
resp = await client.get(f"{HS_BASE}/crm/v3/owners", headers=_headers(api_key))
27+
return resp.status_code == 200
28+
except Exception as exc:
29+
logger.warning("HubSpot test_connection failed: %s", exc)
30+
return False
31+
32+
33+
async def upsert_contact(api_key: str, email: str, first_name: str = "", last_name: str = "") -> Optional[str]:
34+
"""
35+
Create or update a HubSpot contact by email. Returns the contact ID.
36+
"""
37+
try:
38+
async with httpx.AsyncClient(timeout=10.0) as client:
39+
# Search for existing contact
40+
search_resp = await client.post(
41+
f"{HS_BASE}/crm/v3/objects/contacts/search",
42+
headers=_headers(api_key),
43+
json={
44+
"filterGroups": [{"filters": [{"propertyName": "email", "operator": "EQ", "value": email}]}],
45+
"properties": ["email", "firstname", "lastname"],
46+
"limit": 1,
47+
},
48+
)
49+
if search_resp.status_code == 200:
50+
results = search_resp.json().get("results", [])
51+
if results:
52+
return results[0]["id"]
53+
54+
# Create new contact
55+
props: dict = {"email": email}
56+
if first_name:
57+
props["firstname"] = first_name
58+
if last_name:
59+
props["lastname"] = last_name
60+
61+
create_resp = await client.post(
62+
f"{HS_BASE}/crm/v3/objects/contacts",
63+
headers=_headers(api_key),
64+
json={"properties": props},
65+
)
66+
if create_resp.status_code in (200, 201):
67+
return create_resp.json()["id"]
68+
except Exception as exc:
69+
logger.warning("HubSpot upsert_contact failed: %s", exc)
70+
return None
71+
72+
73+
async def create_note(api_key: str, contact_id: str, subject: str, body: str) -> bool:
74+
"""Create a note in HubSpot and associate it with the given contact."""
75+
try:
76+
note_body = f"*{subject}*\n\n{body}"[:65000]
77+
async with httpx.AsyncClient(timeout=10.0) as client:
78+
resp = await client.post(
79+
f"{HS_BASE}/crm/v3/objects/notes",
80+
headers=_headers(api_key),
81+
json={
82+
"properties": {"hs_note_body": note_body},
83+
"associations": [
84+
{
85+
"to": {"id": contact_id},
86+
"types": [{"associationCategory": "HUBSPOT_DEFINED", "associationTypeId": 202}],
87+
}
88+
],
89+
},
90+
)
91+
return resp.status_code in (200, 201)
92+
except Exception as exc:
93+
logger.warning("HubSpot create_note failed: %s", exc)
94+
return False
95+
96+
97+
async def sync_email_to_hubspot(api_key: str, sender_email: str, sender_name: str, subject: str, summary: str) -> None:
98+
"""
99+
Full sync: upsert contact then attach a note with the email summary.
100+
"""
101+
parts = sender_name.strip().split(" ", 1)
102+
first = parts[0] if parts else ""
103+
last = parts[1] if len(parts) > 1 else ""
104+
105+
contact_id = await upsert_contact(api_key, sender_email, first, last)
106+
if contact_id:
107+
await create_note(api_key, contact_id, subject, summary or "Email received via InboxIQ.")
108+
logger.info("HubSpot: synced email from %s (contact=%s)", sender_email, contact_id)
109+
else:
110+
logger.warning("HubSpot: could not upsert contact for %s", sender_email)

0 commit comments

Comments
 (0)