47dfd110a0
The box's existing service-account domain-wide-delegation grant already includes gmail.compose, which authorizes users.messages.send — verified 2026-06-15 by a token-mint probe and a live messages.send to grant. So CRM-originated mail can send through the account that already powers email capture: no SMTP account, no app password, no admin change. - backend/email_integration/gmail_send.py: send_via_gmail() impersonates a domain user and POSTs users.messages.send (reuses credentials.py + the compose scope; mirrors compose.py's REST pattern). - backend/digest_mailer.py: send_digest() prefers Gmail DWD when enabled, falls back to smtp_send otherwise. Sender = CRM_DIGEST_SENDER else first active admin. - server.py: the admin test endpoint now routes through digest_mailer (so the Settings button sends via DWD on the box with zero SMTP config). Recipient restriction to the admin set and no-leak error handling preserved. - test_gmail_send.py: build/send + transport routing (provider + urlopen faked). 19/19 backend green; s9pk typechecks. SMTP (v75) stays as the fallback transport. Send-path decision + scope finding recorded in ROADMAP.md and AGENTS.md.
66 lines
2.7 KiB
Python
66 lines
2.7 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)")
|
|
|
|
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 (HTTP {e.code}): {detail}")
|
|
return {"sent_to": to_addrs, "from": sender_email, "message_id": result.get("id")}
|