fix: revert old oauth behavior and new default email provider
This commit is contained in:
parent
8b5449b1fd
commit
307ca38ecc
7 changed files with 439 additions and 23 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
232
src/email_providers/mail_tm.py
Normal file
232
src/email_providers/mail_tm.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue