return False def get_userinfo(access_token: str) -> dict: resp = requests.get(GOOGLE_USERINFO_URL, headers={"Authorization": f"Bearer {access_token}"}, timeout=15) if resp.status_code != 200: raise GmailOauthError(f"userinfo failed: {resp.status_code}") return resp.json() # --------------------------------------------------------------------------- # Gmail client construction # --------------------------------------------------------------------------- class GmailClient: """Authenticated Gmail client for one account (API or XOAUTH2 SMTP).""" def __init__(self, account_email: str, access_token: str, refresh_token: str, client_config: dict, sending_method: str = "api", token_expiry=None): self.account_email = account_email self.client_config = client_config self.method = sending_method if sending_method in ("api", "smtp") else "api" creds = gcred.Credentials( token=access_token, refresh_token=refresh_token or None, token_uri=GOOGLE_TOKEN_URL, client_id=client_config["client_id"], client_secret=client_config["client_secret"], scopes=GMAIL_SCOPES, ) self._creds = creds self._service = None self.token_expiry = token_expiry def _ensure_valid(self): request = google.auth.transport.requests.Request() if not self._creds.valid: self._creds.refresh(request) return self._creds.token def _api(self): if self._service is None: self._ensure_valid() self._service = build("gmail", "v1", credentials=self._creds, cache_discovery=False) else: self._ensure_valid() return self._service # -- API send ------------------------------------------------------------ def send_mime_api(self, mime_message) -> dict: """Send via Gmail API users.messages.send. Returns real provider response.""" service = self._api() raw = base64.urlsafe_b64encode(mime_message.as_bytes()).decode() body = {"raw": raw} sent = service.users().messages().send(userId="me", body=body).execute() return {"ok": True, "message_id": sent.get("id", ""), "thread_id": sent.get("threadId", ""), "label_ids": sent.get("labelIds", []), "raw_response": sent} # -- SMTP send (XOAUTH2, no password) ------------------------------------- def send_mime_smtp(self, mime_message) -> dict: import smtplib from email.utils import parseaddr self._ensure_valid() access_token = self._creds.token auth_string = f"user={self.account_email}\x01auth=Bearer {access_token}\x01\x01" with smtplib.SMTP("smtp.gmail.com", 587, timeout=30) as smtp: smtp.ehlo() smtp.starttls(context=__import__("ssl").create_default_context()) smtp.ehlo() code, response = smtp.docmd("AUTH", "XOAUTH2 " + base64.b64encode(auth_string.encode()).decode()) refused = smtp.sendmail(self.account_email, [parseaddr(mime_message["To"])[1]], mime_message.as_bytes()) if refused: return {"ok": False, "error": f"SMTP refused recipients: {refused}", "error_class": "permanent"} return {"ok": True, "message_id": "", "thread_id": "", "raw_response": f"250 SMTP accepted ({code} {response.decode(errors='replace')})"} # -- Health / profile ------------------------------------------------------ def get_profile(self) -> dict: service = self._api() prof = service.users().getProfile(userId="me").execute() return {"email_address": prof.get("emailAddress"), "history_id": prof.get("historyId"), "messages_total": prof.get("messagesTotal"), "threads_total": prof.get("threadsTotal")} def verify_connection(self) -> dict: try: prof = self.get_profile() return {"ok": True, "email_address": prof.get("email_address"), "detail": "Gmail API connection verified."} except Exception as e: return {"ok": False, "detail": str(e)[:400]} def build_gmail_client_from_account(account, oauth_token, client_config=None) -> GmailClient: """Build a GmailClient from DB rows (sync session context - worker uses sync ORM).""" cfg = client_config or resolve_client_config_for_account(account) return GmailClient( account_email=account.email, access_token=oauth_token.access_token, # decrypted refresh_token=oauth_token.refresh_token, # decrypted client_config=cfg, sending_method=account.sending_method, token_expiry=oauth_token.expires_at, ) def resolve_client_config_for_account(account) -> dict: """Resolve client config for an account: its linked config row, the owner's latest imported active config, or the server default (env file path).""" # Called from sync or async context; imports DB lazily. from app.db.session import SyncSession from app.models import OAuthClientConfig from sqlalchemy import select as _select if account.oauth_config_id: with SyncSession() as s: