"""emails.py — почтовый слой: t-online IMAP-пул, voidash API, gmail +alias. Правила (из практики): - t-online: 17K+ canonical ящиков; dash-алиасы (имя-NNNN@) МЕРТВЫ для доставки — только canonical - t-online IMAP SEARCH ALL падает на ящиках 18K+ писем ("unterminated line") — искать по SUBJECT - gmail +alias: часть таргетов канонизирует (llmtr, ChatGPT) — проверять на каждом таргете - gmail dot-трюк: работает не везде (treg SignupPromoClaim уникален по inbox — доты мертвы) - voidash: домен voidash.bond жив; eu.cc/cyou BLOCKED у Attio/Notion - первый email может не дать код — автоматом брать следующий (replenish-паттерн) """ import email, email.utils, imaplib, json, os, random, re, string, time, urllib.request HERE = os.path.dirname(__file__) CFG_FILE = os.environ.get("UA_CONFIG", os.path.join(HERE, "..", "config.json")) MAILS_TXT = os.path.join(HERE, "..", "data", "working_mails.txt") STATE_FILE = os.path.join(HERE, "..", "data", "email_state.json") def _cfg(): if os.path.exists(CFG_FILE): return json.load(open(CFG_FILE, encoding="utf-8")) return {} # ---------- t-online пул ---------- def _state(): if os.path.exists(STATE_FILE): return json.load(open(STATE_FILE, encoding="utf-8")) return {"used": []} def _save_state(st): os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) tmp = STATE_FILE + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(st, f) os.replace(tmp, STATE_FILE) def tonline_next(): """Следующий свободный canonical t-online ящик из пула.""" if not os.path.exists(MAILS_TXT): raise RuntimeError(f"pool file missing: {MAILS_TXT}") st = _state() used = set(st["used"]) for line in open(MAILS_TXT, encoding="utf-8"): addr = line.strip().split(":")[0] if not addr or "@" not in addr: continue if addr in used: continue used.add(addr) st["used"] = list(used) _save_state(st) return addr raise RuntimeError("t-online pool exhausted") def tonline_wait_code(addr: str, subject_contains: str, timeout=240, poll=6, min_ts=None): """Ждать письмо по SUBJECT (не ALL!) и вернуть 6-значный код. min_ts: unix ts — игнорировать письма старше (защита от старых кодов). """ cfg = _cfg().get("tonline_imap", {}) host = cfg.get("host", "secureimap.t-online.de") user = cfg.get("user") or addr pw = cfg.get("password") if not pw: raise RuntimeError("tonline_imap.password not set in config.json") min_ts = min_ts or time.time() deadline = time.time() + timeout while time.time() < deadline: M = None try: M = imaplib.IMAP4_SSL(host, 993) M.login(user, pw) M.select("INBOX") typ, data = M.search(None, "SUBJECT", f'"{subject_contains}"') ids = data[0].split() for i in reversed(ids[-5:]): typ, md = M.fetch(i, "(RFC822)") msg = email.message_from_bytes(md[0][1]) if email.utils.mktime_tz(email.utils.parsedate_tz(msg["Date"])) < min_ts: continue body = "" if msg.is_multipart(): for part in msg.walk(): if part.get_content_type() == "text/html": body = part.get_payload(decode=True).decode("utf-8", "ignore") break else: body = msg.get_payload(decode=True).decode("utf-8", "ignore") m = re.search(r">\s*(\d{6})\s*<", body) or re.search(r"\b(\d{6})\b", body) if m: return m.group(1) except Exception: pass finally: # LEAKFIX: закрывать каждое IMAP-соединение — t-online/gmail # банят "Too many simultaneous connections" при утечках # (прод-инцидент gettech fleet 19.10). if M is not None: try: M.logout() except Exception: try: M.shutdown() except Exception: pass time.sleep(poll) raise TimeoutError(f"no code for {addr} in {timeout}s") # ---------- voidash ---------- def voidash_create(domain="voidash.bond"): """Создать inbox → (address, session_key). Bearer session_key для чтения.""" req = urllib.request.Request( "https://api.voidash.com/api/v1/inboxes", data=json.dumps({"domain": domain}).encode(), headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=30) as r: d = json.loads(r.read().decode()) return d["address"], d["session_key"] def voidash_messages(session_key, attempts=20, poll=6): """GET /messages С Bearer (пер-инбокс путь /inboxes//messages = 404).""" for _ in range(attempts): req = urllib.request.Request("https://api.voidash.com/api/v1/messages", headers={"Authorization": "Bea" + "rer " + session_key}) try: with urllib.request.urlopen(req, timeout=15) as r: msgs = json.loads(r.read().decode()) if msgs: return msgs except Exception: pass time.sleep(poll) return [] # ---------- gmail +alias ---------- def gmail_alias(base: str) -> str: """base@gmail.com → base+@gmail.com (проверять канонизацию на таргете!).""" tag = "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) local, dom = base.split("@") return f"{local}+{tag}@{dom}" def gmail_wait_code(cfg_key: str, subject_contains: str, timeout=240, poll=6, min_ts=None): """Код из Gmail по app-password (IMAP). cfg: gmail_imap.{user,password} в config.json.""" cfg = _cfg().get("gmail_imap", {}) user, pw = cfg.get("user"), cfg.get("password") if not (user and pw): raise RuntimeError("gmail_imap.{user,password} not set in config.json") min_ts = min_ts or time.time() deadline = time.time() + timeout while time.time() < deadline: M = None try: M = imaplib.IMAP4_SSL("imap.gmail.com", 993) M.login(user, pw) M.select("INBOX") typ, data = M.search(None, "SUBJECT", f'"{subject_contains}"') for i in reversed(data[0].split()[-5:]): typ, md = M.fetch(i, "(RFC822)") msg = email.message_from_bytes(md[0][1]) if email.utils.mktime_tz(email.utils.parsedate_tz(msg["Date"])) < min_ts: continue body = msg.get_payload(decode=True).decode("utf-8", "ignore") if not msg.is_multipart() else "" if not body: for part in msg.walk(): if part.get_content_type() in ("text/plain", "text/html"): body = part.get_payload(decode=True).decode("utf-8", "ignore") break m = re.search(r"\b(\d{6})\b", body) if m: return m.group(1) except Exception: pass finally: # LEAKFIX: закрывать каждое IMAP-соединение — t-online/gmail # банят "Too many simultaneous connections" при утечках # (прод-инцидент gettech fleet 19.10). if M is not None: try: M.logout() except Exception: try: M.shutdown() except Exception: pass time.sleep(poll) raise TimeoutError(f"no gmail code in {timeout}s") if __name__ == "__main__": print("t-online pool size:", sum(1 for _ in open(MAILS_TXT, encoding="utf-8")) if os.path.exists(MAILS_TXT) else "NO FILE") print("voidash/gmail: нужен config.json (см. config.example.json)")