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:
+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}})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user