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,64 @@
|
||||
"""Transport selection for CRM-originated email (daily digest, admin test sends).
|
||||
|
||||
Prefers Gmail-over-DWD — it reuses the service account that already powers email
|
||||
capture (the grant includes gmail.compose, which can send), so there's no extra
|
||||
credential to manage — and falls back to SMTP (`smtp_send`) when DWD isn't
|
||||
available. One entry point so the digest and the admin test endpoint share the
|
||||
same routing. Stdlib only.
|
||||
"""
|
||||
import os
|
||||
|
||||
|
||||
class NoTransport(Exception):
|
||||
"""Neither Gmail DWD nor SMTP is configured."""
|
||||
|
||||
|
||||
def transport():
|
||||
"""Return the active transport: 'gmail-dwd', 'smtp', or None."""
|
||||
try:
|
||||
from email_integration import gmail_send
|
||||
if gmail_send.gmail_available():
|
||||
return "gmail-dwd"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import smtp_send
|
||||
if smtp_send.smtp_configured():
|
||||
return "smtp"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def default_sender(conn):
|
||||
"""Domain user to send as for the DWD path. `CRM_DIGEST_SENDER` if set, else
|
||||
the first active admin's email."""
|
||||
s = os.environ.get("CRM_DIGEST_SENDER", "").strip()
|
||||
if s:
|
||||
return s
|
||||
if conn is None:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT email FROM users WHERE role='admin' AND is_active=1 "
|
||||
"AND email IS NOT NULL AND TRIM(email)!='' ORDER BY created_at LIMIT 1"
|
||||
).fetchone()
|
||||
return row["email"].strip() if row and row["email"] else None
|
||||
|
||||
|
||||
def send_digest(conn, to_addrs, subject, body, sender_email=None):
|
||||
"""Send via the active transport. Returns the transport's result dict with a
|
||||
'transport' key added; raises NoTransport if neither is configured."""
|
||||
t = transport()
|
||||
if t == "gmail-dwd":
|
||||
from email_integration import gmail_send
|
||||
sender = sender_email or default_sender(conn)
|
||||
result = gmail_send.send_via_gmail(sender, to_addrs, subject, body, conn=conn)
|
||||
result["transport"] = "gmail-dwd"
|
||||
return result
|
||||
if t == "smtp":
|
||||
import smtp_send
|
||||
result = smtp_send.send_email(to_addrs, subject, body)
|
||||
result["transport"] = "smtp"
|
||||
return result
|
||||
raise NoTransport("No email transport configured: enable Gmail (DWD) or set "
|
||||
"SMTP via the 'Configure Digest SMTP' action.")
|
||||
@@ -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")}
|
||||
+40
-45
@@ -4237,63 +4237,58 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
})
|
||||
|
||||
def handle_admin_send_test_email(self, user, body):
|
||||
"""Send a test email through the configured digest SMTP account, to prove
|
||||
the outbound pipe before the daily digest itself is built (Phase A)."""
|
||||
"""Send a test email through the active transport (Gmail DWD preferred,
|
||||
SMTP fallback) to prove the outbound pipe before the daily digest (Phase B)."""
|
||||
if not require_admin(user):
|
||||
return self.send_error_json("Admin only", 403)
|
||||
|
||||
from smtp_send import send_email, smtp_configured, SMTPNotConfigured
|
||||
if not smtp_configured():
|
||||
return self.send_error_json(
|
||||
"SMTP is not configured. Set it via the 'Configure Digest SMTP' "
|
||||
"Start9 action (or SMTP_* in .env for dev), then restart.", 400)
|
||||
import digest_mailer
|
||||
|
||||
# Recipients are restricted to the active-admin set (the real digest
|
||||
# audience). An explicit `to` may NARROW to specific admins but can never
|
||||
# introduce an outside address — this endpoint is not an open mail relay.
|
||||
conn = get_db()
|
||||
try:
|
||||
# Recipients are restricted to the active-admin set (the real digest
|
||||
# audience). An explicit `to` may NARROW to specific admins but can never
|
||||
# introduce an outside address — this endpoint is not an open mail relay.
|
||||
rows = conn.execute(
|
||||
"SELECT email FROM users WHERE role = 'admin' AND is_active = 1 "
|
||||
"AND email IS NOT NULL AND TRIM(email) != ''"
|
||||
).fetchall()
|
||||
admin_emails = [str(r['email']).strip() for r in rows if str(r['email']).strip()]
|
||||
admin_lower = {e.lower() for e in admin_emails}
|
||||
|
||||
to = body.get('to')
|
||||
if to:
|
||||
requested = [to] if isinstance(to, str) else list(to)
|
||||
requested = [str(e).strip() for e in requested if str(e).strip()]
|
||||
outside = [e for e in requested if e.lower() not in admin_lower]
|
||||
if outside:
|
||||
return self.send_error_json(
|
||||
"Test email may only go to an active admin address; "
|
||||
f"not allowed: {', '.join(outside)}", 400)
|
||||
recipients = requested
|
||||
else:
|
||||
recipients = admin_emails
|
||||
if not recipients:
|
||||
return self.send_error_json(
|
||||
"No recipient: give an active admin an email address first.", 400)
|
||||
|
||||
subject = "Ten31 CRM — test digest email"
|
||||
email_body = (
|
||||
"This is a test message from the Ten31 CRM daily-digest mailer.\n\n"
|
||||
f"Triggered by {user.get('full_name') or user.get('user_id')} at {now()}.\n\n"
|
||||
"If you received this, outbound email works and the daily digest can "
|
||||
"be delivered to this address."
|
||||
)
|
||||
result = digest_mailer.send_digest(conn, recipients, subject, email_body)
|
||||
except digest_mailer.NoTransport as exc:
|
||||
return self.send_error_json(str(exc), 400)
|
||||
except Exception as exc:
|
||||
# Never echo the exception to the client — an auth error can carry a
|
||||
# credential or token. Log it server-side instead.
|
||||
print(f"[digest] test send failed: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
return self.send_error_json("Send failed — see server logs for details.", 502)
|
||||
finally:
|
||||
conn.close()
|
||||
admin_emails = [str(r['email']).strip() for r in rows if str(r['email']).strip()]
|
||||
admin_lower = {e.lower() for e in admin_emails}
|
||||
|
||||
to = body.get('to')
|
||||
if to:
|
||||
requested = [to] if isinstance(to, str) else list(to)
|
||||
requested = [str(e).strip() for e in requested if str(e).strip()]
|
||||
outside = [e for e in requested if e.lower() not in admin_lower]
|
||||
if outside:
|
||||
return self.send_error_json(
|
||||
"Test email may only go to an active admin address; "
|
||||
f"not allowed: {', '.join(outside)}", 400)
|
||||
recipients = requested
|
||||
else:
|
||||
recipients = admin_emails
|
||||
if not recipients:
|
||||
return self.send_error_json(
|
||||
"No recipient: give an active admin an email address first.", 400)
|
||||
|
||||
subject = "Ten31 CRM — test digest email"
|
||||
email_body = (
|
||||
"This is a test message from the Ten31 CRM daily-digest mailer.\n\n"
|
||||
f"Triggered by {user.get('full_name') or user.get('user_id')} at {now()}.\n\n"
|
||||
"If you received this, outbound SMTP works and the daily digest can be "
|
||||
"delivered to this address."
|
||||
)
|
||||
try:
|
||||
result = send_email(recipients, subject, email_body)
|
||||
except SMTPNotConfigured as exc:
|
||||
return self.send_error_json(f"SMTP not configured: {exc}", 400)
|
||||
except Exception as exc:
|
||||
# Never echo the exception to the client — an SMTP auth error can carry
|
||||
# the server's reply (and potentially the credential). Log it instead.
|
||||
print(f"[smtp] test send failed: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
return self.send_error_json("Send failed — see server logs for details.", 502)
|
||||
|
||||
return self.send_json({"data": {"status": "sent", **result}})
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for gmail_send (DWD Gmail-API send) + digest_mailer (transport routing).
|
||||
|
||||
No network: the credential provider and urllib.request.urlopen are monkeypatched.
|
||||
Run directly or via backend/run_tests.py.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from email_integration import gmail_send
|
||||
import digest_mailer
|
||||
import smtp_send
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" ok " if cond else " XX ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
class _Tok:
|
||||
def __init__(self, t):
|
||||
self.token = t
|
||||
|
||||
|
||||
class _Provider:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def access_token_for(self, email, scope):
|
||||
self.calls.append((email, scope))
|
||||
return _Tok("tok-" + email)
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, payload):
|
||||
self._p = payload
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self._p).encode()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
# 1. _build_raw round-trips
|
||||
raw = gmail_send._build_raw("a@x", ["b@x", "c@x"], "Subj", "Hello body")
|
||||
decoded = base64.urlsafe_b64decode(raw).decode()
|
||||
check("From: a@x" in decoded and "Subject: Subj" in decoded
|
||||
and "b@x, c@x" in decoded and "Hello body" in decoded,
|
||||
"_build_raw round-trips From/To/Subject/body")
|
||||
|
||||
# 2. send_via_gmail posts correctly (fake provider + fake urlopen)
|
||||
captured = {}
|
||||
orig_open = urllib.request.urlopen
|
||||
orig_build = gmail_send._creds.build_provider
|
||||
prov = _Provider()
|
||||
gmail_send._creds.build_provider = lambda factory: prov
|
||||
|
||||
def fake_open(req, timeout=None):
|
||||
captured["url"] = req.full_url
|
||||
captured["auth"] = req.headers.get("Authorization")
|
||||
captured["body"] = json.loads(req.data.decode())
|
||||
return _Resp({"id": "msg123", "threadId": "thr123"})
|
||||
|
||||
urllib.request.urlopen = fake_open
|
||||
try:
|
||||
res = gmail_send.send_via_gmail("grant@ten31.xyz",
|
||||
["a@ten31.xyz", "b@ten31.xyz"], "S", "B")
|
||||
finally:
|
||||
urllib.request.urlopen = orig_open
|
||||
gmail_send._creds.build_provider = orig_build
|
||||
|
||||
check(res["message_id"] == "msg123", "send returns message_id")
|
||||
check(res["from"] == "grant@ten31.xyz", "send reports from")
|
||||
check(res["sent_to"] == ["a@ten31.xyz", "b@ten31.xyz"], "send reports recipients")
|
||||
check(captured["url"].endswith("/users/grant%40ten31.xyz/messages/send"),
|
||||
"posts to messages/send for the impersonated user")
|
||||
check(captured["auth"] == "Bearer tok-grant@ten31.xyz", "uses the compose-scoped token")
|
||||
check("raw" in captured["body"] and "message" not in captured["body"],
|
||||
"send body is {raw:...} (not nested under message, unlike drafts)")
|
||||
check(prov.calls and prov.calls[0][1] == gmail_send._creds.GMAIL_COMPOSE_SCOPE,
|
||||
"requests the gmail.compose scope")
|
||||
|
||||
# 3. validation
|
||||
try:
|
||||
gmail_send.send_via_gmail("x@x", [], "s", "b"); ok = False
|
||||
except ValueError:
|
||||
ok = True
|
||||
check(ok, "empty recipients -> ValueError")
|
||||
try:
|
||||
gmail_send.send_via_gmail("", ["a@x"], "s", "b"); ok = False
|
||||
except ValueError:
|
||||
ok = True
|
||||
check(ok, "missing sender -> ValueError")
|
||||
|
||||
# 4. transport() selection
|
||||
orig_avail = gmail_send.gmail_available
|
||||
orig_smtp_cfg = smtp_send.smtp_configured
|
||||
try:
|
||||
gmail_send.gmail_available = lambda: True
|
||||
check(digest_mailer.transport() == "gmail-dwd", "transport prefers gmail-dwd")
|
||||
gmail_send.gmail_available = lambda: False
|
||||
smtp_send.smtp_configured = lambda: True
|
||||
check(digest_mailer.transport() == "smtp", "transport falls back to smtp")
|
||||
smtp_send.smtp_configured = lambda: False
|
||||
check(digest_mailer.transport() is None, "transport None when neither configured")
|
||||
finally:
|
||||
gmail_send.gmail_available = orig_avail
|
||||
smtp_send.smtp_configured = orig_smtp_cfg
|
||||
|
||||
# 5. send_digest routes to gmail + honors CRM_DIGEST_SENDER
|
||||
orig_send = gmail_send.send_via_gmail
|
||||
sent = {}
|
||||
try:
|
||||
gmail_send.gmail_available = lambda: True
|
||||
|
||||
def fake_send(sender, to, subj, body, conn=None):
|
||||
sent.update(sender=sender, to=to)
|
||||
return {"sent_to": to, "from": sender, "message_id": "m"}
|
||||
|
||||
gmail_send.send_via_gmail = fake_send
|
||||
os.environ["CRM_DIGEST_SENDER"] = "digest@ten31.xyz"
|
||||
res = digest_mailer.send_digest(None, ["a@ten31.xyz"], "S", "B")
|
||||
check(res["transport"] == "gmail-dwd", "send_digest tags transport gmail-dwd")
|
||||
check(sent["sender"] == "digest@ten31.xyz", "send_digest uses CRM_DIGEST_SENDER")
|
||||
finally:
|
||||
gmail_send.send_via_gmail = orig_send
|
||||
gmail_send.gmail_available = orig_avail
|
||||
os.environ.pop("CRM_DIGEST_SENDER", None)
|
||||
|
||||
# 6. send_digest raises NoTransport when neither is available
|
||||
try:
|
||||
gmail_send.gmail_available = lambda: False
|
||||
smtp_send.smtp_configured = lambda: False
|
||||
try:
|
||||
digest_mailer.send_digest(None, ["a@x"], "S", "B"); ok = False
|
||||
except digest_mailer.NoTransport:
|
||||
ok = True
|
||||
check(ok, "send_digest raises NoTransport when no transport")
|
||||
finally:
|
||||
gmail_send.gmail_available = orig_avail
|
||||
smtp_send.smtp_configured = orig_smtp_cfg
|
||||
|
||||
print()
|
||||
if FAILS:
|
||||
print(f"FAILED ({len(FAILS)}):")
|
||||
for f in FAILS:
|
||||
print(" -", f)
|
||||
raise SystemExit(1)
|
||||
print("ALL PASS (gmail_send + digest_mailer)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user