Files
Keysat fee037a630 Apply review polish to the digest send path (post-v0.1.0:76)
Non-blocking items from the v76 reviewer pass. No redeploy needed — the box runs
v76 and its happy path is unaffected; these ride the next build:

- digest_mailer.send_digest: when Gmail is enabled but no sender resolves
  (CRM_DIGEST_SENDER unset and no admin email), raise NoTransport so the caller
  returns a clear 400 instead of a generic 502.
- gmail_send.send_via_gmail: wrap OSError/URLError (timeout/DNS) as a RuntimeError
  ("Gmail API unreachable: ...") to match the HTTPError handling; include the
  sender in the HTTPError message for debuggability.
- credentials.py: correct the now-stale GMAIL_COMPOSE_SCOPE comment (the digest
  mailer sends with this scope; only outreach drafts never send).
- test_gmail_send.py: add the HTTPError->RuntimeError branch, default_sender DB
  fallback (+None case + env override), and the send_digest SMTP-tag path.

19/19 backend tests green.
2026-06-15 20:37:49 -05:00

70 lines
3.0 KiB
Python

"""Send an email via the Gmail API using the same domain-wide delegation that
powers capture and draft creation.
The DWD grant on this deployment includes the `gmail.compose` scope (verified
2026-06-15: token mint + a live messages.send both succeed), and `gmail.compose`
authorizes `users.messages.send`. So CRM-originated mail (the daily digest) can
send through the existing service account — no SMTP account, no app password, no
admin change. Sends impersonating `sender_email`, which must be a Workspace user
in the delegated domain. Mirrors the REST pattern in compose.py; stdlib only.
"""
import base64
import email.message
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from . import config as _cfg
from . import credentials as _creds
def gmail_available():
"""True when DWD send is usable: integration enabled, DWD auth, key present."""
cfg = _cfg.CONFIG
if not cfg.enabled or cfg.primary_auth != "dwd":
return False
return bool(cfg.dwd_key_path) and os.path.exists(cfg.dwd_key_path)
def _build_raw(from_addr, to_addrs, subject, body):
msg = email.message.EmailMessage()
msg["From"] = from_addr
msg["To"] = ", ".join(to_addrs)
msg["Subject"] = subject or "(no subject)"
msg.set_content(body or "")
return base64.urlsafe_b64encode(msg.as_bytes()).decode("ascii")
def send_via_gmail(sender_email, to_addrs, subject, body, conn=None):
"""Send one message as `sender_email` to `to_addrs` via the Gmail API (DWD).
Returns {'sent_to', 'from', 'message_id'}; raises on failure."""
if isinstance(to_addrs, str):
to_addrs = [to_addrs]
to_addrs = [a for a in (str(x).strip() for x in to_addrs) if a]
if not to_addrs:
raise ValueError("no recipients")
if not sender_email:
raise ValueError("no sender_email (DWD impersonation needs a domain user)")
# conn is only consulted by the OAuth provider path; the DWD provider (the one
# used here) reads the service-account key from disk and ignores it.
provider = _creds.build_provider(lambda: conn)
token = provider.access_token_for(sender_email, _creds.GMAIL_COMPOSE_SCOPE).token
raw = _build_raw(sender_email, to_addrs, subject, body)
url = ("https://gmail.googleapis.com/gmail/v1/users/"
f"{urllib.parse.quote(sender_email)}/messages/send")
req = urllib.request.Request(
url, data=json.dumps({"raw": raw}).encode("utf-8"), method="POST",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=20) as resp:
result = json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")[:300]
raise RuntimeError(f"Gmail API send failed for {sender_email} (HTTP {e.code}): {detail}")
except OSError as e: # URLError/timeout/DNS (URLError subclasses OSError)
raise RuntimeError(f"Gmail API unreachable: {e}")
return {"sent_to": to_addrs, "from": sender_email, "message_id": result.get("id")}