victor34593993 commited on
Commit
f60201e
·
verified ·
1 Parent(s): 0fb952c

feat: portal self-service email

Browse files
app/handoff.py CHANGED
@@ -76,7 +76,12 @@ async def _email_notify(db: AsyncSession, tenant_id: int | None, h: HandoffReque
76
  f"Responde a este correo para contestarle directamente al cliente."
77
  )
78
  try:
79
- # reply_to = the customer's email, so hitting "reply" answers the lead.
80
- await send_email(settings, to, subject, body, reply_to=h.email or None)
 
 
 
 
 
81
  except Exception: # noqa: BLE001 - email is best-effort, never block the capture
82
  log.warning("handoff email notify failed (non-fatal)")
 
76
  f"Responde a este correo para contestarle directamente al cliente."
77
  )
78
  try:
79
+ # reply_to = the customer's email, so hitting "reply" answers the lead;
80
+ # sender_name = the store's own brand so the merchant sees who it's from.
81
+ await send_email(
82
+ settings, to, subject, body,
83
+ reply_to=h.email or None,
84
+ sender_name=f"{brand} · Asistente",
85
+ )
86
  except Exception: # noqa: BLE001 - email is best-effort, never block the capture
87
  log.warning("handoff email notify failed (non-fatal)")
app/mailer.py CHANGED
@@ -26,10 +26,16 @@ class BrevoMailer:
26
  self.sender_name = sender_name or sender_email
27
 
28
  async def send(
29
- self, to: str, subject: str, body: str, *, reply_to: str | None = None
 
 
 
 
 
 
30
  ) -> bool:
31
  payload: dict[str, Any] = {
32
- "sender": {"email": self.sender_email, "name": self.sender_name},
33
  "to": [{"email": to}],
34
  "subject": subject,
35
  "textContent": body,
@@ -53,14 +59,21 @@ class BrevoMailer:
53
 
54
 
55
  async def send_email(
56
- settings: Any, to: str, subject: str, body: str, *, reply_to: str | None = None
 
 
 
 
 
 
57
  ) -> bool:
58
- """Send via the best configured provider: Brevo API first, then SMTP."""
 
59
  if settings.brevo_api_key and settings.brevo_sender:
60
  mailer = BrevoMailer(
61
  settings.brevo_api_key, settings.brevo_sender, settings.brevo_sender_name
62
  )
63
- return await mailer.send(to, subject, body, reply_to=reply_to)
64
  if settings.smtp_host:
65
  return await default_sender(settings)(to, subject, body)
66
  log.warning("no email provider configured; not sending: %s", subject)
 
26
  self.sender_name = sender_name or sender_email
27
 
28
  async def send(
29
+ self,
30
+ to: str,
31
+ subject: str,
32
+ body: str,
33
+ *,
34
+ reply_to: str | None = None,
35
+ sender_name: str | None = None,
36
  ) -> bool:
37
  payload: dict[str, Any] = {
38
+ "sender": {"email": self.sender_email, "name": sender_name or self.sender_name},
39
  "to": [{"email": to}],
40
  "subject": subject,
41
  "textContent": body,
 
59
 
60
 
61
  async def send_email(
62
+ settings: Any,
63
+ to: str,
64
+ subject: str,
65
+ body: str,
66
+ *,
67
+ reply_to: str | None = None,
68
+ sender_name: str | None = None,
69
  ) -> bool:
70
+ """Send via the best configured provider: Brevo API first, then SMTP.
71
+ sender_name overrides the display name (e.g. per-tenant brand)."""
72
  if settings.brevo_api_key and settings.brevo_sender:
73
  mailer = BrevoMailer(
74
  settings.brevo_api_key, settings.brevo_sender, settings.brevo_sender_name
75
  )
76
+ return await mailer.send(to, subject, body, reply_to=reply_to, sender_name=sender_name)
77
  if settings.smtp_host:
78
  return await default_sender(settings)(to, subject, body)
79
  log.warning("no email provider configured; not sending: %s", subject)
app/portal_ui/index.html CHANGED
@@ -46,6 +46,15 @@
46
 
47
  <div id="app" class="hidden">
48
  <div class="stats" id="stats"></div>
 
 
 
 
 
 
 
 
 
49
  <div class="card">
50
  <div class="row" style="justify-content:space-between;">
51
  <strong>Solicitudes de contacto</strong>
@@ -70,10 +79,18 @@
70
  if(!r.ok){ $("loginerr").textContent = "Clave incorrecta."; return; }
71
  var me = await r.json();
72
  $("brand").textContent = me.brand_name || me.name || "Mi panel";
 
 
73
  try { localStorage.setItem("ssb_portal_token", TOKEN); } catch(e){}
74
  $("login").classList.add("hidden"); $("app").classList.remove("hidden");
75
  loadSummary(); loadLeads();
76
  }
 
 
 
 
 
 
77
  function logout(){
78
  TOKEN = ""; try { localStorage.removeItem("ssb_portal_token"); } catch(e){}
79
  $("app").classList.add("hidden"); $("login").classList.remove("hidden"); $("token").value = "";
 
46
 
47
  <div id="app" class="hidden">
48
  <div class="stats" id="stats"></div>
49
+ <div class="card">
50
+ <strong>📩 Email donde recibir los avisos</strong>
51
+ <p class="muted">Pon el correo de tu empresa. Cuando un cliente deje sus datos en el chat, te llegará el aviso aquí automáticamente.</p>
52
+ <div class="row">
53
+ <input id="notify-email" type="email" placeholder="tucorreo@empresa.com" />
54
+ <button onclick="saveEmail()">Guardar</button>
55
+ </div>
56
+ <p class="muted" id="email-state"></p>
57
+ </div>
58
  <div class="card">
59
  <div class="row" style="justify-content:space-between;">
60
  <strong>Solicitudes de contacto</strong>
 
79
  if(!r.ok){ $("loginerr").textContent = "Clave incorrecta."; return; }
80
  var me = await r.json();
81
  $("brand").textContent = me.brand_name || me.name || "Mi panel";
82
+ $("notify-email").value = me.support_email || "";
83
+ $("email-state").textContent = me.support_email ? "Recibirás los avisos en: " + me.support_email : "Aún no has puesto un email de avisos.";
84
  try { localStorage.setItem("ssb_portal_token", TOKEN); } catch(e){}
85
  $("login").classList.add("hidden"); $("app").classList.remove("hidden");
86
  loadSummary(); loadLeads();
87
  }
88
+ async function saveEmail(){
89
+ var email = $("notify-email").value.trim();
90
+ var r = await api("/portal/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ support_email: email }) });
91
+ if(r.ok){ var d = await r.json(); $("email-state").textContent = d.support_email ? "✅ Guardado. Avisos a: " + d.support_email : "Email borrado."; }
92
+ else { $("email-state").textContent = "No se pudo guardar."; }
93
+ }
94
  function logout(){
95
  TOKEN = ""; try { localStorage.removeItem("ssb_portal_token"); } catch(e){}
96
  $("app").classList.add("hidden"); $("login").classList.remove("hidden"); $("token").value = "";
app/routes/portal.py CHANGED
@@ -8,12 +8,17 @@ from pathlib import Path
8
 
9
  from fastapi import APIRouter, Depends, Header, HTTPException
10
  from fastapi.responses import HTMLResponse
 
11
  from sqlalchemy import func, select
12
  from sqlalchemy.ext.asyncio import AsyncSession
13
 
14
  from app.db import get_session
15
  from app.models import Event, HandoffRequest, Tenant
16
 
 
 
 
 
17
  router = APIRouter(prefix="/portal")
18
 
19
  _PAGE = Path(__file__).resolve().parent.parent / "portal_ui" / "index.html"
@@ -44,7 +49,24 @@ async def portal_page() -> HTMLResponse:
44
 
45
  @router.get("/api/me")
46
  async def portal_me(tenant: Tenant = Depends(require_portal_tenant)) -> dict:
47
- return {"slug": tenant.slug, "brand_name": tenant.brand_name, "name": tenant.name}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
 
50
  @router.get("/api/handoffs")
 
8
 
9
  from fastapi import APIRouter, Depends, Header, HTTPException
10
  from fastapi.responses import HTMLResponse
11
+ from pydantic import BaseModel
12
  from sqlalchemy import func, select
13
  from sqlalchemy.ext.asyncio import AsyncSession
14
 
15
  from app.db import get_session
16
  from app.models import Event, HandoffRequest, Tenant
17
 
18
+
19
+ class PortalSettingsIn(BaseModel):
20
+ support_email: str | None = None
21
+
22
  router = APIRouter(prefix="/portal")
23
 
24
  _PAGE = Path(__file__).resolve().parent.parent / "portal_ui" / "index.html"
 
49
 
50
  @router.get("/api/me")
51
  async def portal_me(tenant: Tenant = Depends(require_portal_tenant)) -> dict:
52
+ return {
53
+ "slug": tenant.slug,
54
+ "brand_name": tenant.brand_name,
55
+ "name": tenant.name,
56
+ "support_email": tenant.support_email,
57
+ }
58
+
59
+
60
+ @router.post("/api/settings")
61
+ async def portal_set_email(
62
+ body: PortalSettingsIn,
63
+ tenant: Tenant = Depends(require_portal_tenant),
64
+ db: AsyncSession = Depends(get_session),
65
+ ) -> dict[str, str]:
66
+ """The store sets, by itself, the email where it wants to receive leads."""
67
+ tenant.support_email = (body.support_email or "").strip()[:320]
68
+ await db.commit()
69
+ return {"support_email": tenant.support_email}
70
 
71
 
72
  @router.get("/api/handoffs")
tests/routes/test_portal.py CHANGED
@@ -83,6 +83,36 @@ async def test_portal_me_and_summary(app_client, db_session):
83
  assert s["pending"] == 1
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  async def test_admin_generates_dashboard_token(app_client):
87
  _app, client = app_client
88
  await client.post("/admin/tenants", headers=AUTH, json={"slug": "clienta"})
 
83
  assert s["pending"] == 1
84
 
85
 
86
+ async def test_portal_client_sets_own_notify_email(app_client, db_session):
87
+ _app, client = app_client
88
+ a, a_tok = await _tenant_with_token(db_session, "selfset")
89
+ hdr = {"Authorization": "Bearer " + a_tok}
90
+
91
+ r = await client.post(
92
+ "/portal/api/settings", headers=hdr, json={"support_email": "dueno@selfset.com"}
93
+ )
94
+ assert r.status_code == 200
95
+ assert r.json()["support_email"] == "dueno@selfset.com"
96
+
97
+ me = (await client.get("/portal/api/me", headers=hdr)).json()
98
+ assert me["support_email"] == "dueno@selfset.com"
99
+
100
+
101
+ async def test_portal_settings_scoped_to_own_tenant(app_client, db_session):
102
+ _app, client = app_client
103
+ a, a_tok = await _tenant_with_token(db_session, "ta")
104
+ b, _b = await _tenant_with_token(db_session, "tb")
105
+ # A sets its email; B must be unaffected
106
+ await client.post(
107
+ "/portal/api/settings", headers={"Authorization": "Bearer " + a_tok},
108
+ json={"support_email": "only-a@x.com"},
109
+ )
110
+ await db_session.refresh(b)
111
+ assert b.support_email == "" # B untouched
112
+ await db_session.refresh(a)
113
+ assert a.support_email == "only-a@x.com"
114
+
115
+
116
  async def test_admin_generates_dashboard_token(app_client):
117
  _app, client = app_client
118
  await client.post("/admin/tenants", headers=AUTH, json={"slug": "clienta"})
tests/test_handoff.py CHANGED
@@ -141,8 +141,8 @@ async def test_handoff_uses_brevo_api_when_configured(db_session, monkeypatch):
141
 
142
  captured = {}
143
 
144
- async def fake_send(self, to, subject, body, *, reply_to=None):
145
- captured.update(to=to, reply_to=reply_to, sender=self.sender_email)
146
  return True
147
 
148
  monkeypatch.setattr(mailer.BrevoMailer, "send", fake_send)
@@ -151,6 +151,7 @@ async def test_handoff_uses_brevo_api_when_configured(db_session, monkeypatch):
151
  assert captured["to"] == "dueno@bz.com" # tenant's own inbox, not anyone else's
152
  assert captured["reply_to"] == "cliente@x.com" # merchant replies straight to the lead
153
  assert captured["sender"] == "noreply@flexigobe.com"
 
154
 
155
 
156
  async def test_handoff_no_smtp_still_stores(db_session, monkeypatch):
 
141
 
142
  captured = {}
143
 
144
+ async def fake_send(self, to, subject, body, *, reply_to=None, sender_name=None):
145
+ captured.update(to=to, reply_to=reply_to, sender=self.sender_email, sender_name=sender_name)
146
  return True
147
 
148
  monkeypatch.setattr(mailer.BrevoMailer, "send", fake_send)
 
151
  assert captured["to"] == "dueno@bz.com" # tenant's own inbox, not anyone else's
152
  assert captured["reply_to"] == "cliente@x.com" # merchant replies straight to the lead
153
  assert captured["sender"] == "noreply@flexigobe.com"
154
+ assert "BZ" in captured["sender_name"] # the From name is the store's own brand
155
 
156
 
157
  async def test_handoff_no_smtp_still_stores(db_session, monkeypatch):