"""Gmail API email driver (Google Workspace domain-wide delegation). Sends transactional email through the Gmail API as a dedicated sender mailbox, authenticating with the SAME service account + domain-wide delegation (DWD) Helix already uses to provision Google Workspace accounts. No SMTP password, no SES — the service account JSON (already held, encrypted, by the provisioning integration) impersonates the ``impersonate_subject_email`` mailbox and calls ``users.messages.send``. Prerequisites (one-time, Google Admin → Security → API controls → Domain-wide delegation): the SA's OAuth client ID must be authorized for the ``https://www.googleapis.com/auth/gmail.send`` scope, and the ``impersonate_subject_email`` must be a REAL Workspace user (DWD can only impersonate existing users) — use a dedicated sender like ``it-notifications@``, never a person's account. Why this over SMTP: reuses the existing DWD trust (no new secret), sends from our own domain (SPF/DKIM already correct), and the scope is revocable independently. ``gmail.send`` only — never ``gmail.modify`` / full mailbox — so the SA can send but not read/alter anyone's mail. """ from __future__ import annotations import asyncio import base64 import json import logging from email.message import EmailMessage as _PyEmailMessage from email.utils import formataddr, make_msgid from google.auth.transport.requests import Request as _GoogleAuthRequest from google.oauth2 import service_account from googleapiclient.discovery import build from app.integrations.driver_base import ( EmailDriver, EmailMessage, EmailSendResult, ) from app.integrations.registry import register_driver from app.integrations.schemas import ( IntegrationFieldInfo, IntegrationTestResult, ) logger = logging.getLogger(__name__) # Least-privilege: SEND only. Never gmail.modify / gmail.readonly / full # mailbox — the SA must be able to send, not read or alter anyone's mail. _GMAIL_SEND_SCOPE = "https://www.googleapis.com/auth/gmail.send" def _build_gmail_service(sa_json: str, subject: str): info = json.loads(sa_json) creds = service_account.Credentials.from_service_account_info( info, scopes=[_GMAIL_SEND_SCOPE], subject=subject, ) return build("gmail", "v1", credentials=creds, cache_discovery=False) @register_driver class GmailApiEmailDriver(EmailDriver): provider = "google" display_name = "Gmail API (Workspace DWD)" description = ( "通过 Gmail API + 服务账号域级委托发件,复用现有 Google Workspace " "服务账号(无需 SMTP 密码)。需在 Google Admin 给该 SA 的 client ID " "授权 gmail.send scope,发件人须为真实 Workspace 用户。" ) config_schema = [ IntegrationFieldInfo( key="service_account_json", label="Service Account JSON", is_secret=True, required=True, ), IntegrationFieldInfo( key="impersonate_subject_email", label="发件人邮箱(被模拟的真实用户)", is_secret=False, required=True, placeholder="it-notifications@pingcap.com", ), IntegrationFieldInfo( key="from_name", label="默认发件人显示名", is_secret=False, required=False, placeholder="Helix IT", ), ] # ── config accessors ──────────────────────────────────────── def _sa_json(self) -> str: v = self.config.get("service_account_json") if not v: raise ValueError("service_account_json is not configured") return str(v) def _sender(self) -> str: v = self.config.get("impersonate_subject_email") if not v: raise ValueError("impersonate_subject_email is not configured") return str(v).strip() # ── IntegrationDriver / EmailDriver interface ──────────────── async def test_connection(self) -> IntegrationTestResult: """Validate SA key + DWD authorization for gmail.send on the sender WITHOUT sending anything — a token refresh fails (unauthorized_client) if the client ID hasn't been granted gmail.send for this subject.""" def _check() -> None: info = json.loads(self._sa_json()) creds = service_account.Credentials.from_service_account_info( info, scopes=[_GMAIL_SEND_SCOPE], subject=self._sender(), ) creds.refresh(_GoogleAuthRequest()) if not creds.valid: raise RuntimeError("credentials did not become valid after refresh") try: await asyncio.to_thread(_check) return IntegrationTestResult( ok=True, message=f"Gmail API 授权成功(发件人 {self._sender()})", ) except Exception as exc: # noqa: BLE001 — surface any auth failure return IntegrationTestResult(ok=False, message=str(exc)) async def send(self, msg: EmailMessage) -> EmailSendResult: try: message_id = await asyncio.to_thread(self._send_sync, msg) return EmailSendResult(ok=True, provider_message_id=message_id) except Exception as exc: # noqa: BLE001 — let the caller decide retry logger.warning("Gmail API send failed: %s", exc) return EmailSendResult(ok=False, error_message=str(exc)) # ── internals ─────────────────────────────────────────────── def _send_sync(self, msg: EmailMessage) -> str: # The email is sent AS the impersonated mailbox (DWD subject), so # the From header is that mailbox — NOT a caller-supplied override # (Gmail rejects send-as for an address the subject can't use). sender_addr = self._sender() sender_name = msg.from_name or self.config.get("from_name") # Header-injection guard (mirrors smtp_email): refuse CR/LF in any # value that lands in a header — subject / display name / recipients. for label, value in ( ("subject", msg.subject), ("from_name", sender_name), ("reply_to", msg.reply_to), ("message_id", msg.message_id), ): if value and any(c in value for c in ("\r", "\n")): raise ValueError( f"Gmail email header injection refused: {label} contains CR/LF" ) for label, addrs in (("to", msg.to), ("cc", msg.cc), ("bcc", msg.bcc)): for addr in (addrs or ()): if addr and any(c in addr for c in ("\r", "\n")): raise ValueError( f"Gmail email header injection refused: " f"{label} recipient contains CR/LF" ) py_msg = _PyEmailMessage() py_msg["From"] = ( formataddr((sender_name, sender_addr)) if sender_name else sender_addr ) py_msg["To"] = ", ".join(msg.to) if msg.cc: py_msg["Cc"] = ", ".join(msg.cc) if msg.bcc: py_msg["Bcc"] = ", ".join(msg.bcc) if msg.reply_to: py_msg["Reply-To"] = msg.reply_to py_msg["Subject"] = msg.subject message_id = msg.message_id or make_msgid() py_msg["Message-ID"] = message_id if msg.body_html and msg.body_text: py_msg.set_content(msg.body_text) py_msg.add_alternative(msg.body_html, subtype="html") elif msg.body_html: py_msg.set_content(_html_to_text_stub(msg.body_html)) py_msg.add_alternative(msg.body_html, subtype="html") else: py_msg.set_content(msg.body_text or "") raw = base64.urlsafe_b64encode(py_msg.as_bytes()).decode("ascii") service = _build_gmail_service(self._sa_json(), sender_addr) sent = service.users().messages().send( userId="me", body={"raw": raw}, ).execute() # Prefer the Gmail message id; fall back to our RFC Message-ID. return str(sent.get("id") or message_id) def _html_to_text_stub(html: str) -> str: """Naive HTML→text fallback when only an HTML body is supplied.""" import re text = re.sub(r"", "\n", html, flags=re.IGNORECASE) text = re.sub(r"", "\n\n", text, flags=re.IGNORECASE) text = re.sub(r"<[^>]+>", "", text) return text.strip()