From 6dd26ad3d8c678de3c2488431e378a11225cd1d4 Mon Sep 17 00:00:00 2001 From: "Arthur K." Date: Mon, 2 Mar 2026 19:10:50 +0300 Subject: [PATCH] fix: revert old oauth behavior and new default email provider --- README.md | 5 + scripts/run_token_refresh_flow.py | 55 ++++++ src/email_providers/__init__.py | 8 +- src/email_providers/mail_tm.py | 232 ++++++++++++++++++++++++++ src/providers/chatgpt/provider.py | 4 +- src/providers/chatgpt/registration.py | 90 +++++++--- uv.lock | 68 ++++++++ 7 files changed, 439 insertions(+), 23 deletions(-) create mode 100644 scripts/run_token_refresh_flow.py create mode 100644 src/email_providers/mail_tm.py diff --git a/README.md b/README.md index a061094..3e37601 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,11 @@ Behavior: 4. When usage reaches `CHATGPT_PREPARE_THRESHOLD`, service prepares `next_account`. 5. When usage reaches `CHATGPT_SWITCH_THRESHOLD`, service switches active account to `next_account`. +## Disposable Email Provider + +- Default provider is `mail.tm` API (`MailTmProvider`) and does not use browser automation. +- Flow: fetch domains -> create account with random address/password -> get JWT token -> poll messages. + ## Startup Behavior On startup, service ensures active token exists and is usable. diff --git a/scripts/run_token_refresh_flow.py b/scripts/run_token_refresh_flow.py new file mode 100644 index 0000000..577763b --- /dev/null +++ b/scripts/run_token_refresh_flow.py @@ -0,0 +1,55 @@ +import argparse +import asyncio +import json +import logging + +import browser +import server + + +class _FakeRequest: + def __init__(self, provider: str): + self.match_info = {"provider": provider} + self.method = "GET" + self.path_qs = f"/{provider}/token" + + +def _enable_headed_browser() -> bool: + if "--no-startup-window" in browser.CHROME_FLAGS: + browser.CHROME_FLAGS.remove("--no-startup-window") + return True + return False + + +async def _run(provider: str) -> int: + patched = _enable_headed_browser() + logging.info("Headed mode patch applied: %s", patched) + + request = _FakeRequest(provider) + response = await server.token_handler(request) + payload = json.loads(response.body.decode("utf-8")) + + logging.info("Response status: %s", response.status) + logging.info("Response body: %s", json.dumps(payload, indent=2)) + return 0 if response.status == 200 else 1 + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Run the same token refresh/issue flow as server /{provider}/token " + "in headed browser mode (non-headless)." + ) + ) + parser.add_argument("--provider", default="chatgpt") + args = parser.parse_args() + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + return asyncio.run(_run(args.provider)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/email_providers/__init__.py b/src/email_providers/__init__.py index 76e66ca..a1af08e 100644 --- a/src/email_providers/__init__.py +++ b/src/email_providers/__init__.py @@ -1,5 +1,11 @@ from .base import BaseProvider +from .mail_tm import MailTmProvider from .ten_minute_mail import TenMinuteMailProvider from .temp_mail_org import TempMailOrgProvider -__all__ = ["BaseProvider", "TenMinuteMailProvider", "TempMailOrgProvider"] +__all__ = [ + "BaseProvider", + "MailTmProvider", + "TenMinuteMailProvider", + "TempMailOrgProvider", +] diff --git a/src/email_providers/mail_tm.py b/src/email_providers/mail_tm.py new file mode 100644 index 0000000..891a789 --- /dev/null +++ b/src/email_providers/mail_tm.py @@ -0,0 +1,232 @@ +import asyncio +import logging +import os +import secrets +import string +from typing import Any + +import aiohttp +from playwright.async_api import BrowserContext + +from .base import BaseProvider + +logger = logging.getLogger(__name__) + +_API_BASE = os.environ.get("MAIL_TM_API_BASE", "https://api.mail.tm") +_TIMEOUT_SECONDS = 20 +_FIRST_NAMES = [ + "james", + "john", + "robert", + "michael", + "david", + "william", + "joseph", + "thomas", + "daniel", + "mark", + "paul", + "kevin", +] +_LAST_NAMES = [ + "smith", + "johnson", + "williams", + "brown", + "jones", + "miller", + "davis", + "wilson", + "anderson", + "taylor", + "martin", + "thompson", +] + + +def _generate_local_part() -> str: + first = secrets.choice(_FIRST_NAMES) + last = secrets.choice(_LAST_NAMES) + digits = "".join(secrets.choice(string.digits) for _ in range(8)) + return f"{first}{last}{digits}" + + +def _generate_password(length: int = 24) -> str: + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +class MailTmProvider(BaseProvider): + def __init__(self, browser_session: BrowserContext): + super().__init__(browser_session) + self._address: str | None = None + self._password: str | None = None + self._token: str | None = None + + async def _request( + self, + method: str, + path: str, + *, + token: str | None = None, + json_body: dict[str, Any] | None = None, + ) -> tuple[int, dict[str, Any] | list[Any] | None]: + url = f"{_API_BASE.rstrip('/')}{path}" + headers: dict[str, str] = {} + if token: + headers["Authorization"] = f"Bearer {token}" + + timeout = aiohttp.ClientTimeout(total=_TIMEOUT_SECONDS) + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.request( + method, + url, + headers=headers, + json=json_body, + ) as resp: + status = resp.status + try: + payload = await resp.json() + except aiohttp.ContentTypeError: + payload = None + return status, payload + except aiohttp.ClientError as e: + logger.warning("[mail.tm] request failed %s %s: %s", method, path, e) + return 0, None + + async def _get_domains(self) -> list[str]: + status, payload = await self._request("GET", "/domains") + if status != 200 or not isinstance(payload, dict): + raise RuntimeError("mail.tm domains request failed") + + members = payload.get("hydra:member") + if not isinstance(members, list): + raise RuntimeError("mail.tm domains response has unexpected format") + + domains: list[str] = [] + for item in members: + if not isinstance(item, dict): + continue + domain = item.get("domain") + is_active = bool(item.get("isActive", True)) + if isinstance(domain, str) and domain and is_active: + domains.append(domain) + + if not domains: + raise RuntimeError("mail.tm returned no active domains") + return domains + + async def _create_account(self, address: str, password: str) -> bool: + status, _ = await self._request( + "POST", + "/accounts", + json_body={"address": address, "password": password}, + ) + if status in (200, 201): + return True + return False + + async def _create_token(self, address: str, password: str) -> str | None: + status, payload = await self._request( + "POST", + "/token", + json_body={"address": address, "password": password}, + ) + if status != 200 or not isinstance(payload, dict): + return None + token = payload.get("token") + if isinstance(token, str) and token: + return token + return None + + async def get_new_email(self) -> str: + domains = await self._get_domains() + + for _ in range(8): + domain = secrets.choice(domains) + address = f"{_generate_local_part()}@{domain}" + password = _generate_password() + + created = await self._create_account(address, password) + if not created: + continue + + token = await self._create_token(address, password) + if not token: + continue + + self._address = address + self._password = password + self._token = token + logger.info("[mail.tm] New mailbox acquired: %s", address) + return address + + raise RuntimeError("mail.tm could not create account") + + async def _list_messages(self) -> list[dict[str, Any]]: + if not self._token: + return [] + status, payload = await self._request( + "GET", + "/messages", + token=self._token, + ) + if status == 401 and self._address and self._password: + token = await self._create_token(self._address, self._password) + if token: + self._token = token + status, payload = await self._request( + "GET", + "/messages", + token=self._token, + ) + + if status != 200 or not isinstance(payload, dict): + return [] + + members = payload.get("hydra:member") + if not isinstance(members, list): + return [] + return [item for item in members if isinstance(item, dict)] + + async def _get_message_text(self, message_id: str) -> str | None: + if not self._token: + return None + status, payload = await self._request( + "GET", + f"/messages/{message_id}", + token=self._token, + ) + if status != 200 or not isinstance(payload, dict): + return None + + parts = [ + payload.get("subject"), + payload.get("intro"), + payload.get("text"), + payload.get("html"), + ] + text = "\n".join(str(part) for part in parts if part) + return text or None + + async def get_latest_message(self, email: str) -> str | None: + del email + if not self._token: + raise RuntimeError("mail.tm provider is not initialized with mailbox token") + + for _ in range(45): + messages = await self._list_messages() + if messages: + latest = messages[0] + message_id = latest.get("id") + if isinstance(message_id, str) and message_id: + full_message = await self._get_message_text(message_id) + if full_message: + logger.info("[mail.tm] Latest message received") + return full_message + + await asyncio.sleep(2) + + logger.warning("[mail.tm] No messages received within timeout") + return None diff --git a/src/providers/chatgpt/provider.py b/src/providers/chatgpt/provider.py index ae2e527..445500f 100644 --- a/src/providers/chatgpt/provider.py +++ b/src/providers/chatgpt/provider.py @@ -7,7 +7,7 @@ from typing import Callable from playwright.async_api import BrowserContext from email_providers import BaseProvider -from email_providers import TempMailOrgProvider +from email_providers import MailTmProvider from providers.base import Provider, ProviderTokens from .tokens import ( clear_next_tokens, @@ -34,7 +34,7 @@ class ChatGPTProvider(Provider): self, email_provider_factory: Callable[[BrowserContext], BaseProvider] | None = None, ): - self.email_provider_factory = email_provider_factory or TempMailOrgProvider + self.email_provider_factory = email_provider_factory or MailTmProvider self._token_write_lock = asyncio.Lock() async def _register_with_retries(self) -> bool: diff --git a/src/providers/chatgpt/registration.py b/src/providers/chatgpt/registration.py index 3d106c1..270c6d3 100644 --- a/src/providers/chatgpt/registration.py +++ b/src/providers/chatgpt/registration.py @@ -281,7 +281,34 @@ async def click_continue(page: Page, timeout_ms: int = 10000): await btn.click() -async def click_any_visible_button( +async def oauth_needs_email_check(page: Page) -> bool: + marker = page.get_by_text("Check your inbox", exact=False) + return await marker.count() > 0 + + +async def fill_oauth_code_if_present(page: Page, code: str) -> bool: + candidates = [ + page.get_by_placeholder("Code"), + page.get_by_label("Code"), + page.locator( + 'input[name*="code" i], input[id*="code" i], ' + 'input[autocomplete="one-time-code"], input[inputmode="numeric"]' + ), + ] + + for locator in candidates: + if await locator.count() == 0: + continue + try: + await locator.first.wait_for(state="visible", timeout=1500) + await locator.first.fill(code) + return True + except PlaywrightError: + continue + return False + + +async def click_first_visible_button( page: Page, labels: list[str], timeout_ms: int = 2000, @@ -415,44 +442,67 @@ async def register_chatgpt_account( oauth_page.on("request", handle_request) await oauth_page.goto(authorize_url, wait_until="domcontentloaded") + await oauth_page.locator( + 'input[type="email"], input[name="email"]' + ).first.wait_for(state="visible", timeout=20000) + email_input = oauth_page.locator('input[type="email"], input[name="email"]') if await email_input.count() > 0: - await email_input.first.wait_for(state="visible", timeout=10000) await email_input.first.fill(email) - await click_any_visible_button( - oauth_page, ["Continue"], timeout_ms=4000 + + continue_button = oauth_page.get_by_role("button", name="Continue") + if await continue_button.count() > 0: + await continue_button.first.click() + await oauth_page.locator('input[type="password"]').first.wait_for( + state="visible", timeout=20000 ) password_input = oauth_page.locator('input[type="password"]') if await password_input.count() > 0: - await password_input.first.wait_for(state="visible", timeout=10000) await password_input.first.fill(password) - await click_any_visible_button( - oauth_page, ["Continue"], timeout_ms=4000 - ) + continue_button = oauth_page.get_by_role("button", name="Continue") + if await continue_button.count() > 0: + await continue_button.first.click() - for _ in range(6): + last_oauth_email_code = code + oauth_deadline = asyncio.get_running_loop().time() + 60 + while asyncio.get_running_loop().time() < oauth_deadline: if redirect_url_captured: break - clicked = await click_any_visible_button( - oauth_page, - ["Continue", "Allow", "Authorize"], - timeout_ms=2000, - ) - if clicked: - await asyncio.sleep(0.4) - else: - await asyncio.sleep(0.4) - if not redirect_url_captured: + if await oauth_needs_email_check(oauth_page): + logger.info("OAuth requested email confirmation code") + new_code = await get_latest_code(email_provider, email) + if new_code and new_code != last_oauth_email_code: + filled = await fill_oauth_code_if_present(oauth_page, new_code) + if filled: + last_oauth_email_code = new_code + logger.info("Filled OAuth email confirmation code") + else: + logger.warning( + "OAuth inbox challenge detected but code field not found" + ) + try: current_url = oauth_page.url if "localhost:1455" in current_url and "code=" in current_url: redirect_url_captured = current_url logger.info("Captured OAuth redirect from page URL") - except PlaywrightError: + break + except Exception: pass + clicked = await click_first_visible_button( + oauth_page, + ["Continue", "Allow", "Authorize", "Verify"], + timeout_ms=2000, + ) + + if clicked: + await oauth_page.wait_for_timeout(500) + else: + await oauth_page.wait_for_timeout(1000) + if not redirect_url_captured: raise AutomationError( "oauth", "OAuth redirect with code was not captured", oauth_page diff --git a/uv.lock b/uv.lock index bb427f9..17acd39 100644 --- a/uv.lock +++ b/uv.lock @@ -83,6 +83,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -158,6 +167,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "megapt" version = "0.1.0" @@ -168,12 +186,19 @@ dependencies = [ { name = "playwright" }, ] +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "aiohttp", specifier = "==3.13.3" }, { name = "pkce", specifier = "==1.0.3" }, { name = "playwright", specifier = "==1.58.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, ] +provides-extras = ["dev"] [[package]] name = "multidict" @@ -220,6 +245,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + [[package]] name = "pkce" version = "1.0.3" @@ -248,6 +282,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -299,6 +342,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"