gpt2api / services /register /pool_mail.py
jiayi.xie
feat(register): read outlook_pool OTP via pool relay, not direct IMAP
b3df5c1
Raw
History Blame Contribute Delete
11.5 kB
"""Account-pool mail backend for the registration engine.
The pool web service (rg-gpt ``pool/app.py``, e.g. ``http://1.94.147.46:8091``)
hands out **real** Outlook / Gmail mailboxes with an atomic lease and records the
outcome so a mailbox that already backed one ChatGPT signup is never re-used.
Unlike the temp-mail HTTP providers, the pool gives credentials rather than an
OTP-fetch API, so the OTP is read over IMAP:
- Outlook / Hotmail / Live (personal MSA): basic-auth IMAP and ROPC are both
disabled since Sept 2024, so we mint an OAuth2 access token from the
long-lived ``refresh_token`` (device-code bootstrapped in rg-gpt) and log in
with IMAP **XOAUTH2**.
- Gmail: plain IMAP LOGIN with a 2FA **app password**.
A base mailbox is shared by its ``+1..+5`` plus-address aliases, so messages are
filtered by exact ``To``/``Cc`` recipient to stay concurrency-safe.
This module is deliberately free of app-wide settings / on-disk pool logic (that
lives in the caller); it only holds the pool HTTP client, the MSA token helper,
the IMAP readers, and MIME parsing.
"""
from __future__ import annotations
import base64
import email as email_pkg
import html as html_lib
import imaplib
import re
import time
from datetime import datetime, timezone
from email.header import decode_header, make_header
from email.utils import getaddresses, parsedate_to_datetime
from typing import Any
import requests
# Thunderbird's registered public client — personal MSA accounts accept it for
# the IMAP scope. No client secret (public client).
THUNDERBIRD_CLIENT_ID = "9e5f94bc-e8a4-4e73-b8be-63364c29d753"
# Personal accounts live under /consumers; /common and /organizations reject them.
TOKEN_ENDPOINT = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
IMAP_SCOPE = "https://outlook.office.com/IMAP.AccessAsUser.All offline_access"
IMAP_HOST = "outlook.office365.com"
IMAP_PORT = 993
# Folders an OpenAI OTP mail can land in on a fresh mailbox.
IMAP_FOLDERS = ("INBOX", "Junk")
GMAIL_IMAP_HOST = "imap.gmail.com"
GMAIL_FOLDERS = ("INBOX", "[Gmail]/Spam")
GMAIL_DOMAINS = ("gmail.com", "googlemail.com")
ACCESS_TOKEN_SKEW_SECONDS = 60 # refresh a bit before actual expiry
# imaplib.IMAP4_SSL has NO default timeout: without this a blocked TCP connect (e.g. an
# egress that can't reach office365:993) hangs the register thread forever ("卡住"). Bound it
# so a dead route fails fast and the OTP wait loop can retry / time out cleanly.
IMAP_CONNECT_TIMEOUT = 20
class OutlookAuthError(RuntimeError):
"""Raised when an MSA token refresh or an IMAP login cannot complete."""
# ---- pool HTTP client ---------------------------------------------------------
class PoolClient:
"""Thin client for the pool web service (rg-gpt pool/app.py)."""
def __init__(self, base_url: str, api_key: str, timeout: int = 30) -> None:
self.base = str(base_url or "").rstrip("/")
self.key = str(api_key or "")
self.timeout = timeout
self._s = requests.Session()
self._s.trust_env = False # never route the pool call through an OpenAI proxy
def _headers(self) -> dict[str, str]:
return {"X-API-Key": self.key, "Content-Type": "application/json"}
def lease(self, count: int = 1, leased_by: str = "", kind: str = "") -> list[dict[str, Any]]:
resp = self._s.post(
f"{self.base}/api/lease", headers=self._headers(),
json={"count": count, "leased_by": leased_by, "kind": kind}, timeout=self.timeout,
)
resp.raise_for_status()
return resp.json().get("leased") or []
def available(self, kind: str = "") -> int:
resp = self._s.get(
f"{self.base}/api/available", headers=self._headers(),
params={"kind": kind}, timeout=self.timeout,
)
resp.raise_for_status()
return int(resp.json().get("available") or 0)
def report(self, acct_id: int, status: str, **fields: Any) -> dict[str, Any]:
resp = self._s.post(
f"{self.base}/api/accounts/{acct_id}/result", headers=self._headers(),
json={"status": status, **fields}, timeout=self.timeout,
)
resp.raise_for_status()
return resp.json()
def fetch_otp(self, acct_id: int, lease_token: str = "", since: float = 0.0, exclude: str = "") -> str | None:
"""Ask the pool to read the mailbox server-side (its China egress can reach office365,
unlike this consumer) and return the newest matching OpenAI/ChatGPT OTP, or None."""
resp = self._s.get(
f"{self.base}/api/accounts/{acct_id}/otp", headers=self._headers(),
params={"lease_token": lease_token, "since": since, "exclude": exclude}, timeout=self.timeout,
)
resp.raise_for_status()
code = resp.json().get("code")
return str(code) if code else None
# ---- MSA token ----------------------------------------------------------------
def refresh_access_token(refresh_token: str, *, client_id: str = THUNDERBIRD_CLIENT_ID) -> dict[str, Any]:
resp = requests.post(
TOKEN_ENDPOINT,
data={"grant_type": "refresh_token", "client_id": client_id,
"refresh_token": refresh_token, "scope": IMAP_SCOPE},
timeout=30,
)
data = resp.json()
if "access_token" not in data:
raise OutlookAuthError(f"refresh failed: {data.get('error')} {data.get('error_description')}")
return data
# ---- IMAP readers -------------------------------------------------------------
def _xoauth2_bytes(email: str, access_token: str) -> bytes:
return f"user={email}\x01auth=Bearer {access_token}\x01\x01".encode()
def _imap_collect(conn: imaplib.IMAP4_SSL, folders: tuple[str, ...], per_folder: int) -> list[dict[str, Any]]:
"""After the connection is authenticated, collect recent messages (newest first)."""
out: list[dict[str, Any]] = []
for folder in folders:
typ, _ = conn.select(folder, readonly=True)
if typ != "OK":
continue
typ, data = conn.search(None, "ALL")
if typ != "OK" or not data or not data[0]:
continue
ids = data[0].split()
for msg_id in reversed(ids[-per_folder:]):
typ, msg_data = conn.fetch(msg_id, "(RFC822)")
if typ != "OK" or not msg_data or not msg_data[0]:
continue
msg = email_pkg.message_from_bytes(msg_data[0][1])
subject, text_body, html_body, from_addr = _parse_message(msg)
out.append({
"folder": folder, "subject": subject, "text": text_body, "html": html_body,
"sender": from_addr, "recipients": _msg_recipients(msg),
"received_at": _parse_dt(msg.get("Date")),
})
out.sort(key=lambda m: m["received_at"] or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
return out
def imap_read_messages(email: str, access_token: str, *, per_folder: int = 20) -> list[dict[str, Any]]:
"""Outlook: recent messages across INBOX + Junk via XOAUTH2."""
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, timeout=IMAP_CONNECT_TIMEOUT)
try:
try:
conn.authenticate("XOAUTH2", lambda _challenge: _xoauth2_bytes(email, access_token))
except imaplib.IMAP4.error as exc:
raise OutlookAuthError(f"XOAUTH2 login failed for {email}: {exc}") from exc
return _imap_collect(conn, IMAP_FOLDERS, per_folder)
finally:
try:
conn.logout()
except Exception:
pass
def gmail_imap_read_messages(login_email: str, app_password: str, *, per_folder: int = 20) -> list[dict[str, Any]]:
"""Gmail: recent messages via plain IMAP LOGIN with an app password (2FA + app pw)."""
conn = imaplib.IMAP4_SSL(GMAIL_IMAP_HOST, IMAP_PORT, timeout=IMAP_CONNECT_TIMEOUT)
try:
try:
conn.login(login_email, app_password)
except imaplib.IMAP4.error as exc:
raise OutlookAuthError(f"Gmail IMAP login failed for {login_email}: {exc}") from exc
return _imap_collect(conn, GMAIL_FOLDERS, per_folder)
finally:
try:
conn.logout()
except Exception:
pass
# ---- MIME parsing -------------------------------------------------------------
def _parse_dt(value: Any) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
try:
dt = parsedate_to_datetime(text)
if dt is None:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except Exception:
return None
def _decode_mime_header(value: str | None) -> str:
if not value:
return ""
try:
return str(make_header(decode_header(value)))
except Exception:
return str(value)
def _part_to_text(part: Any) -> str:
try:
payload = part.get_payload(decode=True)
if payload is None:
return str(part.get_payload())
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
except Exception:
try:
return str(part.get_payload())
except Exception:
return ""
def _parse_message(msg: Any) -> tuple[str, str, str, str]:
subject = _decode_mime_header(msg.get("Subject", ""))
from_addr = _decode_mime_header(msg.get("From", ""))
text_body = ""
html_body = ""
if msg.is_multipart():
for part in msg.walk():
if part.is_multipart():
continue
ctype = part.get_content_type()
if "attachment" in (part.get("Content-Disposition") or "").lower():
continue
if ctype == "text/plain" and not text_body:
text_body = _part_to_text(part)
elif ctype == "text/html" and not html_body:
html_body = _part_to_text(part)
else:
decoded = _part_to_text(msg)
if msg.get_content_type() == "text/html":
html_body = decoded
else:
text_body = decoded
return subject, text_body, html_body, from_addr
def _msg_recipients(msg: Any) -> set[str]:
"""Lowercased To+Cc addresses — these preserve the +tag for plus-addressed mail
(Delivered-To is always the base mailbox, so it is deliberately excluded)."""
vals: list[str] = []
for header in ("To", "Cc"):
vals.extend(msg.get_all(header, []) or [])
return {addr.strip().lower() for _, addr in getaddresses(vals) if addr and "@" in addr}
def base_email(email: str) -> str:
"""Strip a +tag sub-address: user+2@outlook.com -> user@outlook.com."""
local, sep, domain = str(email or "").strip().partition("@")
if not sep:
return str(email or "").strip().lower()
return f"{local.split('+', 1)[0]}@{domain}".lower()
def html_to_text(value: Any) -> str:
content = str(value or "")
if not content:
return ""
content = re.sub(r"(?is)<(script|style)\b.*?>.*?</\1>", " ", content)
content = re.sub(r"(?is)<!--.*?-->", " ", content)
content = re.sub(r"(?i)<br\s*/?>", "\n", content)
content = re.sub(r"(?i)</(?:p|div|tr|table|h[1-6]|li|td|section|article)>", "\n", content)
content = re.sub(r"(?s)<[^>]+>", " ", content)
content = html_lib.unescape(content)
content = re.sub(r"[\t\r\f\v ]+", " ", content)
content = re.sub(r"\n\s+", "\n", content)
return content.strip()