mukul-chauhan-methdai Claude Opus 4.8 commited on
Commit
a63a1b7
Β·
1 Parent(s): 55dc8b5

Add Gmail SMTP email backend

Browse files

send_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
- Sends an email via Resend's transactional email API. Falls back to the
4
- in-memory outbox when ``RESEND_API_KEY`` is unset, so demos / CI keep
5
- working without delivery credentials.
 
 
 
 
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. Without this set, the
14
- tool writes to the outbox only and returns
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
- api_key = os.getenv("RESEND_API_KEY")
297
- if not api_key:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  entry = _record_outbox(to, subject, body, delivered=False, error=None)
299
  logger.info(
300
- "[EMAIL DRY-RUN β†’ %s] %s (RESEND_API_KEY unset; outbox only)",
301
  to, subject,
302
  )
303
  return {
304
  "success": True,
305
- "message": f"Email recorded for {to} (Resend not configured β€” outbox only)",
 
 
 
306
  "timestamp": entry["timestamp"],
307
  "delivered": False,
308
  }
309
 
310
- sender = os.getenv("RESEND_FROM", "onboarding@resend.dev")
311
- payload = {
312
- "from": sender,
313
- "to": [to],
314
- "subject": subject,
315
- "text": body,
316
- }
317
 
318
- try:
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 (resend id=%s)", to, subject, message_id)
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}",