Seth commited on
Commit
cdd2ae9
·
1 Parent(s): 5fa5fa1
backend/app/main.py CHANGED
@@ -18,6 +18,7 @@ import asyncio
18
  import math
19
  import re
20
  import requests
 
21
  from datetime import datetime, timedelta
22
  from calendar import monthrange
23
 
@@ -159,12 +160,53 @@ def _pick_linkedin_url(raw_data: Dict) -> str:
159
  "linkedin url",
160
  "linkedin profile",
161
  "person linkedin url",
 
162
  "linkedin profile url",
 
163
  "linkedin_url",
 
164
  ],
165
  )
166
 
167
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  def _require_unipile_config():
169
  if not UNIPILE_API_BASE or not UNIPILE_API_KEY:
170
  raise HTTPException(
@@ -181,7 +223,11 @@ def _unipile_headers():
181
  }
182
 
183
 
184
- def _unipile_request(method: str, path: str, payload: Optional[dict] = None):
 
 
 
 
185
  _require_unipile_config()
186
  url = f"{UNIPILE_API_BASE}{path}"
187
  try:
@@ -199,7 +245,12 @@ def _unipile_request(method: str, path: str, payload: Optional[dict] = None):
199
  data = resp.json()
200
  except Exception:
201
  data = {"raw": resp.text}
202
- if resp.status_code >= 400:
 
 
 
 
 
203
  msg = None
204
  if isinstance(data, dict):
205
  msg = (
@@ -211,12 +262,20 @@ def _unipile_request(method: str, path: str, payload: Optional[dict] = None):
211
  if isinstance(msg, (dict, list)):
212
  msg = json.dumps(msg)
213
  raise HTTPException(
214
- status_code=resp.status_code,
215
- detail=msg or f"UniPile error ({resp.status_code}): {resp.text}",
216
  )
217
  return data
218
 
219
 
 
 
 
 
 
 
 
 
220
  def _public_origin_from_request(request: Request) -> str:
221
  """
222
  Resolve browser-facing origin behind proxies (HF Spaces, ingress).
@@ -231,6 +290,45 @@ def _public_origin_from_request(request: Request) -> str:
231
  return str(request.base_url).rstrip("/")
232
 
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
  def _contact_value(contact: Contact, field: str):
235
  if field == "first_name":
236
  return contact.first_name or _pick_from_raw(contact.raw_data, ["first name", "firstname", "first_name"])
@@ -1023,6 +1121,8 @@ async def list_unipile_linkedin_accounts(t: TenantContext = Depends(get_tenant_c
1023
  {
1024
  "id": r.id,
1025
  "label": r.label or "LinkedIn account",
 
 
1026
  "provider": r.provider,
1027
  "unipile_account_id": r.unipile_account_id,
1028
  "status": r.status or "unknown",
@@ -1092,6 +1192,12 @@ async def unipile_linkedin_hosted_callback(request: Request, db: Session = Depen
1092
  raise HTTPException(status_code=400, detail="Missing name token")
1093
  if not unipile_account_id:
1094
  return {"ok": True, "ignored": True, "reason": "missing_account_id"}
 
 
 
 
 
 
1095
 
1096
  state = (
1097
  db.query(UnipileHostedAuthState)
@@ -1122,7 +1228,7 @@ async def unipile_linkedin_hosted_callback(request: Request, db: Session = Depen
1122
  existing.label = state.label or existing.label
1123
  existing.status = status or existing.status
1124
  existing.auth_mode = "hosted"
1125
- existing.metadata_json = body
1126
  existing.updated_at = datetime.utcnow()
1127
  else:
1128
  db.add(
@@ -1134,7 +1240,7 @@ async def unipile_linkedin_hosted_callback(request: Request, db: Session = Depen
1134
  unipile_account_id=unipile_account_id,
1135
  status=status or "connected",
1136
  auth_mode="hosted",
1137
- metadata_json=body,
1138
  )
1139
  )
1140
  state.used_at = datetime.utcnow()
@@ -1173,6 +1279,10 @@ async def connect_unipile_linkedin(
1173
  account_id = _safe_str((response or {}).get("account", {}).get("id") if isinstance(response, dict) else "")
1174
  if not account_id:
1175
  raise HTTPException(status_code=400, detail="UniPile did not return an account_id")
 
 
 
 
1176
 
1177
  db_row = UnipileAccount(
1178
  tenant_id=t.tenant_id,
@@ -1182,7 +1292,7 @@ async def connect_unipile_linkedin(
1182
  unipile_account_id=account_id,
1183
  status=_safe_str(response.get("status") if isinstance(response, dict) else "") or "connected",
1184
  auth_mode=body.auth_mode,
1185
- metadata_json=response if isinstance(response, dict) else {"raw": str(response)},
1186
  )
1187
  t.db.add(db_row)
1188
  t.db.commit()
@@ -1227,6 +1337,7 @@ async def list_linkedin_campaigns(t: TenantContext = Depends(get_tenant_context)
1227
  "unipile_account_label": a.label if a else "",
1228
  "created_at": c.created_at.isoformat() if c.created_at else None,
1229
  "executed_at": c.executed_at.isoformat() if c.executed_at else None,
 
1230
  }
1231
  for c, a in rows
1232
  ]
@@ -1315,9 +1426,13 @@ async def upload_linkedin_campaign_csv(
1315
  GeneratedSequence.product == campaign.name,
1316
  ).delete()
1317
 
 
1318
  for idx, row in df.iterrows():
1319
  row_dict = row.to_dict()
1320
  sanitized_raw_data = {str(k): _json_safe(v) for k, v in row_dict.items()}
 
 
 
1321
  contact = Contact(
1322
  tenant_id=t.tenant_id,
1323
  file_id=file_id,
@@ -1337,7 +1452,12 @@ async def upload_linkedin_campaign_csv(
1337
  campaign.status = "draft"
1338
  campaign.updated_at = datetime.utcnow()
1339
  db.commit()
1340
- return {"file_id": file_id, "contact_count": contact_count, "message": "Campaign CSV uploaded"}
 
 
 
 
 
1341
 
1342
 
1343
  @app.post("/api/linkedin-campaigns/{campaign_id}/generate")
@@ -1462,8 +1582,14 @@ async def execute_linkedin_campaign(
1462
  t: TenantContext = Depends(get_tenant_context),
1463
  ):
1464
  """
1465
- Execute generated LinkedIn campaign via UniPile.
1466
- This sends the first generated message per contact using UniPile chat-message route.
 
 
 
 
 
 
1467
  """
1468
  db = t.db
1469
  campaign = (
@@ -1512,87 +1638,236 @@ async def execute_linkedin_campaign(
1512
  .order_by(GeneratedSequence.sequence_id.asc())
1513
  .all()
1514
  )
 
 
 
 
 
1515
  msg_by_seq = {r.sequence_id: r for r in seq_rows}
1516
 
1517
- sent = 0
 
 
1518
  skipped = 0
1519
  failed = 0
1520
  errors = []
1521
  attempts = []
1522
 
 
 
 
 
 
 
 
1523
  for idx, c in enumerate(contacts, start=1):
1524
  first_msg = msg_by_seq.get(idx)
 
1525
  if not first_msg:
1526
  skipped += 1
 
 
 
 
 
 
 
 
1527
  continue
1528
- linkedin_url = _pick_linkedin_url(c.raw_data or {})
1529
- if not linkedin_url:
 
 
 
 
1530
  skipped += 1
1531
  errors.append(
1532
  {
1533
  "contact_id": c.id,
1534
  "reason": "missing_linkedin_url",
1535
- "message": "No LinkedIn profile URL column found for this contact.",
 
 
 
 
 
 
 
 
 
1536
  }
1537
  )
1538
  continue
 
 
 
1539
  if body.dry_run:
1540
- sent += 1
1541
- attempts.append({"contact_id": c.id, "linkedin_url": linkedin_url, "status": "dry_run"})
 
 
 
 
 
 
 
 
 
 
1542
  continue
1543
- try:
1544
- # Best-effort execution path:
1545
- # 1) resolve profile from identifier
1546
- profile = _unipile_request(
1547
- "GET",
1548
- f"/api/v1/users/{requests.utils.quote(linkedin_url, safe='')}",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1549
  )
1550
- attendee_id = _safe_str(
1551
- profile.get("id") if isinstance(profile, dict) else ""
1552
- ) or _safe_str(profile.get("provider_id") if isinstance(profile, dict) else "")
1553
- if not attendee_id:
1554
- raise ValueError("UniPile profile id not found")
 
 
 
 
 
 
 
 
 
 
 
 
 
1555
 
1556
- # 2) start or reuse 1:1 chat
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1557
  chat = _unipile_request(
1558
  "POST",
1559
  "/api/v1/chats",
1560
- {
1561
- "account_id": account.unipile_account_id,
1562
- "attendees": [attendee_id],
1563
- },
1564
  )
1565
  chat_id = _safe_str(chat.get("id") if isinstance(chat, dict) else "")
1566
  if not chat_id:
1567
  raise ValueError("UniPile chat id not found")
1568
-
1569
- # 3) send first generated LinkedIn message
1570
  _unipile_request(
1571
  "POST",
1572
- f"/api/v1/chats/{chat_id}/messages",
 
 
 
 
1573
  {
1574
- "text": first_msg.email_content or "",
1575
- },
 
 
 
 
 
 
 
 
 
1576
  )
1577
- sent += 1
1578
- attempts.append({"contact_id": c.id, "linkedin_url": linkedin_url, "status": "sent"})
1579
  except Exception as e:
1580
  failed += 1
1581
  errors.append(
1582
  {
1583
  "contact_id": c.id,
1584
- "reason": "send_failed",
1585
- "message": str(e),
 
 
 
 
 
 
 
 
 
 
 
 
1586
  }
1587
  )
1588
 
 
1589
  result = {
1590
- "sent": sent,
 
 
 
 
1591
  "skipped": skipped,
1592
  "failed": failed,
1593
- "errors": errors[:100],
1594
- "attempts": attempts[:100],
1595
- "dry_run": body.dry_run,
 
1596
  }
1597
  campaign.status = "completed" if failed == 0 else "failed"
1598
  campaign.execution_result = result
 
18
  import math
19
  import re
20
  import requests
21
+ from urllib.parse import urlparse, unquote
22
  from datetime import datetime, timedelta
23
  from calendar import monthrange
24
 
 
160
  "linkedin url",
161
  "linkedin profile",
162
  "person linkedin url",
163
+ "person linkedin profile url",
164
  "linkedin profile url",
165
+ "linkedin public url",
166
  "linkedin_url",
167
+ "linkedin profile link",
168
  ],
169
  )
170
 
171
 
172
+ def _normalize_linkedin_url(raw: str) -> str:
173
+ """Strip whitespace; upgrade http→https for stable display and parsing."""
174
+ s = _safe_str(raw)
175
+ if not s:
176
+ return ""
177
+ if s.startswith("http://"):
178
+ s = "https://" + s[len("http://") :]
179
+ return s
180
+
181
+
182
+ def _linkedin_public_identifier(raw: str) -> str:
183
+ """
184
+ UniPile profile lookup expects the public slug from /in/{slug}, not a full URL.
185
+ Accepts plain slugs (e.g. satyanadella) as well.
186
+ """
187
+ s = _normalize_linkedin_url(raw).strip()
188
+ if not s:
189
+ return ""
190
+ low = s.lower()
191
+ if "linkedin.com" in low:
192
+ try:
193
+ path = urlparse(s).path or ""
194
+ except Exception:
195
+ path = ""
196
+ path = unquote(path).strip("/")
197
+ parts = [p for p in path.split("/") if p]
198
+ for i, seg in enumerate(parts):
199
+ if seg.lower() == "in" and i + 1 < len(parts):
200
+ slug = parts[i + 1].strip()
201
+ return slug.split("?")[0].strip()
202
+ if seg.lower() == "sales" and i + 2 < len(parts) and parts[i + 1].lower() == "lead":
203
+ # /sales/lead/{slug}
204
+ return parts[i + 2].split("?")[0].strip()
205
+ return ""
206
+ # Already a slug / identifier (no URL)
207
+ return s.split("?")[0].strip("/").strip()
208
+
209
+
210
  def _require_unipile_config():
211
  if not UNIPILE_API_BASE or not UNIPILE_API_KEY:
212
  raise HTTPException(
 
223
  }
224
 
225
 
226
+ def _unipile_call(method: str, path: str, payload: Optional[dict] = None):
227
+ """
228
+ Raw UniPile HTTP call. Returns (status_code, parsed_body).
229
+ Raises HTTPException only on transport failure (not on 4xx/5xx responses).
230
+ """
231
  _require_unipile_config()
232
  url = f"{UNIPILE_API_BASE}{path}"
233
  try:
 
245
  data = resp.json()
246
  except Exception:
247
  data = {"raw": resp.text}
248
+ return resp.status_code, data
249
+
250
+
251
+ def _unipile_request(method: str, path: str, payload: Optional[dict] = None):
252
+ status, data = _unipile_call(method, path, payload)
253
+ if status >= 400:
254
  msg = None
255
  if isinstance(data, dict):
256
  msg = (
 
262
  if isinstance(msg, (dict, list)):
263
  msg = json.dumps(msg)
264
  raise HTTPException(
265
+ status_code=status,
266
+ detail=msg or f"UniPile error ({status}): {data if not isinstance(data, dict) else json.dumps(data)}",
267
  )
268
  return data
269
 
270
 
271
+ def _linkedin_invite_note(text: str) -> str:
272
+ """LinkedIn personalized invite notes are short; keep a safe upper bound."""
273
+ t = _safe_str(text).replace("\r\n", "\n").strip()
274
+ if len(t) <= 280:
275
+ return t
276
+ return t[:277].rstrip() + "..."
277
+
278
+
279
  def _public_origin_from_request(request: Request) -> str:
280
  """
281
  Resolve browser-facing origin behind proxies (HF Spaces, ingress).
 
290
  return str(request.base_url).rstrip("/")
291
 
292
 
293
+ def _extract_unipile_identity(data: Optional[dict]) -> tuple[str, str]:
294
+ """
295
+ Best-effort extraction of profile display name and avatar URL from UniPile account payloads.
296
+ """
297
+ if not isinstance(data, dict):
298
+ return "", ""
299
+ candidates_name = [
300
+ data.get("name"),
301
+ data.get("display_name"),
302
+ data.get("full_name"),
303
+ (data.get("user") or {}).get("name") if isinstance(data.get("user"), dict) else None,
304
+ (data.get("profile") or {}).get("name") if isinstance(data.get("profile"), dict) else None,
305
+ (data.get("account") or {}).get("name") if isinstance(data.get("account"), dict) else None,
306
+ ]
307
+ candidates_avatar = [
308
+ data.get("picture"),
309
+ data.get("avatar"),
310
+ data.get("photo_url"),
311
+ data.get("image_url"),
312
+ (data.get("user") or {}).get("picture") if isinstance(data.get("user"), dict) else None,
313
+ (data.get("profile") or {}).get("picture") if isinstance(data.get("profile"), dict) else None,
314
+ (data.get("profile") or {}).get("avatar") if isinstance(data.get("profile"), dict) else None,
315
+ ]
316
+ name = next((_safe_str(x) for x in candidates_name if _safe_str(x)), "")
317
+ avatar = next((_safe_str(x) for x in candidates_avatar if _safe_str(x)), "")
318
+ return name, avatar
319
+
320
+
321
+ def _try_fetch_unipile_account_profile(account_id: str) -> dict:
322
+ """
323
+ Optional enrichment call. If it fails, we just keep existing metadata.
324
+ """
325
+ try:
326
+ out = _unipile_request("GET", f"/api/v1/accounts/{requests.utils.quote(account_id, safe='')}")
327
+ return out if isinstance(out, dict) else {}
328
+ except Exception:
329
+ return {}
330
+
331
+
332
  def _contact_value(contact: Contact, field: str):
333
  if field == "first_name":
334
  return contact.first_name or _pick_from_raw(contact.raw_data, ["first name", "firstname", "first_name"])
 
1121
  {
1122
  "id": r.id,
1123
  "label": r.label or "LinkedIn account",
1124
+ "display_name": _extract_unipile_identity(r.metadata_json or {})[0] or (r.label or "LinkedIn account"),
1125
+ "avatar_url": _extract_unipile_identity(r.metadata_json or {})[1] or "",
1126
  "provider": r.provider,
1127
  "unipile_account_id": r.unipile_account_id,
1128
  "status": r.status or "unknown",
 
1192
  raise HTTPException(status_code=400, detail="Missing name token")
1193
  if not unipile_account_id:
1194
  return {"ok": True, "ignored": True, "reason": "missing_account_id"}
1195
+ account_profile = _try_fetch_unipile_account_profile(unipile_account_id)
1196
+ merged_meta = {}
1197
+ if isinstance(body, dict):
1198
+ merged_meta.update(body)
1199
+ if isinstance(account_profile, dict) and account_profile:
1200
+ merged_meta["account_profile"] = account_profile
1201
 
1202
  state = (
1203
  db.query(UnipileHostedAuthState)
 
1228
  existing.label = state.label or existing.label
1229
  existing.status = status or existing.status
1230
  existing.auth_mode = "hosted"
1231
+ existing.metadata_json = merged_meta or body
1232
  existing.updated_at = datetime.utcnow()
1233
  else:
1234
  db.add(
 
1240
  unipile_account_id=unipile_account_id,
1241
  status=status or "connected",
1242
  auth_mode="hosted",
1243
+ metadata_json=merged_meta or body,
1244
  )
1245
  )
1246
  state.used_at = datetime.utcnow()
 
1279
  account_id = _safe_str((response or {}).get("account", {}).get("id") if isinstance(response, dict) else "")
1280
  if not account_id:
1281
  raise HTTPException(status_code=400, detail="UniPile did not return an account_id")
1282
+ account_profile = _try_fetch_unipile_account_profile(account_id)
1283
+ merged_meta = response if isinstance(response, dict) else {"raw": str(response)}
1284
+ if isinstance(merged_meta, dict) and account_profile:
1285
+ merged_meta["account_profile"] = account_profile
1286
 
1287
  db_row = UnipileAccount(
1288
  tenant_id=t.tenant_id,
 
1292
  unipile_account_id=account_id,
1293
  status=_safe_str(response.get("status") if isinstance(response, dict) else "") or "connected",
1294
  auth_mode=body.auth_mode,
1295
+ metadata_json=merged_meta,
1296
  )
1297
  t.db.add(db_row)
1298
  t.db.commit()
 
1337
  "unipile_account_label": a.label if a else "",
1338
  "created_at": c.created_at.isoformat() if c.created_at else None,
1339
  "executed_at": c.executed_at.isoformat() if c.executed_at else None,
1340
+ "execution_result": c.execution_result,
1341
  }
1342
  for c, a in rows
1343
  ]
 
1426
  GeneratedSequence.product == campaign.name,
1427
  ).delete()
1428
 
1429
+ linkedin_profiles_detected = 0
1430
  for idx, row in df.iterrows():
1431
  row_dict = row.to_dict()
1432
  sanitized_raw_data = {str(k): _json_safe(v) for k, v in row_dict.items()}
1433
+ li_raw = _pick_linkedin_url(sanitized_raw_data)
1434
+ if _linkedin_public_identifier(li_raw):
1435
+ linkedin_profiles_detected += 1
1436
  contact = Contact(
1437
  tenant_id=t.tenant_id,
1438
  file_id=file_id,
 
1452
  campaign.status = "draft"
1453
  campaign.updated_at = datetime.utcnow()
1454
  db.commit()
1455
+ return {
1456
+ "file_id": file_id,
1457
+ "contact_count": contact_count,
1458
+ "linkedin_profiles_detected": linkedin_profiles_detected,
1459
+ "message": "Campaign CSV uploaded",
1460
+ }
1461
 
1462
 
1463
  @app.post("/api/linkedin-campaigns/{campaign_id}/generate")
 
1582
  t: TenantContext = Depends(get_tenant_context),
1583
  ):
1584
  """
1585
+ Execute LinkedIn campaign via UniPile.
1586
+
1587
+ Sends a connection request using POST /users/invite with the first generated step as the
1588
+ invite note (visible under LinkedIn → My Network → Manage invitations → Sent).
1589
+
1590
+ UniPile expects the public /in/{{slug}} identifier plus account_id on profile lookup — not a raw URL path.
1591
+
1592
+ If the invite cannot be sent (e.g. already connected), falls back to a 1:1 LinkedIn DM via chats API.
1593
  """
1594
  db = t.db
1595
  campaign = (
 
1638
  .order_by(GeneratedSequence.sequence_id.asc())
1639
  .all()
1640
  )
1641
+ if not seq_rows:
1642
+ raise HTTPException(
1643
+ status_code=400,
1644
+ detail="Generate LinkedIn sequences before executing the campaign.",
1645
+ )
1646
  msg_by_seq = {r.sequence_id: r for r in seq_rows}
1647
 
1648
+ invite_sent = 0
1649
+ dm_sent = 0
1650
+ dry_preview = 0
1651
  skipped = 0
1652
  failed = 0
1653
  errors = []
1654
  attempts = []
1655
 
1656
+ acc_uid = account.unipile_account_id
1657
+
1658
+ def _contact_label(cr: Contact) -> str:
1659
+ parts = [_safe_str(cr.first_name), _safe_str(cr.last_name)]
1660
+ lab = " ".join(p for p in parts if p).strip()
1661
+ return lab or "Contact"
1662
+
1663
  for idx, c in enumerate(contacts, start=1):
1664
  first_msg = msg_by_seq.get(idx)
1665
+ name_label = _contact_label(c)
1666
  if not first_msg:
1667
  skipped += 1
1668
+ attempts.append(
1669
+ {
1670
+ "contact_id": c.id,
1671
+ "name": name_label,
1672
+ "status": "skipped",
1673
+ "reason": "no_generated_message",
1674
+ }
1675
+ )
1676
  continue
1677
+
1678
+ raw_li = _pick_linkedin_url(c.raw_data or {})
1679
+ url_disp = _normalize_linkedin_url(raw_li) or _safe_str(raw_li)
1680
+ slug = _linkedin_public_identifier(raw_li)
1681
+
1682
+ if not slug:
1683
  skipped += 1
1684
  errors.append(
1685
  {
1686
  "contact_id": c.id,
1687
  "reason": "missing_linkedin_url",
1688
+ "message": "No usable LinkedIn profile URL (expected Apollo-style Person Linkedin Url or /in/{{slug}}).",
1689
+ }
1690
+ )
1691
+ attempts.append(
1692
+ {
1693
+ "contact_id": c.id,
1694
+ "name": name_label,
1695
+ "linkedin_url": url_disp,
1696
+ "status": "skipped",
1697
+ "reason": "missing_or_invalid_linkedin_url",
1698
  }
1699
  )
1700
  continue
1701
+
1702
+ note = _linkedin_invite_note(first_msg.email_content or "")
1703
+
1704
  if body.dry_run:
1705
+ dry_preview += 1
1706
+ attempts.append(
1707
+ {
1708
+ "contact_id": c.id,
1709
+ "name": name_label,
1710
+ "linkedin_url": url_disp,
1711
+ "public_identifier": slug,
1712
+ "status": "dry_run",
1713
+ "detail": "Dry run: no UniPile calls. Uncheck the Dry run checkbox to send connection invites.",
1714
+ "invite_note_preview": note[:180],
1715
+ }
1716
+ )
1717
  continue
1718
+
1719
+ slug_enc = requests.utils.quote(slug, safe="")
1720
+ acc_enc = requests.utils.quote(acc_uid, safe="")
1721
+ st_prof, profile = _unipile_call("GET", f"/api/v1/users/{slug_enc}?account_id={acc_enc}")
1722
+
1723
+ if st_prof >= 400 or not isinstance(profile, dict):
1724
+ failed += 1
1725
+ detail = profile if isinstance(profile, dict) else {}
1726
+ msg = (
1727
+ detail.get("message")
1728
+ or detail.get("detail")
1729
+ or detail.get("error")
1730
+ or (json.dumps(detail)[:800] if detail else "profile lookup failed")
1731
+ )
1732
+ if isinstance(msg, (dict, list)):
1733
+ msg = json.dumps(msg)
1734
+ errors.append({"contact_id": c.id, "reason": "profile_lookup_failed", "message": str(msg)})
1735
+ attempts.append(
1736
+ {
1737
+ "contact_id": c.id,
1738
+ "name": name_label,
1739
+ "linkedin_url": url_disp,
1740
+ "public_identifier": slug,
1741
+ "status": "failed",
1742
+ "step": "resolve_profile",
1743
+ "detail": str(msg)[:500],
1744
+ }
1745
+ )
1746
+ continue
1747
+
1748
+ provider_id = _safe_str(profile.get("provider_id"))
1749
+ rel_hint = _safe_str(
1750
+ profile.get("network_distance") or profile.get("distance") or ""
1751
+ )
1752
+ if not rel_hint and isinstance(profile.get("relation"), dict):
1753
+ rel_hint = _safe_str((profile.get("relation") or {}).get("distance"))
1754
+
1755
+ if not provider_id:
1756
+ failed += 1
1757
+ errors.append(
1758
+ {
1759
+ "contact_id": c.id,
1760
+ "reason": "missing_provider_id",
1761
+ "message": "UniPile profile response had no provider_id.",
1762
+ }
1763
  )
1764
+ attempts.append(
1765
+ {
1766
+ "contact_id": c.id,
1767
+ "name": name_label,
1768
+ "linkedin_url": url_disp,
1769
+ "public_identifier": slug,
1770
+ "status": "failed",
1771
+ "step": "resolve_profile",
1772
+ "detail": "provider_id missing from UniPile profile payload",
1773
+ }
1774
+ )
1775
+ continue
1776
+
1777
+ st_inv, inv_res = _unipile_call(
1778
+ "POST",
1779
+ "/api/v1/users/invite",
1780
+ {"provider_id": provider_id, "account_id": acc_uid, "message": note},
1781
+ )
1782
 
1783
+ if st_inv < 400:
1784
+ invite_sent += 1
1785
+ attempts.append(
1786
+ {
1787
+ "contact_id": c.id,
1788
+ "name": name_label,
1789
+ "linkedin_url": url_disp,
1790
+ "public_identifier": slug,
1791
+ "provider_id": provider_id,
1792
+ "status": "invite_sent",
1793
+ "network_distance": rel_hint or None,
1794
+ "connection_hint": rel_hint or None,
1795
+ }
1796
+ )
1797
+ continue
1798
+
1799
+ inv_err = ""
1800
+ if isinstance(inv_res, dict):
1801
+ inv_err = _safe_str(
1802
+ inv_res.get("message") or inv_res.get("detail") or inv_res.get("error") or ""
1803
+ )
1804
+ if isinstance(inv_res.get("errors"), list):
1805
+ inv_err = inv_err or json.dumps(inv_res.get("errors"))[:400]
1806
+
1807
+ try:
1808
  chat = _unipile_request(
1809
  "POST",
1810
  "/api/v1/chats",
1811
+ {"account_id": acc_uid, "attendees": [provider_id]},
 
 
 
1812
  )
1813
  chat_id = _safe_str(chat.get("id") if isinstance(chat, dict) else "")
1814
  if not chat_id:
1815
  raise ValueError("UniPile chat id not found")
 
 
1816
  _unipile_request(
1817
  "POST",
1818
+ f"/api/v1/chats/{requests.utils.quote(chat_id, safe='')}/messages",
1819
+ {"text": first_msg.email_content or ""},
1820
+ )
1821
+ dm_sent += 1
1822
+ attempts.append(
1823
  {
1824
+ "contact_id": c.id,
1825
+ "name": name_label,
1826
+ "linkedin_url": url_disp,
1827
+ "public_identifier": slug,
1828
+ "provider_id": provider_id,
1829
+ "status": "message_sent",
1830
+ "network_distance": rel_hint or None,
1831
+ "connection_hint": rel_hint or None,
1832
+ "fallback_from_invite_error": inv_err or None,
1833
+ "detail": "Connection invite was not sent (see fallback_from_invite_error); sent LinkedIn DM instead.",
1834
+ }
1835
  )
 
 
1836
  except Exception as e:
1837
  failed += 1
1838
  errors.append(
1839
  {
1840
  "contact_id": c.id,
1841
+ "reason": "invite_and_dm_failed",
1842
+ "message": f"invite: {inv_err}; dm: {str(e)}",
1843
+ }
1844
+ )
1845
+ attempts.append(
1846
+ {
1847
+ "contact_id": c.id,
1848
+ "name": name_label,
1849
+ "linkedin_url": url_disp,
1850
+ "public_identifier": slug,
1851
+ "status": "failed",
1852
+ "step": "invite_and_message",
1853
+ "invite_error": inv_err or None,
1854
+ "detail": str(e),
1855
  }
1856
  )
1857
 
1858
+ total_ok = dry_preview if body.dry_run else (invite_sent + dm_sent)
1859
  result = {
1860
+ "dry_run": body.dry_run,
1861
+ "dry_run_preview_count": dry_preview,
1862
+ "linkedin_invites_sent": invite_sent,
1863
+ "direct_messages_sent": dm_sent,
1864
+ "sent": total_ok,
1865
  "skipped": skipped,
1866
  "failed": failed,
1867
+ "errors": errors[:200],
1868
+ "attempts": attempts[:500],
1869
+ "execution_kind": "linkedin_connection_invite",
1870
+ "help": "Invites appear under LinkedIn → My Network → Manage invitations → Sent. Dry run performs zero UniPile calls.",
1871
  }
1872
  campaign.status = "completed" if failed == 0 else "failed"
1873
  campaign.execution_result = result
frontend/src/components/campaigns/LinkedinCampaignsTab.jsx CHANGED
@@ -21,12 +21,17 @@ export default function LinkedinCampaignsTab() {
21
  const [promptTemplate, setPromptTemplate] = useState(
22
  'Write a concise LinkedIn message sequence (3 steps) personalized using first name, company, and title. Keep each message under 500 characters, human and non-spammy.'
23
  );
24
- const [dryRun, setDryRun] = useState(true);
 
25
 
26
  const selectedCampaign = useMemo(
27
  () => campaigns.find((c) => String(c.id) === String(selectedCampaignId)) || null,
28
  [campaigns, selectedCampaignId]
29
  );
 
 
 
 
30
 
31
  const refreshAll = async () => {
32
  const [accRes, cmpRes] = await Promise.all([
@@ -113,7 +118,12 @@ export default function LinkedinCampaignsTab() {
113
  const data = await res.json().catch(() => ({}));
114
  if (!res.ok) throw new Error(data.detail || 'CSV upload failed');
115
  await refreshAll();
116
- alert(`Uploaded ${data.contact_count || 0} contacts.`);
 
 
 
 
 
117
  } catch (e) {
118
  alert(e.message || 'CSV upload failed');
119
  } finally {
@@ -156,7 +166,15 @@ export default function LinkedinCampaignsTab() {
156
  const data = await res.json().catch(() => ({}));
157
  if (!res.ok) throw new Error(data.detail || 'Execution failed');
158
  await refreshAll();
159
- alert(`Execution finished: sent=${data.sent}, skipped=${data.skipped}, failed=${data.failed}`);
 
 
 
 
 
 
 
 
160
  } catch (e) {
161
  alert(e.message || 'Execution failed');
162
  } finally {
@@ -193,6 +211,38 @@ export default function LinkedinCampaignsTab() {
193
  <div className="mt-3 text-sm text-slate-600">
194
  Connected accounts: {accounts.length}
195
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  </div>
197
 
198
  <div className="rounded-2xl border border-slate-200 bg-white p-5">
@@ -213,7 +263,7 @@ export default function LinkedinCampaignsTab() {
213
  <option value="">Select LinkedIn account</option>
214
  {accounts.map((a) => (
215
  <option key={a.id} value={a.id}>
216
- {a.label} ({a.status || 'unknown'})
217
  </option>
218
  ))}
219
  </select>
@@ -222,6 +272,11 @@ export default function LinkedinCampaignsTab() {
222
  Create New
223
  </Button>
224
  </div>
 
 
 
 
 
225
  <div className="mt-4">
226
  <select
227
  className="h-10 w-full rounded-md border border-slate-200 px-3 text-sm"
@@ -296,18 +351,95 @@ export default function LinkedinCampaignsTab() {
296
 
297
  <div className="rounded-2xl border border-slate-200 bg-white p-5">
298
  <h3 className="mb-3 text-lg font-semibold text-slate-800">5) Execute Campaign via UniPile API</h3>
299
- <label className="mb-3 flex items-center gap-2 text-sm text-slate-700">
300
- <input
301
- type="checkbox"
302
- checked={dryRun}
303
- onChange={(e) => setDryRun(e.target.checked)}
304
- />
305
- Dry run (recommended first)
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  </label>
307
  <Button onClick={executeCampaign} disabled={!selectedCampaignId || busy}>
308
  <Play className="mr-2 h-4 w-4" />
309
  Execute Campaign
310
  </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  </div>
312
  </div>
313
  );
 
21
  const [promptTemplate, setPromptTemplate] = useState(
22
  'Write a concise LinkedIn message sequence (3 steps) personalized using first name, company, and title. Keep each message under 500 characters, human and non-spammy.'
23
  );
24
+ /** When true, the backend never calls UniPile — use once to validate rows, then turn off to send invites. */
25
+ const [dryRun, setDryRun] = useState(false);
26
 
27
  const selectedCampaign = useMemo(
28
  () => campaigns.find((c) => String(c.id) === String(selectedCampaignId)) || null,
29
  [campaigns, selectedCampaignId]
30
  );
31
+ const selectedAccount = useMemo(
32
+ () => accounts.find((a) => String(a.id) === String(createForm.unipile_account_ref_id)) || null,
33
+ [accounts, createForm.unipile_account_ref_id]
34
+ );
35
 
36
  const refreshAll = async () => {
37
  const [accRes, cmpRes] = await Promise.all([
 
118
  const data = await res.json().catch(() => ({}));
119
  if (!res.ok) throw new Error(data.detail || 'CSV upload failed');
120
  await refreshAll();
121
+ const li = data.linkedin_profiles_detected;
122
+ alert(
123
+ `Uploaded ${data.contact_count || 0} rows.${
124
+ li != null ? ` LinkedIn profile URLs detected on ${li} rows (Apollo “Person Linkedin Url”).` : ''
125
+ }`
126
+ );
127
  } catch (e) {
128
  alert(e.message || 'CSV upload failed');
129
  } finally {
 
166
  const data = await res.json().catch(() => ({}));
167
  if (!res.ok) throw new Error(data.detail || 'Execution failed');
168
  await refreshAll();
169
+ if (data.dry_run) {
170
+ alert(
171
+ `Dry run only — nothing was sent to LinkedIn or UniPile.\nContacts previewed: ${data.dry_run_preview_count ?? data.sent ?? 0}\n\nUncheck “Dry run” and execute again to send connection invites.`
172
+ );
173
+ } else {
174
+ alert(
175
+ `Done.\nConnection invites sent: ${data.linkedin_invites_sent ?? 0}\nDirect messages (fallback): ${data.direct_messages_sent ?? 0}\nSkipped: ${data.skipped}\nFailed: ${data.failed}`
176
+ );
177
+ }
178
  } catch (e) {
179
  alert(e.message || 'Execution failed');
180
  } finally {
 
211
  <div className="mt-3 text-sm text-slate-600">
212
  Connected accounts: {accounts.length}
213
  </div>
214
+ {accounts.length > 0 ? (
215
+ <div className="mt-3 flex flex-wrap gap-2">
216
+ {accounts.map((a) => {
217
+ const n = a.display_name || a.label || 'LinkedIn account';
218
+ const initials = n
219
+ .split(/\s+/)
220
+ .slice(0, 2)
221
+ .map((s) => s[0] || '')
222
+ .join('')
223
+ .toUpperCase();
224
+ return (
225
+ <div
226
+ key={a.id}
227
+ className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs text-slate-700"
228
+ >
229
+ {a.avatar_url ? (
230
+ <img
231
+ src={a.avatar_url}
232
+ alt={n}
233
+ className="h-5 w-5 rounded-full object-cover"
234
+ />
235
+ ) : (
236
+ <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-violet-100 text-[10px] font-semibold text-violet-700">
237
+ {initials || 'LI'}
238
+ </span>
239
+ )}
240
+ <span className="max-w-[210px] truncate">{n}</span>
241
+ </div>
242
+ );
243
+ })}
244
+ </div>
245
+ ) : null}
246
  </div>
247
 
248
  <div className="rounded-2xl border border-slate-200 bg-white p-5">
 
263
  <option value="">Select LinkedIn account</option>
264
  {accounts.map((a) => (
265
  <option key={a.id} value={a.id}>
266
+ {(a.display_name || a.label || 'LinkedIn account')} ({a.status || 'unknown'})
267
  </option>
268
  ))}
269
  </select>
 
272
  Create New
273
  </Button>
274
  </div>
275
+ {selectedAccount ? (
276
+ <div className="mt-3 rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
277
+ Using account: <strong>{selectedAccount.display_name || selectedAccount.label}</strong>
278
+ </div>
279
+ ) : null}
280
  <div className="mt-4">
281
  <select
282
  className="h-10 w-full rounded-md border border-slate-200 px-3 text-sm"
 
351
 
352
  <div className="rounded-2xl border border-slate-200 bg-white p-5">
353
  <h3 className="mb-3 text-lg font-semibold text-slate-800">5) Execute Campaign via UniPile API</h3>
354
+ <p className="mb-3 text-sm text-slate-600">
355
+ Sends a <strong>LinkedIn connection request</strong> using your first generated message as the invite note
356
+ (visible under LinkedIn → My Network → Manage invitations → Sent). If an invite cannot be sent (for example
357
+ you are already connected), the app falls back to a direct message when possible.
358
+ </p>
359
+ {dryRun ? (
360
+ <div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-950">
361
+ <strong>Dry run is on.</strong> No requests go to UniPile and nothing appears on LinkedIn — including no
362
+ invitations in “Sent”.
363
+ </div>
364
+ ) : null}
365
+ <label className="mb-3 flex flex-col gap-1 text-sm text-slate-700 sm:flex-row sm:items-center sm:gap-2">
366
+ <span className="inline-flex items-center gap-2">
367
+ <input
368
+ type="checkbox"
369
+ checked={dryRun}
370
+ onChange={(e) => setDryRun(e.target.checked)}
371
+ />
372
+ Dry run (preview only — no UniPile / LinkedIn activity)
373
+ </span>
374
  </label>
375
  <Button onClick={executeCampaign} disabled={!selectedCampaignId || busy}>
376
  <Play className="mr-2 h-4 w-4" />
377
  Execute Campaign
378
  </Button>
379
+
380
+ {selectedCampaign?.execution_result?.attempts?.length ? (
381
+ <div className="mt-5 rounded-lg border border-slate-200 bg-slate-50/80 p-3">
382
+ <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
383
+ <p className="text-sm font-semibold text-slate-800">Last run — per contact</p>
384
+ {selectedCampaign.execution_result?.dry_run ? (
385
+ <span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-900">
386
+ Dry run
387
+ </span>
388
+ ) : null}
389
+ </div>
390
+ <p className="mb-2 text-xs text-slate-600">
391
+ {selectedCampaign.execution_result?.help ||
392
+ 'Statuses: invite_sent = connection request submitted; message_sent = DM (invite not used); dry_run = preview only.'}
393
+ </p>
394
+ <div className="max-h-72 overflow-auto rounded-md border border-slate-200 bg-white">
395
+ <table className="w-full text-left text-xs">
396
+ <thead className="sticky top-0 bg-slate-100 text-slate-700">
397
+ <tr>
398
+ <th className="px-2 py-1.5 font-medium">Name</th>
399
+ <th className="px-2 py-1.5 font-medium">Profile</th>
400
+ <th className="px-2 py-1.5 font-medium">Status</th>
401
+ <th className="px-2 py-1.5 font-medium">Note</th>
402
+ </tr>
403
+ </thead>
404
+ <tbody>
405
+ {selectedCampaign.execution_result.attempts.map((row, i) => (
406
+ <tr key={`${row.contact_id}-${i}`} className="border-t border-slate-100">
407
+ <td className="px-2 py-1.5 align-top text-slate-800">{row.name || '—'}</td>
408
+ <td className="max-w-[200px] px-2 py-1.5 align-top">
409
+ <div className="truncate text-slate-600" title={row.linkedin_url || ''}>
410
+ {row.public_identifier || row.linkedin_url || '—'}
411
+ </div>
412
+ </td>
413
+ <td className="whitespace-nowrap px-2 py-1.5 align-top font-medium text-slate-800">
414
+ {row.status === 'invite_sent'
415
+ ? 'Invite sent'
416
+ : row.status === 'message_sent'
417
+ ? 'DM sent'
418
+ : row.status === 'dry_run'
419
+ ? 'Dry run'
420
+ : row.status === 'skipped'
421
+ ? 'Skipped'
422
+ : row.status === 'failed'
423
+ ? 'Failed'
424
+ : row.status || '—'}
425
+ </td>
426
+ <td className="max-w-[240px] px-2 py-1.5 align-top text-slate-600">
427
+ {row.connection_hint || row.network_distance
428
+ ? `Distance: ${row.connection_hint || row.network_distance}. `
429
+ : ''}
430
+ {row.detail || row.invite_error || row.fallback_from_invite_error || row.reason || ''}
431
+ </td>
432
+ </tr>
433
+ ))}
434
+ </tbody>
435
+ </table>
436
+ </div>
437
+ <div className="mt-2 text-xs text-slate-500">
438
+ LinkedIn does not expose “accepted” here in real time; check My Network for new connections. UniPile
439
+ may include network distance on the profile response when available.
440
+ </div>
441
+ </div>
442
+ ) : null}
443
  </div>
444
  </div>
445
  );