odysseus-dev/odysseus · error · RuntimeError

Google OAuth token unavailable — reconnect the account

Error message

Google OAuth token unavailable — reconnect the account

What it means

RuntimeError raised inside the SMTP auth path of _send_smtp_message when the account uses oauth_provider=='google' but _get_valid_google_token returns falsy — i.e. no stored access token, no usable expiry, and a failed refresh via _refresh_google_token (revoked, expired refresh token, or never-stored credentials).

Source

Thrown at routes/email_helpers.py:174

        return raw
    port = int(cfg.get("smtp_port") or 465)
    if port == 587:
        return "starttls"
    return "ssl"


def _send_smtp_message(cfg: dict, from_addr: str, recipients: list[str], message: str | bytes, timeout: int = 30) -> None:
    """Send through SMTP using the configured transport security mode."""
    host = cfg["smtp_host"]
    port = int(cfg.get("smtp_port") or 465)
    user = cfg.get("smtp_user") or ""
    password = cfg.get("smtp_password") or ""

    def _auth_smtp(smtp):
        if cfg.get("oauth_provider") == "google":
            token = _get_valid_google_token(cfg.get("account_id"), cfg)
            if not token:
                raise RuntimeError("Google OAuth token unavailable — reconnect the account")
            smtp.ehlo()
            smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(user, token), initial_response_ok=True)
        elif user and password:
            smtp.login(user, password)

    security = _smtp_security_mode(cfg)

    if security == "ssl":
        with smtplib.SMTP_SSL(host, port, timeout=timeout) as smtp:
            _auth_smtp(smtp)
            smtp.sendmail(from_addr, recipients, message)
        return

    with smtplib.SMTP(host, port, timeout=timeout) as smtp:
        if security == "starttls":
            smtp.starttls()
        _auth_smtp(smtp)
        smtp.sendmail(from_addr, recipients, message)

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Reconnect the Google account in Settings → Integrations to mint a new refresh token.
  2. Verify the stored oauth refresh token decrypts and exists in secret storage for this account_id.
  3. If the account should use plain SMTP auth, clear oauth_provider on the EmailAccount row so the user/password branch runs.
  4. Re-run the OAuth flow with the gmail.send scope granted and ensure offline access is approved.

Example fix

# before
cfg['oauth_provider'] = 'google'  # but tokens revoked
# after: re-auth and persist fresh tokens
new_tokens = run_google_oauth_flow()  # scope gmail.send, prompt=consent
store_account_tokens(account_id, new_tokens)
Defensive patterns

Strategy: try-catch

Try / catch

try { send_smtp(cfg, msg) } except RuntimeError as e:
    if 'OAuth token unavailable' in str(e):
        prompt_reconnect_google(account_id)
    else: raise

Prevention

When it happens

Trigger: Sending mail through a Google OAuth account after the refresh token was revoked (password change, security checkup, app removed in Google Account), after the account was disconnected but oauth_provider still says 'google', or when decryption/refresh of stored tokens fails.

Common situations: Google revoked the long-lived refresh token due to 6-month inactivity in testing mode; token secret storage key rotated so decrypt returns empty; user reconnected SMTP password auth but the row kept oauth_provider='google'.

Related errors


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/9340f26402dc3a9e. Report an issue: GitHub.