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.
167 lines
5.5 KiB
Python
167 lines
5.5 KiB
Python
#!/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()
|