File size: 11,485 Bytes
0da30c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2822a06
 
 
 
0da30c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b3df5c1
 
 
 
 
 
 
 
 
 
 
0da30c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2822a06
0da30c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2822a06
0da30c1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
"""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()