odysseus-dev/odysseus · error · RuntimeError

Google OAuth token unavailable — reconnect the account in Se

Error message

Google OAuth token unavailable — reconnect the account in Settings → Integrations

What it means

RuntimeError raised during IMAP AUTHENTICATE when oauth_provider=='google' but _get_valid_google_token yields no token — stored access token absent/expired and the refresh attempt failed. The surrounding except shuts the socket down before propagating so failed auths don't leak descriptors.

Source

Thrown at routes/email_helpers.py:1234

        )
    # Connection mode:
    #   STARTTLS on → plain + upgrade
    #   STARTTLS off + port 993 → implicit SSL (IMAPS)
    #   STARTTLS off + any other port → plain (local Dovecot, custom ports)
    # The last branch is critical: previously this fell into IMAP4_SSL
    # for any non-STARTTLS port, which would fail the TLS handshake on
    # plain local servers (Dovecot on 31143, etc.).
    conn = _open_imap_connection(
        cfg["imap_host"],
        cfg["imap_port"],
        starttls=bool(cfg.get("imap_starttls")),
        timeout=timeout,
    )
    try:
        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 in Settings → Integrations")
            conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(cfg["imap_user"], token))
        else:
            conn.login(cfg["imap_user"], cfg["imap_password"])
    except Exception:
        # A failed AUTHENTICATE (e.g. an Office 365 app password on an
        # MFA-enabled tenant, #3174, or an expired/revoked OAuth token)
        # otherwise orphans the already-connected socket; close it before
        # propagating so a misconfigured account can't leak one descriptor
        # per retry / background poller pass.
        try:
            conn.shutdown()
        except Exception:
            pass
        raise
    return conn


from contextlib import contextmanager

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Reconnect the Google account via Settings → Integrations to obtain a fresh refresh token.
  2. If the account should use password auth instead, clear oauth_provider so conn.login() is used.
  3. Disable background polling for accounts whose OAuth creds are known-bad until reconnected.

Example fix

# before: poller crashes each pass
with _imap(account_id, owner=owner) as conn: inbox_poll(conn)
# after: skip unrecoverable auth failures
try:
    with _imap(account_id, owner=owner) as conn: inbox_poll(conn)
except RuntimeError as e:
    if 'OAuth token unavailable' in str(e):
        mark_account_needs_reconnect(account_id)
Defensive patterns

Strategy: try-catch

Try / catch

try:
    with _imap(account_id, owner=owner) as conn: poll(conn)
except RuntimeError as e:
    if 'OAuth token unavailable' in str(e):
        disable_polling(account_id); notify_reconnect_needed(account_id)
    else: raise

Prevention

When it happens

Trigger: Polling or reading an inbox of a Google OAuth account whose refresh token was revoked or expired, whose token row was deleted, or whose secret-storage decryption fails; any background inbox poll after the user disconnected Google in Settings.

Common situations: Google revoking refresh tokens on inactive OAuth apps in testing mode; secret storage key rotated between deployments; user removed app access from their Google account but the local account row still says google.

Related errors


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