Spaces:
Running
Running
Commit Β·
a63a1b7
1
Parent(s): 55dc8b5
Add Gmail SMTP email backend
Browse filessend_email can now deliver via Gmail SMTP (delivers to ANY recipient with just a Google App Password β no domain verification) as well as Resend, selected by EMAIL_BACKEND (auto-detects if unset). The blocking smtplib call runs via asyncio.to_thread so the realtime audio loop is never stalled. All existing guards (visitor-identity check, placeholder rejection, per-session dedupe, outbox recording) are preserved. The four Gmail settings keys are exposed in the dashboard Settings panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
src/reachy_mini_receptionist/main.py
CHANGED
|
@@ -819,6 +819,10 @@ def _mount_dashboard_api(
|
|
| 819 |
"GEMINI_API_KEY": {"secret": True, "restart": True},
|
| 820 |
"STT_DISABLE_BIAS": {"secret": False, "restart": True},
|
| 821 |
"STT_LANGUAGE": {"secret": False, "restart": True},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 822 |
"RESEND_API_KEY": {"secret": True, "restart": False},
|
| 823 |
"RESEND_FROM": {"secret": False, "restart": False},
|
| 824 |
"RECEPTION_ICS_URL": {"secret": False, "restart": False},
|
|
|
|
| 819 |
"GEMINI_API_KEY": {"secret": True, "restart": True},
|
| 820 |
"STT_DISABLE_BIAS": {"secret": False, "restart": True},
|
| 821 |
"STT_LANGUAGE": {"secret": False, "restart": True},
|
| 822 |
+
"EMAIL_BACKEND": {"secret": False, "restart": False},
|
| 823 |
+
"GMAIL_ADDRESS": {"secret": False, "restart": False},
|
| 824 |
+
"GMAIL_APP_PASSWORD": {"secret": True, "restart": False},
|
| 825 |
+
"GMAIL_FROM_NAME": {"secret": False, "restart": False},
|
| 826 |
"RESEND_API_KEY": {"secret": True, "restart": False},
|
| 827 |
"RESEND_FROM": {"secret": False, "restart": False},
|
| 828 |
"RECEPTION_ICS_URL": {"secret": False, "restart": False},
|
src/reachy_mini_receptionist/tools/send_email.py
CHANGED
|
@@ -1,20 +1,28 @@
|
|
| 1 |
"""Tool: send_email
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
The outbox is ALWAYS written so the dashboard's Mailbox Out panel reflects
|
| 8 |
reality regardless of delivery mode. Entries carry ``delivered`` and
|
| 9 |
``error`` fields so the UI can flag failed sends.
|
| 10 |
|
| 11 |
Environment variables:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
RESEND_API_KEY Resend API key (``re_...``). Get one free at
|
| 13 |
-
https://resend.com/api-keys.
|
| 14 |
-
|
| 15 |
-
``success=True, delivered=False`` so the conversation
|
| 16 |
-
flow can continue.
|
| 17 |
-
RESEND_FROM Sender address. Defaults to ``onboarding@resend.dev``
|
| 18 |
(Resend's sandbox sender; this only delivers to the
|
| 19 |
email address registered on your Resend account).
|
| 20 |
For production, verify a domain at
|
|
@@ -23,9 +31,10 @@ Environment variables:
|
|
| 23 |
"""
|
| 24 |
from __future__ import annotations
|
| 25 |
|
| 26 |
-
import logging
|
| 27 |
import os
|
| 28 |
import time
|
|
|
|
|
|
|
| 29 |
from typing import Any, Dict, List, Optional
|
| 30 |
|
| 31 |
import httpx
|
|
@@ -80,6 +89,92 @@ def _record_outbox(
|
|
| 80 |
return entry
|
| 81 |
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
# ---------------------------------------------------------------------------
|
| 84 |
# Tool class
|
| 85 |
# ---------------------------------------------------------------------------
|
|
@@ -293,40 +388,50 @@ class SendEmail(Tool):
|
|
| 293 |
except Exception as e:
|
| 294 |
logger.debug("release_email_claim failed: %s", e)
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
entry = _record_outbox(to, subject, body, delivered=False, error=None)
|
| 299 |
logger.info(
|
| 300 |
-
"[EMAIL DRY-RUN β %s] %s (
|
| 301 |
to, subject,
|
| 302 |
)
|
| 303 |
return {
|
| 304 |
"success": True,
|
| 305 |
-
"message":
|
|
|
|
|
|
|
|
|
|
| 306 |
"timestamp": entry["timestamp"],
|
| 307 |
"delivered": False,
|
| 308 |
}
|
| 309 |
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
"text": body,
|
| 316 |
-
}
|
| 317 |
|
| 318 |
-
|
| 319 |
-
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS) as client:
|
| 320 |
-
resp = await client.post(
|
| 321 |
-
_RESEND_API_URL,
|
| 322 |
-
headers={
|
| 323 |
-
"Authorization": f"Bearer {api_key}",
|
| 324 |
-
"Content-Type": "application/json",
|
| 325 |
-
},
|
| 326 |
-
json=payload,
|
| 327 |
-
)
|
| 328 |
-
except Exception as e:
|
| 329 |
-
err = f"{type(e).__name__}: {e}"
|
| 330 |
entry = _record_outbox(to, subject, body, delivered=False, error=err)
|
| 331 |
logger.warning("[EMAIL FAILED β %s] %s | %s", to, subject, err)
|
| 332 |
_release_claim()
|
|
@@ -337,34 +442,12 @@ class SendEmail(Tool):
|
|
| 337 |
"delivered": False,
|
| 338 |
}
|
| 339 |
|
| 340 |
-
if resp.status_code >= 400:
|
| 341 |
-
try:
|
| 342 |
-
parsed = resp.json()
|
| 343 |
-
err_msg = parsed.get("message") or parsed.get("name") or resp.text
|
| 344 |
-
except Exception:
|
| 345 |
-
err_msg = resp.text
|
| 346 |
-
err = f"HTTP {resp.status_code}: {err_msg}"
|
| 347 |
-
entry = _record_outbox(to, subject, body, delivered=False, error=err)
|
| 348 |
-
logger.warning("[EMAIL FAILED β %s] %s | %s", to, subject, err)
|
| 349 |
-
_release_claim()
|
| 350 |
-
return {
|
| 351 |
-
"success": False,
|
| 352 |
-
"error": err,
|
| 353 |
-
"timestamp": entry["timestamp"],
|
| 354 |
-
"delivered": False,
|
| 355 |
-
}
|
| 356 |
-
|
| 357 |
-
try:
|
| 358 |
-
message_id = resp.json().get("id")
|
| 359 |
-
except Exception:
|
| 360 |
-
message_id = None
|
| 361 |
-
|
| 362 |
entry = _record_outbox(
|
| 363 |
to, subject, body,
|
| 364 |
delivered=True, error=None,
|
| 365 |
provider_message_id=message_id,
|
| 366 |
)
|
| 367 |
-
logger.info("[EMAIL β %s] %s (
|
| 368 |
return {
|
| 369 |
"success": True,
|
| 370 |
"message": f"Email sent to {to}",
|
|
|
|
| 1 |
"""Tool: send_email
|
| 2 |
|
| 3 |
+
Delivers via one of two backends (selectable with ``EMAIL_BACKEND``):
|
| 4 |
+
* ``gmail`` β Gmail SMTP. Delivers to ANY recipient with just a Google
|
| 5 |
+
App Password; no domain verification needed.
|
| 6 |
+
* ``resend`` β Resend's transactional HTTP API.
|
| 7 |
+
Unset/unknown ``EMAIL_BACKEND`` auto-detects: prefer Gmail if its creds are
|
| 8 |
+
present, else Resend, else falls back to the in-memory outbox only β so
|
| 9 |
+
demos / CI keep working without any delivery credentials.
|
| 10 |
|
| 11 |
The outbox is ALWAYS written so the dashboard's Mailbox Out panel reflects
|
| 12 |
reality regardless of delivery mode. Entries carry ``delivered`` and
|
| 13 |
``error`` fields so the UI can flag failed sends.
|
| 14 |
|
| 15 |
Environment variables:
|
| 16 |
+
EMAIL_BACKEND ``gmail`` or ``resend``. Unset β auto-detect (see above).
|
| 17 |
+
GMAIL_ADDRESS The Gmail / Google Workspace address to send from.
|
| 18 |
+
GMAIL_APP_PASSWORD
|
| 19 |
+
A Google App Password (16 chars; requires 2-Step
|
| 20 |
+
Verification enabled). Delivers to ANY recipient β no
|
| 21 |
+
domain to verify. https://myaccount.google.com/apppasswords
|
| 22 |
+
GMAIL_FROM_NAME Display name for the sender (default "Reachy Reception").
|
| 23 |
RESEND_API_KEY Resend API key (``re_...``). Get one free at
|
| 24 |
+
https://resend.com/api-keys.
|
| 25 |
+
RESEND_FROM Resend sender address. Defaults to ``onboarding@resend.dev``
|
|
|
|
|
|
|
|
|
|
| 26 |
(Resend's sandbox sender; this only delivers to the
|
| 27 |
email address registered on your Resend account).
|
| 28 |
For production, verify a domain at
|
|
|
|
| 31 |
"""
|
| 32 |
from __future__ import annotations
|
| 33 |
|
|
|
|
| 34 |
import os
|
| 35 |
import time
|
| 36 |
+
import asyncio
|
| 37 |
+
import logging
|
| 38 |
from typing import Any, Dict, List, Optional
|
| 39 |
|
| 40 |
import httpx
|
|
|
|
| 89 |
return entry
|
| 90 |
|
| 91 |
|
| 92 |
+
# ---------------------------------------------------------------------------
|
| 93 |
+
# Delivery backends β each returns (delivered, error, provider_message_id)
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
async def _send_via_resend(
|
| 96 |
+
to: str, subject: str, body: str
|
| 97 |
+
) -> tuple[bool, Optional[str], Optional[str]]:
|
| 98 |
+
"""Deliver via Resend's HTTP API. Assumes RESEND_API_KEY is set."""
|
| 99 |
+
api_key = os.getenv("RESEND_API_KEY")
|
| 100 |
+
sender = os.getenv("RESEND_FROM", "onboarding@resend.dev")
|
| 101 |
+
payload = {"from": sender, "to": [to], "subject": subject, "text": body}
|
| 102 |
+
try:
|
| 103 |
+
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SECONDS) as client:
|
| 104 |
+
resp = await client.post(
|
| 105 |
+
_RESEND_API_URL,
|
| 106 |
+
headers={
|
| 107 |
+
"Authorization": f"Bearer {api_key}",
|
| 108 |
+
"Content-Type": "application/json",
|
| 109 |
+
},
|
| 110 |
+
json=payload,
|
| 111 |
+
)
|
| 112 |
+
except Exception as e:
|
| 113 |
+
return False, f"{type(e).__name__}: {e}", None
|
| 114 |
+
if resp.status_code >= 400:
|
| 115 |
+
try:
|
| 116 |
+
parsed = resp.json()
|
| 117 |
+
err_msg = parsed.get("message") or parsed.get("name") or resp.text
|
| 118 |
+
except Exception:
|
| 119 |
+
err_msg = resp.text
|
| 120 |
+
return False, f"HTTP {resp.status_code}: {err_msg}", None
|
| 121 |
+
try:
|
| 122 |
+
return True, None, resp.json().get("id")
|
| 123 |
+
except Exception:
|
| 124 |
+
return True, None, None
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def _send_via_gmail(
|
| 128 |
+
to: str, subject: str, body: str
|
| 129 |
+
) -> tuple[bool, Optional[str], Optional[str]]:
|
| 130 |
+
"""Deliver via Gmail SMTP.
|
| 131 |
+
|
| 132 |
+
Runs the blocking smtplib call off the event loop with asyncio.to_thread
|
| 133 |
+
so the realtime audio pipeline is never stalled while a mail is in flight.
|
| 134 |
+
"""
|
| 135 |
+
return await asyncio.to_thread(_send_via_gmail_sync, to, subject, body)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _send_via_gmail_sync(
|
| 139 |
+
to: str, subject: str, body: str
|
| 140 |
+
) -> tuple[bool, Optional[str], Optional[str]]:
|
| 141 |
+
import ssl
|
| 142 |
+
import smtplib
|
| 143 |
+
from email.message import EmailMessage
|
| 144 |
+
|
| 145 |
+
address = (os.getenv("GMAIL_ADDRESS") or "").strip()
|
| 146 |
+
# App passwords are displayed grouped as "abcd efgh ijkl mnop" β tolerate
|
| 147 |
+
# the spaces so a pasted value still authenticates.
|
| 148 |
+
app_password = (os.getenv("GMAIL_APP_PASSWORD") or "").replace(" ", "").strip()
|
| 149 |
+
from_name = (os.getenv("GMAIL_FROM_NAME") or "Reachy Reception").strip()
|
| 150 |
+
if not address or not app_password:
|
| 151 |
+
return False, "GMAIL_ADDRESS / GMAIL_APP_PASSWORD not set", None
|
| 152 |
+
|
| 153 |
+
msg = EmailMessage()
|
| 154 |
+
msg["From"] = f"{from_name} <{address}>" if from_name else address
|
| 155 |
+
msg["To"] = to
|
| 156 |
+
msg["Subject"] = subject
|
| 157 |
+
msg.set_content(body)
|
| 158 |
+
|
| 159 |
+
try:
|
| 160 |
+
context = ssl.create_default_context()
|
| 161 |
+
with smtplib.SMTP_SSL(
|
| 162 |
+
"smtp.gmail.com", 465, timeout=_HTTP_TIMEOUT_SECONDS, context=context
|
| 163 |
+
) as server:
|
| 164 |
+
server.login(address, app_password)
|
| 165 |
+
server.send_message(msg)
|
| 166 |
+
except smtplib.SMTPAuthenticationError as e:
|
| 167 |
+
return (
|
| 168 |
+
False,
|
| 169 |
+
"Gmail auth failed β check GMAIL_ADDRESS and the 16-char "
|
| 170 |
+
f"GMAIL_APP_PASSWORD (2-Step Verification must be on): {e}",
|
| 171 |
+
None,
|
| 172 |
+
)
|
| 173 |
+
except Exception as e:
|
| 174 |
+
return False, f"{type(e).__name__}: {e}", None
|
| 175 |
+
return True, None, msg.get("Message-ID")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
# ---------------------------------------------------------------------------
|
| 179 |
# Tool class
|
| 180 |
# ---------------------------------------------------------------------------
|
|
|
|
| 388 |
except Exception as e:
|
| 389 |
logger.debug("release_email_claim failed: %s", e)
|
| 390 |
|
| 391 |
+
# ---- Delivery backend selection ---------------------------------
|
| 392 |
+
# EMAIL_BACKEND=gmail | resend. Unset/unknown -> auto-detect: prefer
|
| 393 |
+
# Gmail (delivers to ANY recipient with just an app password β no
|
| 394 |
+
# domain verification), then Resend, else outbox-only dry run.
|
| 395 |
+
# Existing Resend-only deployments keep working unchanged.
|
| 396 |
+
backend = (os.getenv("EMAIL_BACKEND") or "").strip().lower()
|
| 397 |
+
gmail_ready = bool(
|
| 398 |
+
(os.getenv("GMAIL_ADDRESS") or "").strip()
|
| 399 |
+
and (os.getenv("GMAIL_APP_PASSWORD") or "").strip()
|
| 400 |
+
)
|
| 401 |
+
resend_ready = bool((os.getenv("RESEND_API_KEY") or "").strip())
|
| 402 |
+
if backend not in ("gmail", "resend"):
|
| 403 |
+
backend = "gmail" if gmail_ready else ("resend" if resend_ready else "")
|
| 404 |
+
|
| 405 |
+
# No usable credentials for the chosen backend -> outbox-only dry run.
|
| 406 |
+
# (Do NOT release the email claim β this is treated as a successful
|
| 407 |
+
# record, so the dedupe guard must still hold for the session.)
|
| 408 |
+
if (
|
| 409 |
+
backend == ""
|
| 410 |
+
or (backend == "gmail" and not gmail_ready)
|
| 411 |
+
or (backend == "resend" and not resend_ready)
|
| 412 |
+
):
|
| 413 |
entry = _record_outbox(to, subject, body, delivered=False, error=None)
|
| 414 |
logger.info(
|
| 415 |
+
"[EMAIL DRY-RUN β %s] %s (no email backend configured; outbox only)",
|
| 416 |
to, subject,
|
| 417 |
)
|
| 418 |
return {
|
| 419 |
"success": True,
|
| 420 |
+
"message": (
|
| 421 |
+
f"Email recorded for {to} (no email backend configured β "
|
| 422 |
+
f"outbox only)"
|
| 423 |
+
),
|
| 424 |
"timestamp": entry["timestamp"],
|
| 425 |
"delivered": False,
|
| 426 |
}
|
| 427 |
|
| 428 |
+
# ---- Deliver via the selected backend ---------------------------
|
| 429 |
+
if backend == "gmail":
|
| 430 |
+
delivered, err, message_id = await _send_via_gmail(to, subject, body)
|
| 431 |
+
else:
|
| 432 |
+
delivered, err, message_id = await _send_via_resend(to, subject, body)
|
|
|
|
|
|
|
| 433 |
|
| 434 |
+
if not delivered:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
entry = _record_outbox(to, subject, body, delivered=False, error=err)
|
| 436 |
logger.warning("[EMAIL FAILED β %s] %s | %s", to, subject, err)
|
| 437 |
_release_claim()
|
|
|
|
| 442 |
"delivered": False,
|
| 443 |
}
|
| 444 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
entry = _record_outbox(
|
| 446 |
to, subject, body,
|
| 447 |
delivered=True, error=None,
|
| 448 |
provider_message_id=message_id,
|
| 449 |
)
|
| 450 |
+
logger.info("[EMAIL β %s] %s (%s id=%s)", to, subject, backend, message_id)
|
| 451 |
return {
|
| 452 |
"success": True,
|
| 453 |
"message": f"Email sent to {to}",
|