Initial sanitized code sync

This commit is contained in:
zqq61
2026-03-15 20:48:19 +08:00
commit 17ee51ba04
126 changed files with 22546 additions and 0 deletions

589
tools/cf_email_subdomain.py Normal file
View File

@@ -0,0 +1,589 @@
#!/usr/bin/env python3
"""
Cloudflare 二级域名邮箱 DNS 批量配置工具
功能:选择域名,批量为二级域名创建邮箱所需的 DNS 记录MX + SPF + DKIM
支持并发创建、自动跳过已存在的记录
"""
import httpx
import sys
import time
import asyncio
# ============ 配置 ============
CF_API_TOKEN = "nJWYVC1s7P9PpEdELjRTlJaYSMs0XOML1auCv6R8"
CF_API_BASE = "https://api.cloudflare.com/client/v4"
PROXY = "http://192.168.1.2:10810"
MAX_CONCURRENCY = 5 # 最大并发数Cloudflare 限流,不宜太高)
MAX_RETRIES = 3 # 限流重试次数
# Cloudflare Email Routing MX 记录
MX_RECORDS = [
{"content": "route1.mx.cloudflare.net", "priority": 10},
{"content": "route2.mx.cloudflare.net", "priority": 20},
{"content": "route3.mx.cloudflare.net", "priority": 30},
]
SPF_VALUE = "v=spf1 include:_spf.mx.cloudflare.net ~all"
def sync_client():
return httpx.Client(
proxy=PROXY,
headers={
"Authorization": f"Bearer {CF_API_TOKEN}",
"Content-Type": "application/json",
},
timeout=30,
)
def async_client():
return httpx.AsyncClient(
proxy=PROXY,
headers={
"Authorization": f"Bearer {CF_API_TOKEN}",
"Content-Type": "application/json",
},
timeout=30,
)
def api_get_sync(client, path, params=None):
resp = client.get(f"{CF_API_BASE}{path}", params=params)
data = resp.json()
if not data["success"]:
print(f" API 错误: {data['errors']}")
return None
return data
def list_zones(client):
data = api_get_sync(client, "/zones", {"per_page": 50})
if not data:
return []
return data["result"]
def get_existing_records(client, zone_id):
"""获取 zone 下所有 DNS 记录,用于去重"""
all_records = []
page = 1
while True:
data = api_get_sync(client, f"/zones/{zone_id}/dns_records", {
"per_page": 100,
"page": page,
})
if not data:
break
all_records.extend(data["result"])
if page >= data["result_info"]["total_pages"]:
break
page += 1
return all_records
def get_dkim_record(client, zone_id, domain):
data = api_get_sync(client, f"/zones/{zone_id}/dns_records", {
"type": "TXT",
"name": f"cf2024-1._domainkey.{domain}",
})
if data and data["result"]:
return data["result"][0]["content"]
return None
def build_records_for_subdomain(domain, subdomain, dkim_value=None):
"""构建一个子域名所需的所有 DNS 记录"""
full_sub = f"{subdomain}.{domain}"
records = []
for mx in MX_RECORDS:
records.append({
"type": "MX",
"name": full_sub,
"content": mx["content"],
"priority": mx["priority"],
"ttl": 1,
"proxied": False,
"comment": f"Email routing for {full_sub}",
})
records.append({
"type": "TXT",
"name": full_sub,
"content": SPF_VALUE,
"ttl": 1,
"proxied": False,
"comment": f"SPF for {full_sub}",
})
if dkim_value:
records.append({
"type": "TXT",
"name": f"cf2024-1._domainkey.{full_sub}",
"content": dkim_value,
"ttl": 1,
"proxied": False,
"comment": f"DKIM for {full_sub}",
})
return records
def filter_existing(records_to_create, existing_records):
"""过滤掉已存在的记录"""
existing_set = set()
for r in existing_records:
key = (r["type"], r["name"], r["content"])
existing_set.add(key)
new_records = []
skipped = 0
for r in records_to_create:
key = (r["type"], r["name"], r["content"])
if key in existing_set:
skipped += 1
else:
new_records.append(r)
return new_records, skipped
async def create_record_async(sem, client, zone_id, record):
"""并发创建单条 DNS 记录,带限流重试"""
async with sem:
for attempt in range(1, MAX_RETRIES + 1):
try:
resp = await client.post(
f"{CF_API_BASE}/zones/{zone_id}/dns_records",
json=record,
)
data = resp.json()
if data["success"]:
label = record["type"]
if record["type"] == "TXT":
if "spf1" in record["content"]:
label = "TXT(SPF)"
elif "DKIM" in record.get("comment", ""):
label = "TXT(DKIM)"
print(f" OK {label:10s} {record['name']} -> {record['content'][:50]}")
return "ok"
else:
# 检查是否限流 (code 971)
is_throttle = any(e.get("code") == 971 for e in data.get("errors", []))
if is_throttle and attempt < MAX_RETRIES:
print(f" THROTTLE {record['name']} 等待 5min 后重试 ({attempt}/{MAX_RETRIES})...")
await asyncio.sleep(300)
continue
print(f" FAIL {record['type']:10s} {record['name']} 错误: {data['errors']}")
return "throttle" if is_throttle else "fail"
except Exception as e:
if attempt < MAX_RETRIES:
wait = attempt * 2
print(f" ERR {record['name']} {e} 重试 ({attempt}/{MAX_RETRIES})...")
await asyncio.sleep(wait)
continue
print(f" ERR {record['type']:10s} {record['name']} 异常: {e}")
return "fail"
return "fail"
async def batch_create(zone_id, records):
"""批量并发创建 DNS 记录,每批之间等待 1 秒"""
total_success = 0
total_failed = 0
async with async_client() as client:
for i in range(0, len(records), MAX_CONCURRENCY):
batch = records[i:i + MAX_CONCURRENCY]
sem = asyncio.Semaphore(MAX_CONCURRENCY)
tasks = [create_record_async(sem, client, zone_id, r) for r in batch]
results = await asyncio.gather(*tasks)
total_success += sum(1 for r in results if r == "ok")
total_failed += sum(1 for r in results if r != "ok")
# 如果这批有限流,等 30 秒;否则等 1 秒
had_throttle = any(r == "throttle" for r in results)
if i + MAX_CONCURRENCY < len(records):
if had_throttle:
print(" 检测到限流,等待 5min...")
await asyncio.sleep(300)
else:
await asyncio.sleep(1)
return total_success, total_failed
def find_incomplete_subdomains(existing_records, domain, has_dkim=False):
"""找出邮箱 DNS 记录不完整的子域名"""
# 收集所有子域名的记录
sub_records = {} # subdomain -> {"mx": set(), "spf": bool, "dkim": bool}
mx_targets = {mx["content"] for mx in MX_RECORDS}
for r in existing_records:
name = r["name"]
# 跳过主域本身的记录
if name == domain:
continue
# 只关注 .domain 结尾的子域名
if not name.endswith(f".{domain}"):
continue
# 提取子域名部分
sub_part = name[: -(len(domain) + 1)]
# DKIM 记录: cf2024-1._domainkey.sub.domain
if sub_part.startswith("cf2024-1._domainkey."):
actual_sub = sub_part[len("cf2024-1._domainkey."):]
if actual_sub not in sub_records:
sub_records[actual_sub] = {"mx": set(), "spf": False, "dkim": False, "ids": []}
sub_records[actual_sub]["dkim"] = True
sub_records[actual_sub]["ids"].append(r["id"])
continue
# MX / TXT(SPF) 记录
if sub_part not in sub_records:
sub_records[sub_part] = {"mx": set(), "spf": False, "dkim": False, "ids": []}
if r["type"] == "MX" and r["content"] in mx_targets:
sub_records[sub_part]["mx"].add(r["content"])
sub_records[sub_part]["ids"].append(r["id"])
elif r["type"] == "TXT" and "spf1" in r.get("content", ""):
sub_records[sub_part]["spf"] = True
sub_records[sub_part]["ids"].append(r["id"])
# 判断完整性: 需要 3 MX + 1 SPF (+ 可选 DKIM)
incomplete = {}
complete = {}
for sub, info in sub_records.items():
mx_count = len(info["mx"])
is_complete = mx_count == 3 and info["spf"]
if has_dkim:
is_complete = is_complete and info["dkim"]
if not is_complete and info["ids"]: # 有记录但不完整
incomplete[sub] = info
elif is_complete:
complete[sub] = info
return incomplete, complete
def delete_records_sync(client, zone_id, record_ids):
"""同步删除 DNS 记录"""
success = 0
failed = 0
for rid in record_ids:
resp = client.delete(f"{CF_API_BASE}/zones/{zone_id}/dns_records/{rid}")
data = resp.json()
if data["success"]:
success += 1
else:
print(f" 删除失败 {rid}: {data['errors']}")
failed += 1
return success, failed
def cleanup_incomplete(client, zones):
"""检查并清理不完整的子域名邮箱记录"""
# 选择域名
print(f"\n选择要检查的域名 [1-{len(zones)}],多选用逗号分隔,输入 all 选择全部:")
while True:
choice = input(">>> ").strip().lower()
if choice == "all":
selected = zones[:]
break
parts = [p.strip() for p in choice.split(",") if p.strip()]
if all(p.isdigit() and 1 <= int(p) <= len(zones) for p in parts) and parts:
selected = [zones[int(p) - 1] for p in parts]
break
print("无效选择。")
total_deleted = 0
total_incomplete = 0
for zone in selected:
zone_id = zone["id"]
domain = zone["name"]
print(f"\n{'=' * 55}")
print(f" 检查域名: {domain}")
print(f"{'=' * 55}")
existing = get_existing_records(client, zone_id)
incomplete, complete = find_incomplete_subdomains(existing, domain)
print(f" 完整子域名: {len(complete)}")
print(f" 不完整子域名: {len(incomplete)}")
if not incomplete:
print(" 没有不完整的记录,跳过。")
continue
for sub, info in sorted(incomplete.items()):
mx_count = len(info["mx"])
print(f" {sub}.{domain} MX:{mx_count}/3 SPF:{'' if info['spf'] else ''} DKIM:{'' if info['dkim'] else ''} 记录数:{len(info['ids'])}")
total_incomplete += len(incomplete)
if total_incomplete == 0:
print("\n所有域名记录都是完整的。")
return
confirm = input(f"\n确认删除以上 {total_incomplete} 个不完整子域名的所有相关记录?[y/N]: ").strip().lower()
if confirm != "y":
print("已取消。")
return
# 执行删除
for zone in selected:
zone_id = zone["id"]
domain = zone["name"]
existing = get_existing_records(client, zone_id)
incomplete, _ = find_incomplete_subdomains(existing, domain)
if not incomplete:
continue
print(f"\n 删除 {domain} 的不完整记录...")
all_ids = []
for info in incomplete.values():
all_ids.extend(info["ids"])
success, failed = delete_records_sync(client, zone_id, all_ids)
total_deleted += success
print(f" 删除完成: 成功 {success}, 失败 {failed}")
print(f"\n总计删除: {total_deleted} 条记录")
def export_email_suffixes(client, zones):
"""导出解析完整的可用邮箱后缀列表"""
print(f"\n选择要导出的域名 [1-{len(zones)}],多选用逗号分隔,输入 all 选择全部:")
while True:
choice = input(">>> ").strip().lower()
if choice == "all":
selected = zones[:]
break
parts = [p.strip() for p in choice.split(",") if p.strip()]
if all(p.isdigit() and 1 <= int(p) <= len(zones) for p in parts) and parts:
selected = [zones[int(p) - 1] for p in parts]
break
print("无效选择。")
all_suffixes = []
for zone in selected:
zone_id = zone["id"]
domain = zone["name"]
print(f"\n 检查 {domain} ...")
existing = get_existing_records(client, zone_id)
incomplete, complete = find_incomplete_subdomains(existing, domain)
# 主域如果有完整邮箱记录也算
main_mx = set()
main_spf = False
for r in existing:
if r["name"] == domain:
if r["type"] == "MX" and r["content"] in {mx["content"] for mx in MX_RECORDS}:
main_mx.add(r["content"])
elif r["type"] == "TXT" and "spf1" in r.get("content", ""):
main_spf = True
if len(main_mx) == 3 and main_spf:
all_suffixes.append(domain)
# 完整的子域名
for sub in sorted(complete.keys()):
all_suffixes.append(f"{sub}.{domain}")
print(f" 完整: {len(complete)} 个子域名, 不完整: {len(incomplete)}")
print(f"\n{'=' * 55}")
print(f" 可用邮箱后缀: {len(all_suffixes)}")
print(f"{'=' * 55}")
# 打印数组格式
import json
print(f"\n{json.dumps(all_suffixes, ensure_ascii=False)}")
def main():
print("=" * 55)
print(" Cloudflare 二级域名邮箱 DNS 批量配置工具 (并发版)")
print("=" * 55)
client = sync_client()
# 主菜单
print("\n功能选择:")
print(" [1] 批量创建子域名邮箱 DNS")
print(" [2] 检查并清理不完整的记录")
print(" [3] 导出可用邮箱后缀列表")
while True:
mode = input("\n选择功能 [1-3]: ").strip()
if mode in ("1", "2", "3"):
break
print("无效选择。")
# 1. 列出域名
print("\n获取域名列表...")
zones = list_zones(client)
if not zones:
print("没有找到域名,请检查 API Token 权限。")
sys.exit(1)
print(f"\n找到 {len(zones)} 个域名:")
for i, z in enumerate(zones):
print(f" [{i + 1}] {z['name']} (状态: {z['status']})")
# 功能 2: 清理不完整记录
if mode == "2":
cleanup_incomplete(client, zones)
client.close()
return
# 功能 3: 导出可用邮箱后缀
if mode == "3":
export_email_suffixes(client, zones)
client.close()
return
# 2. 选择域名(支持多选)
print(f"\n选择域名 [1-{len(zones)}],多选用逗号分隔,输入 all 选择全部:")
while True:
choice = input(">>> ").strip().lower()
if choice == "all":
selected_zones = zones[:]
break
parts = [p.strip() for p in choice.split(",") if p.strip()]
if all(p.isdigit() and 1 <= int(p) <= len(zones) for p in parts) and parts:
selected_zones = [zones[int(p) - 1] for p in parts]
break
print("无效选择,请重试。")
print(f"\n已选择 {len(selected_zones)} 个域名:")
for z in selected_zones:
print(f" - {z['name']}")
# 3. 输入二级域名数量和前缀
while True:
count = input("\n要创建多少个二级域名邮箱?: ").strip()
if count.isdigit() and int(count) > 0:
count = int(count)
break
print("请输入正整数。")
print("\n前缀模式:")
print(" [1] 数字前缀 (mail1, mail2, mail3...)")
print(" [2] 字母前缀 (a, b, c...)")
print(" [3] 自定义前缀 (输入逗号分隔的列表)")
while True:
prefix_choice = input("\n选择前缀模式 [1-3]: ").strip()
if prefix_choice in ("1", "2", "3"):
break
print("无效选择。")
subdomains = []
if prefix_choice == "1":
prefix = input("输入前缀 (默认 mail): ").strip() or "mail"
subdomains = [f"{prefix}{i}" for i in range(1, count + 1)]
elif prefix_choice == "2":
if count > 26:
print("字母前缀最多支持 26 个。")
sys.exit(1)
subdomains = [chr(ord('a') + i) for i in range(count)]
else:
custom = input("输入前缀列表 (逗号分隔): ").strip()
subdomains = [s.strip() for s in custom.split(",") if s.strip()]
if len(subdomains) != count:
print(f"警告:输入了 {len(subdomains)} 个前缀,与数量 {count} 不匹配,使用实际输入的。")
count = len(subdomains)
# 4. DKIM 策略
print("\nDKIM 策略:")
print(" [1] 复制主域 DKIM 记录")
print(" [2] 跳过 DKIM (只创建 MX + SPF)")
while True:
dkim_choice = input("\n选择 DKIM 策略 [1-2]: ").strip()
if dkim_choice in ("1", "2"):
break
print("无效选择。")
# 5. 对每个域名执行
grand_total_success = 0
grand_total_skipped = 0
grand_total_failed = 0
all_created_emails = []
for zi, zone in enumerate(selected_zones):
zone_id = zone["id"]
domain = zone["name"]
print(f"\n{'=' * 55}")
print(f" [{zi+1}/{len(selected_zones)}] 处理域名: {domain}")
print(f"{'=' * 55}")
# DKIM
dkim_value = None
if dkim_choice == "1":
print(" 获取主域 DKIM 记录...")
dkim_value = get_dkim_record(client, zone_id, domain)
if dkim_value:
print(f" 找到 DKIM 记录 (长度: {len(dkim_value)})")
else:
print(" 未找到主域 DKIM 记录,将跳过 DKIM。")
# 构建记录
all_records = []
for sub in subdomains:
all_records.extend(build_records_for_subdomain(domain, sub, dkim_value))
print(f" 待创建: {len(all_records)} 条记录")
# 去重
print(" 检查已有 DNS 记录 (去重)...")
existing = get_existing_records(client, zone_id)
print(f" 已有记录: {len(existing)}")
new_records, skipped = filter_existing(all_records, existing)
print(f" 跳过已存在: {skipped}")
print(f" 需新建: {len(new_records)}")
grand_total_skipped += skipped
if not new_records:
print(" 所有记录已存在,跳过此域名。")
continue
# 并发创建
print(f" 开始并发创建 (并发数: {MAX_CONCURRENCY})...")
start_time = time.time()
success, failed = asyncio.run(batch_create(zone_id, new_records))
elapsed = time.time() - start_time
grand_total_success += success
grand_total_failed += failed
print(f" 完成!成功: {success}, 失败: {failed}, 耗时: {elapsed:.1f}s")
for sub in subdomains:
all_created_emails.append(f"*@{sub}.{domain}")
client.close()
# 6. 总汇总
print(f"\n{'=' * 55}")
print(f" 全部完成!")
print(f" 域名数: {len(selected_zones)}")
print(f" 子域名数: {count} x {len(selected_zones)} = {count * len(selected_zones)}")
print(f" 成功: {grand_total_success}")
print(f" 跳过: {grand_total_skipped}")
print(f" 失败: {grand_total_failed}")
print(f"{'=' * 55}")
if grand_total_success > 0:
print("\n注意: 还需要在 Cloudflare Email Routing 中配置转发规则才能收邮件。")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,99 @@
# Team 邀请小号流程分析报告
## 1. 邀请流程 (Phase-7)
完整流程在 `cmd/gptplus/main.go` 第 582-718 行:
```
Team 创建成功 → 获取 workspace token → 循环 invite_count 次:
1. 创建新 HTTP client (带独立代理 session)
2. 调用 auth.Register() 注册新账号 ← 消耗一个邮箱
3. 调用 chatgpt.InviteToTeam() 发送邀请 (POST /backend-api/accounts/{id}/invites)
4. 成员登录获取 token
5. 调用 auth.ObtainCodexTokens() 获取 Team 作用域 token (最多重试 3 次)
6. 保存成员 auth 文件
```
邀请 API: `POST /backend-api/accounts/{team_account_id}/invites`
- payload: `{"email_addresses": ["xxx@outlook.com"], "role": "standard-user", "resend_emails": true}`
- 需要 workspace-scoped access token
**注意:当前流程只发送邀请,没有"接受邀请"的步骤。** 成员注册后直接通过 `ObtainCodexTokens` 获取 Team token假设邀请自动生效因为是 admin 邀请已注册邮箱)。
---
## 2. 小号获取邮件的逻辑
### 当前行为
小号注册时调用的是**同一个 `emailProv`**(第 618 行):
```go
memberRegResult, memberRegErr := auth.Register(ctx, memberClient, emailProv, memberPassword)
```
`auth.Register()` 内部会调用 `emailProv.CreateMailbox()` 获取邮箱,然后调用 `emailProv.WaitForVerificationCode()` 获取 OTP。
### 有没有问题?
**之前有问题(已修复):** `WaitForVerificationCode` 使用的 `$filter` 查询被 Outlook REST API 拒绝InefficientFilter导致永远拿不到 OTP。现在已改为无 filter + 客户端过滤。
**潜在问题:** 无 filter 模式下拉取最近 10 封邮件,如果多个小号同时注册,收件箱里会有多封 OTP 邮件。由于有 `notBefore` 时间过滤 + `$orderby=ReceivedDateTime+desc`(最新优先),且代码取到第一个匹配的 OTP 就返回,所以**正常情况下不会混淆**。但如果两个小号在同一秒内收到 OTP理论上可能取错概率极低因为是串行注册
---
## 3. 小号是否遵循邮箱设定Outlook
**是的。** 小号使用的是同一个 `emailProv` 实例:
- 主账号和所有小号共享同一个 `OutlookProvider`
- 每次 `CreateMailbox()` 调用会返回 `accounts[nextIdx]` 并递增 `nextIdx`
- 所以小号注册时会**按顺序消耗 outmail.txt 中的 Outlook 账号**
如果配置是 `email.provider: "outlook"`,所有小号都会用 Outlook 邮箱。
---
## 4. 邮箱消耗顺序问题(核心问题)
### 场景
- outmail.txt 有 10 个邮箱 (索引 0-9)
- `invite_count: 4`
- 第一次运行:邮箱 #0 注册 Plus → Team 成功 → 邀请 4 个小号
### 当前行为
```
邮箱 #0 → 主账号注册 (CreateMailbox, nextIdx: 0→1)
邮箱 #1 → 小号1注册 (CreateMailbox, nextIdx: 1→2)
邮箱 #2 → 小号2注册 (CreateMailbox, nextIdx: 2→3)
邮箱 #3 → 小号3注册 (CreateMailbox, nextIdx: 3→4)
邮箱 #4 → 小号4注册 (CreateMailbox, nextIdx: 4→5)
```
**一次完整运行消耗 5 个邮箱1 主 + 4 小号)。**
### 下一次运行会怎样?
**会从邮箱 #0 重新开始,不是 #5。**
原因:`OutlookProvider.nextIdx` 是内存变量,每次程序启动时初始化为 0。没有持久化记录哪些邮箱已经用过。
```go
// NewOutlookProvider 每次创建时 nextIdx = 0
return &OutlookProvider{
accounts: accounts,
}, nil
```
### 后果
- 第二次运行会尝试用邮箱 #0 注册,但 #0 已经注册过 → **注册失败**
- 如果 OpenAI 返回的是 "Failed to register username" 而不是 "email already exists",程序无法区分
- 需要手动从 outmail.txt 删除已用过的邮箱,或者实现已用邮箱的持久化追踪
---
## 5. 问题汇总与建议
| # | 问题 | 严重度 | 建议 |
|---|------|--------|------|
| 1 | **nextIdx 不持久化**:重启后从 #0 开始,已用邮箱会重复注册失败 | 🔴 高 | 用文件记录已用邮箱索引,或注册成功后从 outmail.txt 中移除 |
| 2 | **无接受邀请步骤**:依赖 ObtainCodexTokens 自动加入 | 🟡 中 | 目前看起来可行,但可能需要验证在某些情况下是否需要显式 accept |
| 3 | **OTP 串号风险**:多小号共用收件箱时理论上可能取错 OTP | 🟢 低 | 串行注册 + notBefore 过滤已足够,暂不需处理 |
| 4 | **Outlook filter 已修复** | ✅ | 已改为无 filter + 客户端过滤 |

161
tools/test_outlook.go Normal file
View File

@@ -0,0 +1,161 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
const (
msTokenURL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
outlookMailURL = "https://outlook.office.com/api/v2.0/me/mailFolders/inbox/messages"
)
func main() {
clientID := "9e5f94bc-e8a4-4e73-b8be-63364c29d753"
refreshToken := "M.C502_SN1.0.U.-Crvsmbp5ATQIkZ3hocv9p7Z2X5!OXVJHdHa72LcvvI0vL89DBJ3O6CU!j07BU3A9kAJlFQcYVE*wmp!riMOJXBdeMvgB9xuF0duYbb8*M8qCnYGkX196K0z5CMhkHOOSahXbHkWN0cI685XA4z27rPYPOcGylHZdax1K0wF6N4kSxe*eoXn0dYL9wMPe1oC3vQ62B1EYECpaqqpfIKfKqhBpkbQ74up272uhiMvlRkYiD6ZCxL1aSeHg*aRiXGP4XfWWojKHN5rBqcXCXTRTGqw4r1Zij!RDXSBPBucjlL2uUSgPUu4H!UzEIQntGU2DeTGqecwk*XiJhLiic*RbrQliU9wQ!8J36Cc2grdSbeF9TeCHp!H6FC!qS40rekL31ZPFSunD8sJ*dQTOxPH9qC9ErHeoHvREFh0ypy4pa!!h5!15vvtwAPZgEcxcg2qSiA$$"
email := "frher9601@outlook.com"
fmt.Printf("=== Outlook REST API - No-Filter Strategy Test ===\n")
fmt.Printf("Account: %s\n\n", email)
// Step 1: Get access token
fmt.Println("[1] Exchanging refresh token...")
accessToken, err := exchangeToken(clientID, refreshToken)
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
return
}
fmt.Printf(" OK: access_token (%d chars)\n\n", len(accessToken))
// Step 2: Fetch recent emails WITHOUT any filter (the fix)
fmt.Println("[2] Fetching recent 10 emails (no $filter)...")
reqURL := fmt.Sprintf("%s?$top=10&$orderby=ReceivedDateTime+desc&$select=Subject,BodyPreview,ReceivedDateTime,From",
outlookMailURL)
req, _ := http.NewRequest("GET", reqURL, nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf(" FAILED: %v\n", err)
return
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
fmt.Printf(" FAILED (HTTP %d): %s\n", resp.StatusCode, string(body)[:min(len(body), 300)])
return
}
var result struct {
Value []struct {
Subject string `json:"Subject"`
BodyPreview string `json:"BodyPreview"`
ReceivedDateTime string `json:"ReceivedDateTime"`
From struct {
EmailAddress struct {
Address string `json:"Address"`
} `json:"EmailAddress"`
} `json:"From"`
} `json:"value"`
}
json.Unmarshal(body, &result)
fmt.Printf(" OK: %d emails returned\n\n", len(result.Value))
// Step 3: Client-side filter simulation
fmt.Println("[3] Client-side filtering (simulating fixed code)...")
notBefore := time.Now().Add(-48 * time.Hour) // last 48h
otpRegexp := regexp.MustCompile(`\b(\d{6})\b`)
foundOTP := ""
for i, e := range result.Value {
sender := strings.ToLower(e.From.EmailAddress.Address)
preview := e.BodyPreview
if len(preview) > 80 {
preview = preview[:80] + "..."
}
fmt.Printf(" [%d] from=%-45s subj=%q\n", i, sender, e.Subject)
// Filter: must be from OpenAI
if !strings.Contains(sender, "openai") {
fmt.Printf(" -> SKIP (not openai)\n")
continue
}
// Filter: time check
if e.ReceivedDateTime != "" {
if t, err := time.Parse("2006-01-02T15:04:05Z", e.ReceivedDateTime); err == nil {
if t.Before(notBefore) {
fmt.Printf(" -> SKIP (too old: %s)\n", e.ReceivedDateTime)
continue
}
}
}
// Extract OTP
if m := otpRegexp.FindStringSubmatch(e.Subject); len(m) >= 2 {
foundOTP = m[1]
fmt.Printf(" -> OTP from subject: %s\n", foundOTP)
break
}
if m := otpRegexp.FindStringSubmatch(e.BodyPreview); len(m) >= 2 {
foundOTP = m[1]
fmt.Printf(" -> OTP from body: %s\n", foundOTP)
break
}
fmt.Printf(" -> OpenAI email but no OTP found\n")
}
fmt.Println()
if foundOTP != "" {
fmt.Printf("=== SUCCESS: OTP = %s ===\n", foundOTP)
} else {
fmt.Println("=== No OTP found (no recent OpenAI email in inbox) ===")
}
}
func exchangeToken(clientID, refreshToken string) (string, error) {
data := url.Values{
"client_id": {clientID},
"grant_type": {"refresh_token"},
"refresh_token": {refreshToken},
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.PostForm(msTokenURL, data)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
cut := len(body)
if cut > 300 {
cut = 300
}
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)[:cut])
}
var tokenResp struct {
AccessToken string `json:"access_token"`
}
json.Unmarshal(body, &tokenResp)
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("no access_token in response")
}
return tokenResp.AccessToken, nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

292
tools/validate_cards.py Normal file
View File

@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""
Card Pre-Validator — Generate and validate cards via Stripe /v1/tokens.
Saves valid cards to a file for use by the main gptplus pipeline.
Usage:
python validate_cards.py --count 3000 --concurrency 20 --output valid_cards.txt
python validate_cards.py --count 1000 --pk pk_live_xxx --bin 6258142602
"""
import argparse
import asyncio
import random
import time
import sys
from datetime import datetime, timedelta
try:
import aiohttp
except ImportError:
print("需要安装 aiohttp: pip install aiohttp")
sys.exit(1)
# ─── Default Config ───
DEFAULT_BINS = ["6258142602", "62581717"]
DEFAULT_COUNTRY = "KR"
DEFAULT_CURRENCY = "KRW"
STRIPE_TOKENS_URL = "https://api.stripe.com/v1/tokens"
# Korean address data for random generation
KOREAN_CITIES = [
("Seoul", "서울특별시", "11"),
("Busan", "부산광역시", "26"),
("Incheon", "인천광역시", "28"),
("Daegu", "대구광역시", "27"),
("Daejeon", "대전광역시", "30"),
("Gwangju", "광주광역시", "29"),
("Suwon", "경기도", "41"),
("Seongnam", "경기도", "41"),
]
KOREAN_STREETS = [
"Gangnam-daero", "Teheran-ro", "Sejong-daero", "Eulji-ro",
"Jongno", "Hangang-daero", "Dongho-ro", "Yeoksam-ro",
"Apgujeong-ro", "Sinsa-dong", "Nonhyeon-ro", "Samseong-ro",
]
KOREAN_NAMES = [
"Kim Min Jun", "Lee Seo Yeon", "Park Ji Hoon", "Choi Yuna",
"Jung Hyun Woo", "Kang Soo Jin", "Yoon Tae Yang", "Lim Ha Eun",
"Han Seung Ho", "Shin Ye Rin", "Song Jae Min", "Hwang Mi Rae",
"Bae Dong Hyun", "Jeon So Hee", "Ko Young Jun", "Seo Na Yeon",
]
KOREAN_ZIPS = [
"06010", "06134", "06232", "06611", "07258", "08502", "04778",
"05502", "03181", "02578", "01411", "04516", "05854", "06775",
]
# ─── Card Generation ───
def luhn_check_digit(partial: str) -> str:
"""Calculate Luhn check digit for a partial card number."""
digits = [int(d) for d in partial]
# Double every second digit from right (starting from the rightmost)
for i in range(len(digits) - 1, -1, -2):
digits[i] *= 2
if digits[i] > 9:
digits[i] -= 9
total = sum(digits)
check = (10 - (total % 10)) % 10
return str(check)
def generate_card(bins: list[str]) -> dict:
"""Generate a random card number with valid Luhn checksum."""
bin_prefix = random.choice(bins)
total_len = 16
fill_len = total_len - len(bin_prefix) - 1 # -1 for check digit
partial = bin_prefix + "".join([str(random.randint(0, 9)) for _ in range(fill_len)])
check = luhn_check_digit(partial)
number = partial + check
# Random expiry 12-48 months from now
future = datetime.now() + timedelta(days=random.randint(365, 365 * 4))
exp_month = f"{future.month:02d}"
exp_year = str(future.year)
# Random CVC
cvc = f"{random.randint(0, 999):03d}"
# Random Korean identity
city_data = random.choice(KOREAN_CITIES)
name = random.choice(KOREAN_NAMES)
street_num = random.randint(1, 300)
street = random.choice(KOREAN_STREETS)
zip_code = random.choice(KOREAN_ZIPS)
return {
"number": number,
"exp_month": exp_month,
"exp_year": exp_year,
"cvc": cvc,
"name": name,
"country": DEFAULT_COUNTRY,
"currency": DEFAULT_CURRENCY,
"address": f"{street_num} {street}",
"city": city_data[0],
"state": city_data[2],
"postal_code": zip_code,
}
# ─── Stripe Validation ───
def _rotate_proxy(proxy_template: str) -> str:
"""Generate a unique proxy URL by replacing session placeholder with random value.
e.g. 'http://USER-session-{SESSION}-xxx:pass@host:port' → unique session per request."""
if not proxy_template:
return ""
if "{SESSION}" in proxy_template:
session_id = f"{random.randint(100000, 999999)}"
return proxy_template.replace("{SESSION}", session_id)
# If no {SESSION} placeholder, append random session to avoid same IP
return proxy_template
async def validate_card(session: aiohttp.ClientSession, card: dict, pk: str, proxy_template: str, semaphore: asyncio.Semaphore) -> tuple[dict, bool, str]:
"""Validate a card via Stripe /v1/tokens. Each request gets a unique proxy IP."""
async with semaphore:
# Rotate proxy — each request gets a different IP
proxy = _rotate_proxy(proxy_template)
data = {
"card[number]": card["number"],
"card[exp_month]": card["exp_month"],
"card[exp_year]": card["exp_year"],
"card[cvc]": card["cvc"],
"card[name]": card["name"],
"card[address_country]": card["country"],
"card[address_line1]": card["address"],
"card[address_city]": card["city"],
"card[address_state]": card["state"],
"card[address_zip]": card["postal_code"],
}
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Origin": "https://js.stripe.com",
"Referer": "https://js.stripe.com/",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
}
try:
async with session.post(
f"{STRIPE_TOKENS_URL}?key={pk}",
data=data,
headers=headers,
proxy=proxy if proxy else None,
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
body = await resp.json()
if resp.status == 200 and "id" in body:
return card, True, ""
# Extract error
err = body.get("error", {})
code = err.get("code", "unknown")
msg = err.get("message", str(body))
return card, False, f"{code}: {msg}"
except asyncio.TimeoutError:
return card, False, "timeout"
except Exception as e:
return card, False, str(e)
def card_to_line(card: dict) -> str:
"""Format card as pipe-delimited line (same format as success_cards.txt)."""
return "|".join([
card["number"], card["exp_month"], card["exp_year"], card["cvc"],
card["name"], card["country"], card["currency"],
card["address"], card["city"], card["state"], card["postal_code"],
])
# ─── Main ───
async def main():
parser = argparse.ArgumentParser(description="Card Pre-Validator via Stripe /v1/tokens")
parser.add_argument("--count", type=int, default=1000, help="Number of cards to generate and test")
parser.add_argument("--concurrency", type=int, default=20, help="Concurrent validation requests")
parser.add_argument("--output", type=str, default="valid_cards.txt", help="Output file for valid cards")
parser.add_argument("--pk", type=str, required=True, help="Stripe publishable key (pk_live_xxx)")
parser.add_argument("--bin", type=str, default="", help="Custom BIN prefix (comma-separated for multiple)")
parser.add_argument("--proxy", type=str, default="", help="Korean proxy URL (e.g. http://user:pass@host:port)")
args = parser.parse_args()
bins = DEFAULT_BINS
if args.bin:
bins = [b.strip() for b in args.bin.split(",")]
proxy = args.proxy or ""
print(f"=== Card Pre-Validator ===")
print(f"生成: {args.count}")
print(f"并发: {args.concurrency}")
print(f"BIN: {bins}")
print(f"代理: {proxy if proxy else '无 (直连)'}")
print(f"输出: {args.output}")
print()
# Generate all cards first
print(f"▶ 生成 {args.count} 张卡...")
cards = [generate_card(bins) for _ in range(args.count)]
print(f"✓ 生成完毕")
# Validate concurrently
semaphore = asyncio.Semaphore(args.concurrency)
valid_cards = []
invalid_count = 0
error_counts = {}
start_time = time.time()
async with aiohttp.ClientSession() as session:
tasks = [validate_card(session, card, args.pk, proxy, semaphore) for card in cards]
print(f"▶ 开始验证 (并发={args.concurrency})...")
done = 0
for coro in asyncio.as_completed(tasks):
card, is_valid, err_msg = await coro
done += 1
if is_valid:
valid_cards.append(card)
last4 = card["number"][-4:]
print(f" ✓ [{done}/{args.count}] ...{last4} 有效 (累计有效: {len(valid_cards)})")
else:
invalid_count += 1
# Track error types
err_type = err_msg.split(":")[0] if ":" in err_msg else err_msg
error_counts[err_type] = error_counts.get(err_type, 0) + 1
# Print progress every 100 or on interesting errors
if done % 100 == 0 or done == args.count:
elapsed = time.time() - start_time
rate = done / elapsed if elapsed > 0 else 0
print(f" → [{done}/{args.count}] 有效={len(valid_cards)} 无效={invalid_count} ({rate:.1f}/s)")
elapsed = time.time() - start_time
# Save valid cards
if valid_cards:
with open(args.output, "w") as f:
for card in valid_cards:
f.write(card_to_line(card) + "\n")
# Summary
print()
print(f"╔══════════════════════════════════════╗")
print(f"║ 验证结果汇总 ║")
print(f"╠══════════════════════════════════════╣")
print(f"║ 总计: {args.count:>6} 耗时: {elapsed:.1f}s ║")
print(f"║ 有效: {len(valid_cards):>6} ({len(valid_cards)/args.count*100:.1f}%) ║")
print(f"║ 无效: {invalid_count:>6}")
print(f"║ 速率: {args.count/elapsed:.1f}/s ║")
print(f"║ 输出: {args.output:<30s}")
print(f"╚══════════════════════════════════════╝")
if error_counts:
print(f"\n错误分布:")
for err, count in sorted(error_counts.items(), key=lambda x: -x[1]):
print(f" {err}: {count}")
print(f"\n有效卡片已保存到 {args.output}")
print(f"在 config.yaml 中使用:")
print(f" card:")
print(f" provider: file")
print(f" file:")
print(f" path: {args.output}")
print(f" default_country: KR")
print(f" default_currency: KRW")
if __name__ == "__main__":
asyncio.run(main())