Seth commited on
Commit ·
f1ca579
1
Parent(s): 40ce77b
update
Browse files- backend/app/auth_routes.py +6 -0
- backend/app/database.py +6 -0
- backend/app/main.py +249 -0
- backend/app/models.py +11 -0
- frontend/src/components/settings/ConnectMailboxSettings.jsx +168 -0
- frontend/src/pages/Settings.jsx +6 -4
backend/app/auth_routes.py
CHANGED
|
@@ -254,9 +254,13 @@ async def auth_me(request: Request, db: Session = Depends(get_db)):
|
|
| 254 |
|
| 255 |
lpdn = None
|
| 256 |
duar = None
|
|
|
|
|
|
|
| 257 |
if urow:
|
| 258 |
lpdn = getattr(urow, "linkedin_profile_display_name", None)
|
| 259 |
duar = getattr(urow, "default_unipile_account_ref_id", None)
|
|
|
|
|
|
|
| 260 |
|
| 261 |
return {
|
| 262 |
**profile,
|
|
@@ -267,6 +271,8 @@ async def auth_me(request: Request, db: Session = Depends(get_db)):
|
|
| 267 |
"gmail_invites_ready": gmail_invites_ready,
|
| 268 |
"linkedin_profile_display_name": lpdn or "",
|
| 269 |
"default_unipile_account_ref_id": int(duar) if duar is not None else None,
|
|
|
|
|
|
|
| 270 |
}
|
| 271 |
|
| 272 |
|
|
|
|
| 254 |
|
| 255 |
lpdn = None
|
| 256 |
duar = None
|
| 257 |
+
mpdn = None
|
| 258 |
+
dmuar = None
|
| 259 |
if urow:
|
| 260 |
lpdn = getattr(urow, "linkedin_profile_display_name", None)
|
| 261 |
duar = getattr(urow, "default_unipile_account_ref_id", None)
|
| 262 |
+
mpdn = getattr(urow, "mailbox_profile_display_name", None)
|
| 263 |
+
dmuar = getattr(urow, "default_mailbox_unipile_account_ref_id", None)
|
| 264 |
|
| 265 |
return {
|
| 266 |
**profile,
|
|
|
|
| 271 |
"gmail_invites_ready": gmail_invites_ready,
|
| 272 |
"linkedin_profile_display_name": lpdn or "",
|
| 273 |
"default_unipile_account_ref_id": int(duar) if duar is not None else None,
|
| 274 |
+
"mailbox_profile_display_name": mpdn or "",
|
| 275 |
+
"default_mailbox_unipile_account_ref_id": int(dmuar) if dmuar is not None else None,
|
| 276 |
}
|
| 277 |
|
| 278 |
|
backend/app/database.py
CHANGED
|
@@ -37,6 +37,8 @@ class User(Base):
|
|
| 37 |
google_refresh_token = Column(Text, nullable=True) # for Gmail send (invite emails); treat as secret
|
| 38 |
linkedin_profile_display_name = Column(String, nullable=True)
|
| 39 |
default_unipile_account_ref_id = Column(Integer, ForeignKey("unipile_accounts.id"), nullable=True)
|
|
|
|
|
|
|
| 40 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 41 |
|
| 42 |
|
|
@@ -335,6 +337,10 @@ def run_migrations(connection_engine):
|
|
| 335 |
conn.execute(text("ALTER TABLE users ADD COLUMN linkedin_profile_display_name TEXT"))
|
| 336 |
if "default_unipile_account_ref_id" not in ucols:
|
| 337 |
conn.execute(text("ALTER TABLE users ADD COLUMN default_unipile_account_ref_id INTEGER"))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
|
| 339 |
insp = inspect(connection_engine)
|
| 340 |
if insp.has_table("crm_deals"):
|
|
|
|
| 37 |
google_refresh_token = Column(Text, nullable=True) # for Gmail send (invite emails); treat as secret
|
| 38 |
linkedin_profile_display_name = Column(String, nullable=True)
|
| 39 |
default_unipile_account_ref_id = Column(Integer, ForeignKey("unipile_accounts.id"), nullable=True)
|
| 40 |
+
mailbox_profile_display_name = Column(String, nullable=True)
|
| 41 |
+
default_mailbox_unipile_account_ref_id = Column(Integer, ForeignKey("unipile_accounts.id"), nullable=True)
|
| 42 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 43 |
|
| 44 |
|
|
|
|
| 337 |
conn.execute(text("ALTER TABLE users ADD COLUMN linkedin_profile_display_name TEXT"))
|
| 338 |
if "default_unipile_account_ref_id" not in ucols:
|
| 339 |
conn.execute(text("ALTER TABLE users ADD COLUMN default_unipile_account_ref_id INTEGER"))
|
| 340 |
+
if "mailbox_profile_display_name" not in ucols:
|
| 341 |
+
conn.execute(text("ALTER TABLE users ADD COLUMN mailbox_profile_display_name TEXT"))
|
| 342 |
+
if "default_mailbox_unipile_account_ref_id" not in ucols:
|
| 343 |
+
conn.execute(text("ALTER TABLE users ADD COLUMN default_mailbox_unipile_account_ref_id INTEGER"))
|
| 344 |
|
| 345 |
insp = inspect(connection_engine)
|
| 346 |
if insp.has_table("crm_deals"):
|
backend/app/main.py
CHANGED
|
@@ -57,7 +57,9 @@ from .models import (
|
|
| 57 |
WonBillingPayload,
|
| 58 |
UnipileConnectRequest,
|
| 59 |
UnipileHostedLinkRequest,
|
|
|
|
| 60 |
UserLinkedinPrefsPatch,
|
|
|
|
| 61 |
LinkedinCampaignCreateRequest,
|
| 62 |
LinkedinCampaignGenerateRequest,
|
| 63 |
LinkedinCampaignExecuteRequest,
|
|
@@ -392,6 +394,17 @@ def _linkedin_account_display_name(row: UnipileAccount) -> str:
|
|
| 392 |
return lab or "LinkedIn account"
|
| 393 |
|
| 394 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
def _try_fetch_unipile_account_profile(account_id: str) -> dict:
|
| 396 |
"""
|
| 397 |
Optional enrichment call. If it fails, we just keep existing metadata.
|
|
@@ -1267,6 +1280,242 @@ async def patch_me_linkedin_prefs(
|
|
| 1267 |
}
|
| 1268 |
|
| 1269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1270 |
@app.get("/api/unipile/linkedin/accounts")
|
| 1271 |
async def list_unipile_linkedin_accounts(t: TenantContext = Depends(get_tenant_context)):
|
| 1272 |
rows = (
|
|
|
|
| 57 |
WonBillingPayload,
|
| 58 |
UnipileConnectRequest,
|
| 59 |
UnipileHostedLinkRequest,
|
| 60 |
+
UnipileMailboxHostedLinkRequest,
|
| 61 |
UserLinkedinPrefsPatch,
|
| 62 |
+
UserMailboxPrefsPatch,
|
| 63 |
LinkedinCampaignCreateRequest,
|
| 64 |
LinkedinCampaignGenerateRequest,
|
| 65 |
LinkedinCampaignExecuteRequest,
|
|
|
|
| 394 |
return lab or "LinkedIn account"
|
| 395 |
|
| 396 |
|
| 397 |
+
_MAILBOX_PROVIDER_SET = ("GOOGLE", "MICROSOFT", "OUTLOOK", "MAIL", "IMAP")
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def _mailbox_account_display_name(row: UnipileAccount) -> str:
|
| 401 |
+
meta = row.metadata_json if isinstance(row.metadata_json, dict) else {}
|
| 402 |
+
n = _extract_unipile_identity(meta)[0].strip()
|
| 403 |
+
if n:
|
| 404 |
+
return n
|
| 405 |
+
return (row.label or "").strip() or "Mailbox"
|
| 406 |
+
|
| 407 |
+
|
| 408 |
def _try_fetch_unipile_account_profile(account_id: str) -> dict:
|
| 409 |
"""
|
| 410 |
Optional enrichment call. If it fails, we just keep existing metadata.
|
|
|
|
| 1280 |
}
|
| 1281 |
|
| 1282 |
|
| 1283 |
+
@app.get("/api/unipile/mailbox/campaign-defaults")
|
| 1284 |
+
async def unipile_mailbox_campaign_defaults(t: TenantContext = Depends(get_tenant_context)):
|
| 1285 |
+
db = t.db
|
| 1286 |
+
user_row = db.query(User).filter(User.id == t.user_id).first()
|
| 1287 |
+
rows = (
|
| 1288 |
+
db.query(UnipileAccount)
|
| 1289 |
+
.filter(
|
| 1290 |
+
UnipileAccount.tenant_id == t.tenant_id,
|
| 1291 |
+
UnipileAccount.user_id == t.user_id,
|
| 1292 |
+
UnipileAccount.provider.in_(_MAILBOX_PROVIDER_SET),
|
| 1293 |
+
)
|
| 1294 |
+
.order_by(UnipileAccount.created_at.desc(), UnipileAccount.id.desc())
|
| 1295 |
+
.all()
|
| 1296 |
+
)
|
| 1297 |
+
accounts = []
|
| 1298 |
+
for r in rows:
|
| 1299 |
+
ident = _extract_unipile_identity(r.metadata_json or {})
|
| 1300 |
+
accounts.append(
|
| 1301 |
+
{
|
| 1302 |
+
"id": r.id,
|
| 1303 |
+
"label": r.label or "Mailbox",
|
| 1304 |
+
"display_name": _mailbox_account_display_name(r),
|
| 1305 |
+
"avatar_url": ident[1] or "",
|
| 1306 |
+
"provider": r.provider,
|
| 1307 |
+
"unipile_account_id": r.unipile_account_id,
|
| 1308 |
+
"status": r.status or "unknown",
|
| 1309 |
+
"auth_mode": r.auth_mode or "hosted",
|
| 1310 |
+
"created_at": r.created_at.isoformat() if r.created_at else None,
|
| 1311 |
+
}
|
| 1312 |
+
)
|
| 1313 |
+
prof = (getattr(user_row, "mailbox_profile_display_name", None) or "").strip() if user_row else ""
|
| 1314 |
+
default_ref = getattr(user_row, "default_mailbox_unipile_account_ref_id", None) if user_row else None
|
| 1315 |
+
valid_default = None
|
| 1316 |
+
if default_ref is not None and any(a["id"] == default_ref for a in accounts):
|
| 1317 |
+
valid_default = default_ref
|
| 1318 |
+
return {
|
| 1319 |
+
"mailbox_profile_display_name": prof,
|
| 1320 |
+
"default_mailbox_unipile_account_ref_id": valid_default,
|
| 1321 |
+
"accounts": accounts,
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
|
| 1325 |
+
@app.patch("/api/me/mailbox-prefs")
|
| 1326 |
+
async def patch_me_mailbox_prefs(
|
| 1327 |
+
body: UserMailboxPrefsPatch,
|
| 1328 |
+
t: TenantContext = Depends(get_tenant_context),
|
| 1329 |
+
):
|
| 1330 |
+
db = t.db
|
| 1331 |
+
u = db.query(User).filter(User.id == t.user_id).first()
|
| 1332 |
+
if not u:
|
| 1333 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 1334 |
+
patch = body.model_dump(exclude_unset=True)
|
| 1335 |
+
if "mailbox_profile_display_name" in patch:
|
| 1336 |
+
s = (patch["mailbox_profile_display_name"] or "").strip()
|
| 1337 |
+
u.mailbox_profile_display_name = s[:200] if s else None
|
| 1338 |
+
if "default_mailbox_unipile_account_ref_id" in patch:
|
| 1339 |
+
rid = patch["default_mailbox_unipile_account_ref_id"]
|
| 1340 |
+
if rid is None or rid == 0:
|
| 1341 |
+
u.default_mailbox_unipile_account_ref_id = None
|
| 1342 |
+
else:
|
| 1343 |
+
acc = (
|
| 1344 |
+
db.query(UnipileAccount)
|
| 1345 |
+
.filter(
|
| 1346 |
+
UnipileAccount.id == rid,
|
| 1347 |
+
UnipileAccount.tenant_id == t.tenant_id,
|
| 1348 |
+
UnipileAccount.user_id == t.user_id,
|
| 1349 |
+
UnipileAccount.provider.in_(_MAILBOX_PROVIDER_SET),
|
| 1350 |
+
)
|
| 1351 |
+
.first()
|
| 1352 |
+
)
|
| 1353 |
+
if not acc:
|
| 1354 |
+
raise HTTPException(status_code=404, detail="Mailbox account not found for this user")
|
| 1355 |
+
u.default_mailbox_unipile_account_ref_id = rid
|
| 1356 |
+
db.commit()
|
| 1357 |
+
return {
|
| 1358 |
+
"ok": True,
|
| 1359 |
+
"mailbox_profile_display_name": (u.mailbox_profile_display_name or ""),
|
| 1360 |
+
"default_mailbox_unipile_account_ref_id": u.default_mailbox_unipile_account_ref_id,
|
| 1361 |
+
}
|
| 1362 |
+
|
| 1363 |
+
|
| 1364 |
+
@app.post("/api/unipile/mailbox/hosted-link")
|
| 1365 |
+
async def create_unipile_mailbox_hosted_link(
|
| 1366 |
+
body: UnipileMailboxHostedLinkRequest,
|
| 1367 |
+
request: Request,
|
| 1368 |
+
t: TenantContext = Depends(get_tenant_context),
|
| 1369 |
+
):
|
| 1370 |
+
origin = (FRONTEND_ORIGIN or "").strip().rstrip("/") or _public_origin_from_request(request)
|
| 1371 |
+
if not origin:
|
| 1372 |
+
raise HTTPException(status_code=400, detail="Could not determine frontend origin for hosted auth.")
|
| 1373 |
+
token = str(uuid.uuid4())
|
| 1374 |
+
expires_at = datetime.utcnow() + timedelta(minutes=20)
|
| 1375 |
+
u = t.db.query(User).filter(User.id == t.user_id).first()
|
| 1376 |
+
if u is not None and body.mailbox_profile_display_name is not None:
|
| 1377 |
+
s = (body.mailbox_profile_display_name or "").strip()
|
| 1378 |
+
if s:
|
| 1379 |
+
u.mailbox_profile_display_name = s[:200]
|
| 1380 |
+
state = UnipileHostedAuthState(
|
| 1381 |
+
tenant_id=t.tenant_id,
|
| 1382 |
+
user_id=t.user_id,
|
| 1383 |
+
token=token,
|
| 1384 |
+
provider="GOOGLE",
|
| 1385 |
+
label=(body.label or "Mailbox").strip(),
|
| 1386 |
+
expires_at=expires_at,
|
| 1387 |
+
)
|
| 1388 |
+
t.db.add(state)
|
| 1389 |
+
t.db.commit()
|
| 1390 |
+
expires_iso = expires_at.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
| 1391 |
+
payload = {
|
| 1392 |
+
"type": "create",
|
| 1393 |
+
"providers": ["GOOGLE"],
|
| 1394 |
+
"api_url": UNIPILE_API_BASE,
|
| 1395 |
+
"expiresOn": expires_iso,
|
| 1396 |
+
"notify_url": f"{origin}/api/unipile/mailbox/hosted-callback",
|
| 1397 |
+
"success_redirect_url": f"{origin}/settings?mailbox=connected",
|
| 1398 |
+
"failure_redirect_url": f"{origin}/settings?mailbox=failed",
|
| 1399 |
+
"name": token,
|
| 1400 |
+
}
|
| 1401 |
+
response = _unipile_request("POST", "/api/v1/hosted/accounts/link", payload)
|
| 1402 |
+
hosted_url = _safe_str(response.get("url") if isinstance(response, dict) else "")
|
| 1403 |
+
if not hosted_url:
|
| 1404 |
+
raise HTTPException(status_code=400, detail="UniPile did not return hosted auth URL")
|
| 1405 |
+
return {"url": hosted_url}
|
| 1406 |
+
|
| 1407 |
+
|
| 1408 |
+
@app.post("/api/unipile/mailbox/hosted-callback")
|
| 1409 |
+
async def unipile_mailbox_hosted_callback(request: Request, db: Session = Depends(get_db)):
|
| 1410 |
+
try:
|
| 1411 |
+
body = await request.json()
|
| 1412 |
+
except Exception:
|
| 1413 |
+
raise HTTPException(status_code=400, detail="Expected JSON body")
|
| 1414 |
+
name_token = _safe_str((body or {}).get("name"))
|
| 1415 |
+
unipile_account_id = _safe_str((body or {}).get("account_id"))
|
| 1416 |
+
status = _safe_str((body or {}).get("status") or "connected")
|
| 1417 |
+
if not name_token:
|
| 1418 |
+
raise HTTPException(status_code=400, detail="Missing name token")
|
| 1419 |
+
if not unipile_account_id:
|
| 1420 |
+
return {"ok": True, "ignored": True, "reason": "missing_account_id"}
|
| 1421 |
+
account_profile = _try_fetch_unipile_account_profile(unipile_account_id)
|
| 1422 |
+
merged_meta = {}
|
| 1423 |
+
if isinstance(body, dict):
|
| 1424 |
+
merged_meta.update(body)
|
| 1425 |
+
if isinstance(account_profile, dict) and account_profile:
|
| 1426 |
+
merged_meta["account_profile"] = account_profile
|
| 1427 |
+
state = (
|
| 1428 |
+
db.query(UnipileHostedAuthState)
|
| 1429 |
+
.filter(
|
| 1430 |
+
UnipileHostedAuthState.token == name_token,
|
| 1431 |
+
UnipileHostedAuthState.provider == "GOOGLE",
|
| 1432 |
+
)
|
| 1433 |
+
.first()
|
| 1434 |
+
)
|
| 1435 |
+
if not state:
|
| 1436 |
+
return {"ok": True, "ignored": True, "reason": "unknown_token"}
|
| 1437 |
+
if state.used_at is not None:
|
| 1438 |
+
return {"ok": True, "ignored": True, "reason": "token_already_used"}
|
| 1439 |
+
if state.expires_at and state.expires_at < datetime.utcnow():
|
| 1440 |
+
return {"ok": True, "ignored": True, "reason": "token_expired"}
|
| 1441 |
+
existing = (
|
| 1442 |
+
db.query(UnipileAccount)
|
| 1443 |
+
.filter(
|
| 1444 |
+
UnipileAccount.tenant_id == state.tenant_id,
|
| 1445 |
+
UnipileAccount.user_id == state.user_id,
|
| 1446 |
+
UnipileAccount.provider.in_(_MAILBOX_PROVIDER_SET),
|
| 1447 |
+
UnipileAccount.unipile_account_id == unipile_account_id,
|
| 1448 |
+
)
|
| 1449 |
+
.first()
|
| 1450 |
+
)
|
| 1451 |
+
user_row = db.query(User).filter(User.id == state.user_id).first()
|
| 1452 |
+
box_name = _extract_unipile_identity(merged_meta)[0].strip()
|
| 1453 |
+
if user_row is not None and box_name:
|
| 1454 |
+
user_row.mailbox_profile_display_name = box_name[:200]
|
| 1455 |
+
nice_label = (state.label or "").strip() or "Mailbox"
|
| 1456 |
+
if box_name:
|
| 1457 |
+
nice_label = f"{box_name}'s mailbox"
|
| 1458 |
+
nice_label = nice_label[:255]
|
| 1459 |
+
if existing:
|
| 1460 |
+
existing.label = nice_label
|
| 1461 |
+
existing.status = status or existing.status
|
| 1462 |
+
existing.auth_mode = "hosted"
|
| 1463 |
+
existing.metadata_json = merged_meta or body
|
| 1464 |
+
existing.updated_at = datetime.utcnow()
|
| 1465 |
+
if user_row is not None and getattr(user_row, "default_mailbox_unipile_account_ref_id", None) is None:
|
| 1466 |
+
user_row.default_mailbox_unipile_account_ref_id = existing.id
|
| 1467 |
+
else:
|
| 1468 |
+
row = UnipileAccount(
|
| 1469 |
+
tenant_id=state.tenant_id,
|
| 1470 |
+
user_id=state.user_id,
|
| 1471 |
+
label=nice_label,
|
| 1472 |
+
provider="GOOGLE",
|
| 1473 |
+
unipile_account_id=unipile_account_id,
|
| 1474 |
+
status=status or "connected",
|
| 1475 |
+
auth_mode="hosted",
|
| 1476 |
+
metadata_json=merged_meta or body,
|
| 1477 |
+
)
|
| 1478 |
+
db.add(row)
|
| 1479 |
+
db.flush()
|
| 1480 |
+
acc_ref_id = row.id
|
| 1481 |
+
if user_row is not None and getattr(user_row, "default_mailbox_unipile_account_ref_id", None) is None:
|
| 1482 |
+
user_row.default_mailbox_unipile_account_ref_id = acc_ref_id
|
| 1483 |
+
state.used_at = datetime.utcnow()
|
| 1484 |
+
db.commit()
|
| 1485 |
+
return {"ok": True}
|
| 1486 |
+
|
| 1487 |
+
|
| 1488 |
+
@app.get("/api/unipile/mailbox/accounts")
|
| 1489 |
+
async def list_unipile_mailbox_accounts(t: TenantContext = Depends(get_tenant_context)):
|
| 1490 |
+
rows = (
|
| 1491 |
+
t.db.query(UnipileAccount)
|
| 1492 |
+
.filter(
|
| 1493 |
+
UnipileAccount.tenant_id == t.tenant_id,
|
| 1494 |
+
UnipileAccount.user_id == t.user_id,
|
| 1495 |
+
UnipileAccount.provider.in_(_MAILBOX_PROVIDER_SET),
|
| 1496 |
+
)
|
| 1497 |
+
.order_by(UnipileAccount.created_at.desc(), UnipileAccount.id.desc())
|
| 1498 |
+
.all()
|
| 1499 |
+
)
|
| 1500 |
+
out = []
|
| 1501 |
+
for r in rows:
|
| 1502 |
+
ident = _extract_unipile_identity(r.metadata_json or {})
|
| 1503 |
+
out.append(
|
| 1504 |
+
{
|
| 1505 |
+
"id": r.id,
|
| 1506 |
+
"label": r.label or "Mailbox",
|
| 1507 |
+
"display_name": _mailbox_account_display_name(r),
|
| 1508 |
+
"avatar_url": ident[1] or "",
|
| 1509 |
+
"provider": r.provider,
|
| 1510 |
+
"unipile_account_id": r.unipile_account_id,
|
| 1511 |
+
"status": r.status or "unknown",
|
| 1512 |
+
"auth_mode": r.auth_mode or "hosted",
|
| 1513 |
+
"created_at": r.created_at.isoformat() if r.created_at else None,
|
| 1514 |
+
}
|
| 1515 |
+
)
|
| 1516 |
+
return {"accounts": out}
|
| 1517 |
+
|
| 1518 |
+
|
| 1519 |
@app.get("/api/unipile/linkedin/accounts")
|
| 1520 |
async def list_unipile_linkedin_accounts(t: TenantContext = Depends(get_tenant_context)):
|
| 1521 |
rows = (
|
backend/app/models.py
CHANGED
|
@@ -250,12 +250,23 @@ class UnipileHostedLinkRequest(BaseModel):
|
|
| 250 |
"""User-facing name for campaigns (saved on your profile when starting hosted auth)."""
|
| 251 |
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
class UserLinkedinPrefsPatch(BaseModel):
|
| 254 |
linkedin_profile_display_name: Optional[str] = Field(None, max_length=200)
|
| 255 |
default_unipile_account_ref_id: Optional[int] = Field(None, ge=0)
|
| 256 |
"""Use 0 to clear the default account selection."""
|
| 257 |
|
| 258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
class LinkedinCampaignCreateRequest(BaseModel):
|
| 260 |
name: str
|
| 261 |
unipile_account_ref_id: int
|
|
|
|
| 250 |
"""User-facing name for campaigns (saved on your profile when starting hosted auth)."""
|
| 251 |
|
| 252 |
|
| 253 |
+
class UnipileMailboxHostedLinkRequest(BaseModel):
|
| 254 |
+
label: str = "Mailbox"
|
| 255 |
+
mailbox_profile_display_name: Optional[str] = None
|
| 256 |
+
|
| 257 |
+
|
| 258 |
class UserLinkedinPrefsPatch(BaseModel):
|
| 259 |
linkedin_profile_display_name: Optional[str] = Field(None, max_length=200)
|
| 260 |
default_unipile_account_ref_id: Optional[int] = Field(None, ge=0)
|
| 261 |
"""Use 0 to clear the default account selection."""
|
| 262 |
|
| 263 |
|
| 264 |
+
class UserMailboxPrefsPatch(BaseModel):
|
| 265 |
+
mailbox_profile_display_name: Optional[str] = Field(None, max_length=200)
|
| 266 |
+
default_mailbox_unipile_account_ref_id: Optional[int] = Field(None, ge=0)
|
| 267 |
+
"""Use 0 to clear the default mailbox account selection."""
|
| 268 |
+
|
| 269 |
+
|
| 270 |
class LinkedinCampaignCreateRequest(BaseModel):
|
| 271 |
name: str
|
| 272 |
unipile_account_ref_id: int
|
frontend/src/components/settings/ConnectMailboxSettings.jsx
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useCallback, useEffect, useState } from 'react';
|
| 2 |
+
import { Mail, RefreshCw, Loader2 } from 'lucide-react';
|
| 3 |
+
import { Button } from '@/components/ui/button';
|
| 4 |
+
import { Input } from '@/components/ui/input';
|
| 5 |
+
import { apiFetch } from '@/lib/api';
|
| 6 |
+
import { cn } from '@/lib/utils';
|
| 7 |
+
|
| 8 |
+
export default function ConnectMailboxSettings() {
|
| 9 |
+
const [loading, setLoading] = useState(true);
|
| 10 |
+
const [busy, setBusy] = useState(false);
|
| 11 |
+
const [profileName, setProfileName] = useState('');
|
| 12 |
+
const [accountLabel, setAccountLabel] = useState('Mailbox');
|
| 13 |
+
const [accounts, setAccounts] = useState([]);
|
| 14 |
+
const [defaultRefId, setDefaultRefId] = useState(null);
|
| 15 |
+
|
| 16 |
+
const load = useCallback(async () => {
|
| 17 |
+
setLoading(true);
|
| 18 |
+
try {
|
| 19 |
+
const r = await apiFetch('/api/unipile/mailbox/campaign-defaults');
|
| 20 |
+
const d = r.ok ? await r.json() : {};
|
| 21 |
+
setProfileName(d.mailbox_profile_display_name || '');
|
| 22 |
+
setAccounts(d.accounts || []);
|
| 23 |
+
setDefaultRefId(d.default_mailbox_unipile_account_ref_id ?? null);
|
| 24 |
+
const n = (d.mailbox_profile_display_name || '').trim();
|
| 25 |
+
setAccountLabel(n ? `${n}'s mailbox` : 'Mailbox');
|
| 26 |
+
} catch {
|
| 27 |
+
setAccounts([]);
|
| 28 |
+
} finally {
|
| 29 |
+
setLoading(false);
|
| 30 |
+
}
|
| 31 |
+
}, []);
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
load();
|
| 35 |
+
}, [load]);
|
| 36 |
+
|
| 37 |
+
const savePreferences = async () => {
|
| 38 |
+
setBusy(true);
|
| 39 |
+
try {
|
| 40 |
+
const payload = { mailbox_profile_display_name: profileName.trim() };
|
| 41 |
+
if (defaultRefId != null) payload.default_mailbox_unipile_account_ref_id = defaultRefId;
|
| 42 |
+
const res = await apiFetch('/api/me/mailbox-prefs', {
|
| 43 |
+
method: 'PATCH',
|
| 44 |
+
headers: { 'Content-Type': 'application/json' },
|
| 45 |
+
body: JSON.stringify(payload),
|
| 46 |
+
});
|
| 47 |
+
const data = await res.json().catch(() => ({}));
|
| 48 |
+
if (!res.ok) throw new Error(data.detail || 'Could not save');
|
| 49 |
+
setProfileName(data.mailbox_profile_display_name || '');
|
| 50 |
+
setDefaultRefId(data.default_mailbox_unipile_account_ref_id ?? null);
|
| 51 |
+
await load();
|
| 52 |
+
} catch (e) {
|
| 53 |
+
alert(e.message || 'Save failed');
|
| 54 |
+
} finally {
|
| 55 |
+
setBusy(false);
|
| 56 |
+
}
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const connectAccount = async () => {
|
| 60 |
+
setBusy(true);
|
| 61 |
+
try {
|
| 62 |
+
const res = await apiFetch('/api/unipile/mailbox/hosted-link', {
|
| 63 |
+
method: 'POST',
|
| 64 |
+
headers: { 'Content-Type': 'application/json' },
|
| 65 |
+
body: JSON.stringify({
|
| 66 |
+
label: accountLabel.trim() || 'Mailbox',
|
| 67 |
+
mailbox_profile_display_name: profileName.trim() || undefined,
|
| 68 |
+
}),
|
| 69 |
+
});
|
| 70 |
+
const data = await res.json().catch(() => ({}));
|
| 71 |
+
if (!res.ok) throw new Error(data.detail || 'Could not start mailbox connect');
|
| 72 |
+
if (data.url) window.location.href = data.url;
|
| 73 |
+
} catch (e) {
|
| 74 |
+
alert(e.message || 'Connect failed');
|
| 75 |
+
} finally {
|
| 76 |
+
setBusy(false);
|
| 77 |
+
}
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
return (
|
| 81 |
+
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
| 82 |
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
| 83 |
+
<div className="flex items-center gap-2 font-semibold text-slate-800">
|
| 84 |
+
<Mail className="h-5 w-5 text-violet-600" />
|
| 85 |
+
Connect Mailbox
|
| 86 |
+
</div>
|
| 87 |
+
<Button type="button" variant="outline" size="sm" disabled={loading || busy} onClick={load}>
|
| 88 |
+
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
|
| 89 |
+
Refresh
|
| 90 |
+
</Button>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
| 94 |
+
<div>
|
| 95 |
+
<label className="mb-1 block text-xs font-medium text-slate-600">Mailbox profile name</label>
|
| 96 |
+
<Input
|
| 97 |
+
placeholder="e.g. Seth S"
|
| 98 |
+
value={profileName}
|
| 99 |
+
onChange={(e) => setProfileName(e.target.value)}
|
| 100 |
+
className="h-10"
|
| 101 |
+
/>
|
| 102 |
+
</div>
|
| 103 |
+
<div>
|
| 104 |
+
<label className="mb-1 block text-xs font-medium text-slate-600">Account label</label>
|
| 105 |
+
<Input
|
| 106 |
+
placeholder="e.g. Seth S's mailbox"
|
| 107 |
+
value={accountLabel}
|
| 108 |
+
onChange={(e) => setAccountLabel(e.target.value)}
|
| 109 |
+
className="h-10"
|
| 110 |
+
/>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<div className="mt-4 flex flex-wrap gap-2">
|
| 115 |
+
<Button type="button" disabled={busy} onClick={savePreferences} variant="outline">
|
| 116 |
+
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save preferences'}
|
| 117 |
+
</Button>
|
| 118 |
+
<Button type="button" disabled={busy} onClick={connectAccount} className="bg-slate-900 text-white">
|
| 119 |
+
Connect mailbox
|
| 120 |
+
</Button>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
{loading ? (
|
| 124 |
+
<div className="mt-6 flex justify-center py-8 text-slate-400">
|
| 125 |
+
<Loader2 className="h-8 w-8 animate-spin" />
|
| 126 |
+
</div>
|
| 127 |
+
) : accounts.length === 0 ? (
|
| 128 |
+
<p className="mt-6 text-sm text-slate-500">Connected mailboxes: 0</p>
|
| 129 |
+
) : (
|
| 130 |
+
<div className="mt-6 space-y-3">
|
| 131 |
+
<p className="text-sm font-medium text-slate-700">
|
| 132 |
+
Connected mailboxes ({accounts.length}) — default for email sequence steps
|
| 133 |
+
</p>
|
| 134 |
+
<ul className="space-y-2">
|
| 135 |
+
{accounts.map((a) => {
|
| 136 |
+
const name = a.display_name || a.label || 'Mailbox';
|
| 137 |
+
const checked = defaultRefId != null && Number(defaultRefId) === Number(a.id);
|
| 138 |
+
return (
|
| 139 |
+
<li
|
| 140 |
+
key={a.id}
|
| 141 |
+
className={cn(
|
| 142 |
+
'flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2 transition',
|
| 143 |
+
checked ? 'border-violet-300 bg-violet-50/60' : 'border-slate-200 hover:bg-slate-50'
|
| 144 |
+
)}
|
| 145 |
+
onClick={() => setDefaultRefId(a.id)}
|
| 146 |
+
>
|
| 147 |
+
<input
|
| 148 |
+
type="radio"
|
| 149 |
+
className="h-4 w-4 accent-violet-600"
|
| 150 |
+
checked={checked}
|
| 151 |
+
onChange={() => setDefaultRefId(a.id)}
|
| 152 |
+
/>
|
| 153 |
+
<div className="min-w-0 flex-1">
|
| 154 |
+
<p className="truncate font-medium text-slate-900">{name}</p>
|
| 155 |
+
<p className="truncate text-xs text-slate-500">{a.label}</p>
|
| 156 |
+
</div>
|
| 157 |
+
<span className="text-[10px] font-semibold uppercase text-slate-400">
|
| 158 |
+
{a.status || '—'}
|
| 159 |
+
</span>
|
| 160 |
+
</li>
|
| 161 |
+
);
|
| 162 |
+
})}
|
| 163 |
+
</ul>
|
| 164 |
+
</div>
|
| 165 |
+
)}
|
| 166 |
+
</section>
|
| 167 |
+
);
|
| 168 |
+
}
|
frontend/src/pages/Settings.jsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
| 10 |
} from 'lucide-react';
|
| 11 |
import AppShell from '@/components/layout/AppShell';
|
| 12 |
import LinkedInConnectSettings from '@/components/settings/LinkedInConnectSettings';
|
|
|
|
| 13 |
import { Button } from '@/components/ui/button';
|
| 14 |
import { Input } from '@/components/ui/input';
|
| 15 |
import {
|
|
@@ -190,8 +191,8 @@ export default function Settings() {
|
|
| 190 |
title="Settings"
|
| 191 |
subtitle={
|
| 192 |
isAdmin
|
| 193 |
-
? 'Invitations, members,
|
| 194 |
-
: '
|
| 195 |
}
|
| 196 |
>
|
| 197 |
<div className="space-y-8 max-w-3xl">
|
|
@@ -355,6 +356,9 @@ export default function Settings() {
|
|
| 355 |
</section>
|
| 356 |
) : null}
|
| 357 |
|
|
|
|
|
|
|
|
|
|
| 358 |
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
| 359 |
<div className="flex items-center gap-2 text-slate-800 font-semibold mb-4">
|
| 360 |
<Link2 className="h-5 w-5 text-violet-600" />
|
|
@@ -406,8 +410,6 @@ export default function Settings() {
|
|
| 406 |
</div>
|
| 407 |
) : null}
|
| 408 |
</section>
|
| 409 |
-
|
| 410 |
-
<LinkedInConnectSettings />
|
| 411 |
</div>
|
| 412 |
</AppShell>
|
| 413 |
);
|
|
|
|
| 10 |
} from 'lucide-react';
|
| 11 |
import AppShell from '@/components/layout/AppShell';
|
| 12 |
import LinkedInConnectSettings from '@/components/settings/LinkedInConnectSettings';
|
| 13 |
+
import ConnectMailboxSettings from '@/components/settings/ConnectMailboxSettings';
|
| 14 |
import { Button } from '@/components/ui/button';
|
| 15 |
import { Input } from '@/components/ui/input';
|
| 16 |
import {
|
|
|
|
| 191 |
title="Settings"
|
| 192 |
subtitle={
|
| 193 |
isAdmin
|
| 194 |
+
? 'Invitations, members, LinkedIn, mailbox, and Smartlead webhook.'
|
| 195 |
+
: 'LinkedIn, mailbox, and Smartlead webhook. Invites and member management require admin.'
|
| 196 |
}
|
| 197 |
>
|
| 198 |
<div className="space-y-8 max-w-3xl">
|
|
|
|
| 356 |
</section>
|
| 357 |
) : null}
|
| 358 |
|
| 359 |
+
<LinkedInConnectSettings />
|
| 360 |
+
<ConnectMailboxSettings />
|
| 361 |
+
|
| 362 |
<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
| 363 |
<div className="flex items-center gap-2 text-slate-800 font-semibold mb-4">
|
| 364 |
<Link2 className="h-5 w-5 text-violet-600" />
|
|
|
|
| 410 |
</div>
|
| 411 |
) : null}
|
| 412 |
</section>
|
|
|
|
|
|
|
| 413 |
</div>
|
| 414 |
</AppShell>
|
| 415 |
);
|