Add Gmail-DWD send path for the digest mailer (v0.1.0:76)
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.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""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")}
|
||||
Reference in New Issue
Block a user