Seth commited on
Commit
f1ca579
·
1 Parent(s): 40ce77b
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, Smartlead webhook, and LinkedIn.'
194
- : 'Smartlead webhook and LinkedIn. Invites and member management require admin.'
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
  );