mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
fix(email): test saved OAuth accounts through shared transports (#5653)
* fix(email): use XOAUTH2 in test-connection for Google OAuth accounts
The test-connection endpoint was password-only and had no awareness of
OAuth accounts. For Google-connected accounts this caused two failures:
- IMAP: "Need IMAP host, username, and password" because imap_pass is
empty (no password is stored for OAuth accounts)
- SMTP: 535 BadCredentials because smtp.login() was called with an
empty password instead of an XOAUTH2 token
Fix: include oauth_provider and token fields in saved_body when hydrating
from the DB, then use conn.authenticate("XOAUTH2") / smtp.auth("XOAUTH2")
for Google accounts in both the IMAP and SMTP test paths, mirroring what
_send_smtp_message already does for real sends.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(email): add OAuth2 tests for test-connection endpoint
Covers the XOAUTH2 changes made to routes/email_routes.py:
- Google OAuth accounts must not be rejected with 'Need IMAP host,
username, and password' (no stored password for OAuth accounts)
- IMAP and SMTP test paths must use conn.authenticate('XOAUTH2')
for Google accounts
- Password accounts must still use conn.login() / smtp.login()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(email): bind OAuth account tests to Google transport
---------
Co-authored-by: TNTBA <trynottobreakanything@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Sonnet 4.6
TNTBA
parent
dcc7e52a86
commit
65987fc772
+120
-12
@@ -20,6 +20,7 @@ import email as email_mod
|
||||
import email.header
|
||||
import email.utils
|
||||
import smtplib
|
||||
import ssl
|
||||
import json
|
||||
import re
|
||||
import html
|
||||
@@ -47,6 +48,7 @@ from routes.email_helpers import (
|
||||
_load_settings, _save_settings, _get_email_config,
|
||||
_send_smtp_message, _smtp_security_mode,
|
||||
_IMAP_TIMEOUT_SECONDS, _open_imap_connection,
|
||||
_get_valid_google_token, _xoauth2_bytes, _xoauth2_raw,
|
||||
make_oauth_state, verify_oauth_state,
|
||||
EmailNotConfiguredError,
|
||||
_imap_connect, _imap, _decode_header, _detect_sent_folder, _detect_drafts_folder,
|
||||
@@ -65,6 +67,27 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
ODYSSEUS_MAIL_ORIGIN = "odysseus-ui"
|
||||
EMAIL_READ_ATTACHMENT_VERSION = 2
|
||||
_GOOGLE_OAUTH_IMAP_HOST = "imap.gmail.com"
|
||||
_GOOGLE_OAUTH_SMTP_HOST = "smtp.gmail.com"
|
||||
_SERVER_OWNED_OAUTH_FIELDS = {
|
||||
"oauth_provider",
|
||||
"oauth_access_token",
|
||||
"oauth_refresh_token",
|
||||
"oauth_token_expiry",
|
||||
}
|
||||
|
||||
|
||||
def _normalized_mail_host(value) -> str:
|
||||
"""Normalize a mail hostname for exact provider-bound comparisons."""
|
||||
return str(value or "").strip().lower().rstrip(".")
|
||||
|
||||
|
||||
def _google_oauth_imap_transport_allowed(port: int, starttls: bool) -> bool:
|
||||
return (port == 993 and not starttls) or (port == 143 and starttls)
|
||||
|
||||
|
||||
def _google_oauth_smtp_transport_allowed(port: int, security: str) -> bool:
|
||||
return (port == 465 and security == "ssl") or (port == 587 and security == "starttls")
|
||||
|
||||
|
||||
def _safe_attachment_zip_name(name: str, fallback: str) -> str:
|
||||
@@ -5013,15 +5036,24 @@ def setup_email_routes():
|
||||
"smtp_security": _smtp_security_mode({"smtp_security": getattr(row, "smtp_security", ""), "smtp_port": row.smtp_port}),
|
||||
"smtp_user": row.smtp_user or "",
|
||||
"smtp_password": _decrypt(row.smtp_password or ""),
|
||||
"oauth_provider": row.oauth_provider or "",
|
||||
"oauth_access_token": row.oauth_access_token or "",
|
||||
"oauth_refresh_token": row.oauth_refresh_token or "",
|
||||
"oauth_token_expiry": row.oauth_token_expiry or "",
|
||||
"account_id": acc_id,
|
||||
}
|
||||
for key, value in body.items():
|
||||
if key == "account_id":
|
||||
if key == "account_id" or key in _SERVER_OWNED_OAUTH_FIELDS:
|
||||
continue
|
||||
if value not in (None, ""):
|
||||
saved_body[key] = value
|
||||
body = saved_body
|
||||
finally:
|
||||
db.close()
|
||||
else:
|
||||
# OAuth state belongs to a saved, owner-checked account. Do not let
|
||||
# inline test payloads select the OAuth branch or supply token data.
|
||||
body = {key: value for key, value in body.items() if key not in _SERVER_OWNED_OAUTH_FIELDS}
|
||||
|
||||
imap_result = {"ok": False}
|
||||
smtp_result = None
|
||||
@@ -5031,11 +5063,33 @@ def setup_email_routes():
|
||||
imap_user = (body.get("imap_user") or "").strip()
|
||||
imap_pass = body.get("imap_password") or ""
|
||||
imap_starttls = bool(body.get("imap_starttls"))
|
||||
oauth_provider = body.get("oauth_provider") or ""
|
||||
|
||||
google_token = None
|
||||
google_token_loaded = False
|
||||
google_ssl_context = (
|
||||
ssl.create_default_context()
|
||||
if oauth_provider == "google"
|
||||
else None
|
||||
)
|
||||
|
||||
def _google_token():
|
||||
nonlocal google_token, google_token_loaded
|
||||
if not google_token_loaded:
|
||||
google_token = _get_valid_google_token(body.get("account_id"), body)
|
||||
google_token_loaded = True
|
||||
if not google_token:
|
||||
raise RuntimeError("Google OAuth token unavailable — reconnect the account")
|
||||
return google_token
|
||||
|
||||
if imap_port_err:
|
||||
imap_result = {"ok": False, "error": imap_port_err}
|
||||
elif not (imap_host and imap_user and imap_pass):
|
||||
elif not (imap_host and imap_user and (imap_pass or oauth_provider == "google")):
|
||||
imap_result = {"ok": False, "error": "Need IMAP host, username, and password"}
|
||||
elif oauth_provider == "google" and _normalized_mail_host(imap_host) != _GOOGLE_OAUTH_IMAP_HOST:
|
||||
imap_result = {"ok": False, "error": "Google OAuth IMAP requires imap.gmail.com"}
|
||||
elif oauth_provider == "google" and not _google_oauth_imap_transport_allowed(imap_port, imap_starttls):
|
||||
imap_result = {"ok": False, "error": "Google OAuth IMAP requires TLS on port 993 or STARTTLS on port 143"}
|
||||
else:
|
||||
# Connection mode resolution:
|
||||
# STARTTLS on → plain IMAP4 + .starttls() (upgrade)
|
||||
@@ -5045,14 +5099,23 @@ def setup_email_routes():
|
||||
# port (Dovecot on 31143, etc.) would always fail the SSL
|
||||
# handshake because they're not actually wrapped in TLS.
|
||||
try:
|
||||
imap_kwargs = {
|
||||
"starttls": imap_starttls,
|
||||
"timeout": _IMAP_TIMEOUT_SECONDS,
|
||||
}
|
||||
if google_ssl_context:
|
||||
imap_kwargs["ssl_context"] = google_ssl_context
|
||||
conn = _open_imap_connection(
|
||||
imap_host,
|
||||
imap_port,
|
||||
starttls=imap_starttls,
|
||||
timeout=_IMAP_TIMEOUT_SECONDS,
|
||||
**imap_kwargs,
|
||||
)
|
||||
try:
|
||||
conn.login(imap_user, imap_pass)
|
||||
if oauth_provider == "google":
|
||||
token = _google_token()
|
||||
conn.authenticate("XOAUTH2", lambda x: _xoauth2_bytes(imap_user, token))
|
||||
else:
|
||||
conn.login(imap_user, imap_pass)
|
||||
imap_result = {"ok": True}
|
||||
finally:
|
||||
try: conn.logout()
|
||||
@@ -5064,25 +5127,70 @@ def setup_email_routes():
|
||||
smtp_port, smtp_port_err = _coerce_port(body.get("smtp_port"), 465)
|
||||
if smtp_host and smtp_port_err:
|
||||
smtp_result = {"ok": False, "error": smtp_port_err}
|
||||
elif oauth_provider == "google" and smtp_host and _normalized_mail_host(smtp_host) != _GOOGLE_OAUTH_SMTP_HOST:
|
||||
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires smtp.gmail.com"}
|
||||
elif (
|
||||
oauth_provider == "google"
|
||||
and smtp_host
|
||||
and not _google_oauth_smtp_transport_allowed(
|
||||
smtp_port,
|
||||
_smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port}),
|
||||
)
|
||||
):
|
||||
smtp_result = {"ok": False, "error": "Google OAuth SMTP requires TLS on port 465 or STARTTLS on port 587"}
|
||||
elif smtp_host:
|
||||
smtp_security = _smtp_security_mode({"smtp_security": body.get("smtp_security"), "smtp_port": smtp_port})
|
||||
smtp_user = (body.get("smtp_user") or imap_user).strip()
|
||||
smtp_pass = body.get("smtp_password") or imap_pass
|
||||
smtp = None
|
||||
try:
|
||||
if smtp_security == "ssl":
|
||||
smtp = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10)
|
||||
smtp_kwargs = (
|
||||
{"context": google_ssl_context}
|
||||
if google_ssl_context
|
||||
else {}
|
||||
)
|
||||
smtp = smtplib.SMTP_SSL(
|
||||
smtp_host,
|
||||
smtp_port,
|
||||
timeout=10,
|
||||
**smtp_kwargs,
|
||||
)
|
||||
else:
|
||||
smtp = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
|
||||
if smtp_security == "starttls":
|
||||
smtp.starttls()
|
||||
try:
|
||||
try:
|
||||
if google_ssl_context:
|
||||
smtp.starttls(context=google_ssl_context)
|
||||
else:
|
||||
smtp.starttls()
|
||||
except Exception:
|
||||
# STARTTLS failed before the auth cleanup block.
|
||||
# Close the still-open plaintext socket explicitly.
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
smtp = None
|
||||
raise
|
||||
if oauth_provider == "google":
|
||||
token = _google_token()
|
||||
smtp.ehlo()
|
||||
smtp.auth("XOAUTH2", lambda challenge=None: _xoauth2_raw(smtp_user, token), initial_response_ok=True)
|
||||
else:
|
||||
smtp.login(smtp_user, smtp_pass)
|
||||
smtp_result = {"ok": True}
|
||||
finally:
|
||||
try: smtp.quit()
|
||||
except Exception: pass
|
||||
smtp_result = {"ok": True}
|
||||
except Exception as e:
|
||||
smtp_result = {"ok": False, "error": _friendly_email_auth_error("SMTP", smtp_host, e)}
|
||||
finally:
|
||||
if smtp is not None:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": imap_result["ok"] and (smtp_result is None or smtp_result["ok"]),
|
||||
|
||||
Reference in New Issue
Block a user