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:
Keysat
2026-06-15 20:17:27 -05:00
parent e62306be27
commit 47dfd110a0
10 changed files with 383 additions and 56 deletions
+40 -45
View File
@@ -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}})