Add 2925 account pool binding helper

This commit is contained in:
2026-05-11 16:07:33 +08:00
parent 26f42427be
commit 228d650ca8
3 changed files with 326 additions and 0 deletions
+36
View File
@@ -142,6 +142,32 @@ If login recovery hits `Incorrect email address or password`, `invalid_state`, s
If login recovery hits `Incorrect email address or password`, `invalid_state`, stale email verification, or tab ambiguity, stop before buying phone numbers; recover account/page first. For existing accounts with password failure, the login-page `使用一次性验证码登录` path may send an email code, but `mail-cli` output is not guaranteed newest-first: parse/sort the `date` field and submit the newest OpenAI login mail. Do not use passwordless signup (`使用一次性验证码注册`) for an existing account; it can return `passwordless_signup_disabled`. If a fresh email-code submission returns `account_deactivated`, stop the account immediately even if phone verification previously succeeded. If login recovery hits `Incorrect email address or password`, `invalid_state`, stale email verification, or tab ambiguity, stop before buying phone numbers; recover account/page first. For existing accounts with password failure, the login-page `使用一次性验证码登录` path may send an email code, but `mail-cli` output is not guaranteed newest-first: parse/sort the `date` field and submit the newest OpenAI login mail. Do not use passwordless signup (`使用一次性验证码注册`) for an existing account; it can return `passwordless_signup_disabled`. If a fresh email-code submission returns `account_deactivated`, stop the account immediately even if phone verification previously succeeded.
## 6A. 2925 mailbox provider option
Use this when ClawEmail accounts appear unstable and BOSS wants 2925 instead. The original extension treats 2925 as a class-level mail provider with two modes:
```text
mail2925Mode=provide # preferred for replacing ClawEmail: generate 2925 aliases for OpenAI signup
mail2925Mode=receive # registration email comes from elsewhere; 2925 only receives/polls mail
```
For `provide`, generate registration emails as `baseLocal + randomSuffix(6) + @2925.com`; **do not** use Gmail-style `+tag`. Example: `abc@2925.com -> abcxk39qz@2925.com`.
Recommended gates for Hermes runners:
1. Use an account pool; format can mirror original extension imports: `email@2925.com----password`.
2. Select an enabled, non-cooldown account with the oldest `lastUsedAt`; tie-break by email.
3. Open `https://2925.com/#/mailList` or login at `https://2925.com/login/`; on force relogin clear cookies for `2925.com`, `www.2925.com`, and `mail2.xiyouji.com`.
4. Verify the displayed mailbox email equals the selected 2925 account before polling. If mismatched, relogin to the selected account; do not poll the wrong mailbox.
5. Before requesting an OpenAI code, pre-clear the 2925 inbox where possible.
6. Poll 15 attempts x 15 seconds for signup/login codes; open candidate mail, extract a strict 6-digit code, delete after read, return to inbox.
7. Maintain `seenCodes` and `rejectedCodes`; if OpenAI rejects a code, request/resend and keep polling instead of reusing old mail.
8. Detect `子邮箱已达上限` / `已达上限邮箱` / `子邮箱上限` / `邮箱已达上限`; mark the current 2925 account cooldown for 24h and switch accounts. If no account pool/next account exists, stop the flow.
Reference: `references/2925-mail-provider-original-extension-rules-2026-05-11.md` and `references/mail2925-account-pool-integration-2026-05-11.md`.
BOSS-provided pool is stored locally at `~/.hermes/secrets/mail2925_accounts.txt` with mode `0600` (20 accounts, `chickliu2021@2925.com` through `chickliu2040@2925.com`). Never print or commit raw passwords. Use `scripts/mail2925_pool.py` for account rotation and alias generation. Hard invariant: generated alias local-part must start with the selected account local-part, and the displayed 2925 mailbox must equal the selected account before polling code. If mismatch, clear 2925 cookies and force-login the selected account; never read code from another account's mailbox.
## 7. 5sim SMS phone flow ## 7. 5sim SMS phone flow
Use SMS activation provider selected by `PHONE_SMS_PROVIDER`: Use SMS activation provider selected by `PHONE_SMS_PROVIDER`:
@@ -191,6 +217,16 @@ Observed OpenAI service lookup returns entries such as `OpenAI / ChatGpt` and `O
Verified 2026-05-11 with BOSS key: balance API OK, balance `2.000`; service list OK. Do not print the key. The helper script is at `scripts/luban_sms.py`. Verified 2026-05-11 with BOSS key: balance API OK, balance `2.000`; service list OK. Do not print the key. The helper script is at `scripts/luban_sms.py`.
Integration/verification policy:
- First validate Luban with `balance` and `resolve` only; do not buy a number while merely adding the provider.
- Luban `/List` output is not a fixed country matrix; resolve `service_id` dynamically from `service=OpenAI`/`OpenAI / ChatGpt`, then pick the cheapest usable match unless BOSS asks for a specific country/provider.
- Treat Luban `request_id` as the activation id. Poll `/getSms`; `msg=wait` means keep polling, `msg=success` plus `sms_code` means submit a strict 6-digit code, and `wrong_status` means the request expired/closed.
- Release failed/rejected Luban numbers with `/setStatus?...&status=reject`; Luban has no 5sim-style `finish`, so a successful code submission is a local no-op finish in the runner.
- Store the key only in `~/.hermes/env/codex_oauth_onboarding.env` as `LUBAN_SMS_API_KEY`; never echo it in command output, logs, or chat.
- Details: `references/luban-sms-provider-integration-2026-05-11.md`.
- Provider abstraction lessons: `references/sms-provider-abstraction-and-luban-2026-05-11.md`.
If using the long-running runner: If using the long-running runner:
```bash ```bash
@@ -0,0 +1,153 @@
# 2925 account pool integration notes — 2026-05-11
Decision: abandon POP3/SMTP/IMAP forwarding to ClawEmail. Use the original extension's 2925 webmail/account-pool model directly.
## Account pool
Secret file:
```text
~/.hermes/secrets/mail2925_accounts.txt
```
Format:
```text
邮箱----密码
user@2925.com----password
```
BOSS provided 20 accounts (`chickliu2021@2925.com` through `chickliu2040@2925.com`). Passwords are stored only in the local secret file with mode `0600`; never print or commit raw passwords.
State file:
```text
~/.hermes/state/mail2925_accounts_state.json
```
State fields per account:
```json
{
"lastUsedAt": 0,
"lastLoginAt": 0,
"lastLimitAt": 0,
"disabledUntil": 0,
"lastError": ""
}
```
## Hard invariant: alias/account binding
A generated 2925 registration address must be derived from the same 2925 account used to read mail.
Example:
```text
selected account: chickliu2021@2925.com
allowed alias: chickliu2021rl9sxh@2925.com
mail reader: logged in as chickliu2021@2925.com
```
Forbidden:
```text
alias from chickliu2021*, but mailbox logged in as chickliu2022@2925.com
```
Gate before reading any code:
```text
displayedMailboxEmail == selectedAccount.email
alias.localPart startsWith selectedAccount.localPart
```
If the displayed mailbox email differs, clear 2925 cookies and force-login the selected account before continuing.
## Alias generation
Use original extension rule, not Gmail-style plus tags:
```text
baseLocal + randomSuffix(6) + @2925.com
```
Example:
```text
chickliu2021@2925.com -> chickliu2021a8k2mz@2925.com
```
Do not generate `chickliu2021+tag@2925.com`.
## Account selection
Pick among accounts that are:
```text
enabled != false
have password
disabledUntil <= now
```
Sort by:
```text
lastUsedAt ascending, then email ascending
```
This rotates accounts and avoids overusing one mailbox.
## Limit handling
Detect 2925 limit messages:
```text
子邮箱已达上限
已达上限邮箱
子邮箱上限
邮箱已达上限
```
On hit:
```text
currentAccount.disabledUntil = now + 24h
currentAccount.lastLimitAt = now
currentAccount.lastError = reason
switch to next available account
terminate current OpenAI attempt and restart next round with a fresh alias
```
Do not continue the same OpenAI page after switching 2925 account; it risks mixing alias/account state.
## Local helper
```bash
python3 scripts/mail2925_pool.py selftest
python3 scripts/mail2925_pool.py list
python3 scripts/mail2925_pool.py pick
python3 scripts/mail2925_pool.py mark-limit chickliu2021@2925.com --reason '子邮箱已达上限邮箱'
python3 scripts/mail2925_pool.py mark-login chickliu2021@2925.com
```
`pick` prints only email + generated alias; never prints passwords.
Verified locally on 2026-05-11:
```json
{"accounts":20,"aliasFailures":[],"secretMode":"0o600","ok":true}
```
## Webmail flow to implement/use
1. Pick account and generate alias in one operation.
2. Open/login `https://2925.com/login/` or `https://2925.com/#/mailList`.
3. Verify displayed mailbox email equals picked account email.
4. Clear inbox before triggering OpenAI code.
5. Submit generated alias to OpenAI.
6. Poll 2925 inbox: 15 attempts, 15s interval.
7. Match OpenAI/auth/verify/code/login sender/subject/body; extract 6-digit code.
8. Open matching mail, extract body code, delete mail, return inbox.
9. Submit code; if invalid, add to rejectedCodes, resend, and continue polling.
10. On 2925 limit, mark current account cooldown and restart with next account.
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""2925 account-pool helper. Never prints passwords."""
import argparse, json, os, random, re, stat, string, time
from pathlib import Path
ACCOUNTS_FILE = Path(os.environ.get('MAIL2925_ACCOUNTS_FILE', str(Path.home()/'.hermes/secrets/mail2925_accounts.txt'))).expanduser()
STATE_FILE = Path(os.environ.get('MAIL2925_STATE_FILE', str(Path.home()/'.hermes/state/mail2925_accounts_state.json'))).expanduser()
COOLDOWN_SECONDS = int(os.environ.get('MAIL2925_LIMIT_COOLDOWN_SECONDS', str(24*60*60)))
SUFFIX_LEN = int(os.environ.get('MAIL2925_ALIAS_SUFFIX_LEN', '6'))
EMAIL_RE = re.compile(r'^[^@\s]+@2925\.com$', re.I)
def load_accounts(path=ACCOUNTS_FILE):
text = path.read_text()
out=[]; seen=set()
for raw in text.splitlines():
line=raw.strip()
if not line or line.lower() == '邮箱----密码':
continue
if '----' not in line:
raise ValueError(f'bad account line: {line[:20]}...')
email,pwd=[x.strip() for x in line.split('----',1)]
email=email.lower()
if not EMAIL_RE.match(email):
raise ValueError(f'bad 2925 email: {email}')
if not pwd:
raise ValueError(f'missing password: {email}')
if email in seen:
continue
seen.add(email); out.append({'id': email, 'email': email, 'password': pwd})
return out
def load_state():
if not STATE_FILE.exists(): return {'accounts': {}}
try: return json.loads(STATE_FILE.read_text())
except Exception: return {'accounts': {}}
def save_state(state):
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True))
os.chmod(STATE_FILE, 0o600)
def public_account(a, state):
s=state.get('accounts',{}).get(a['email'],{})
now=time.time(); disabled=float(s.get('disabledUntil') or 0)
status='cooldown' if disabled>now else ('disabled' if s.get('enabled') is False else 'ready')
return {'email': a['email'], 'status': status, 'lastUsedAt': int(s.get('lastUsedAt') or 0), 'lastLoginAt': int(s.get('lastLoginAt') or 0), 'disabledUntil': int(disabled), 'lastError': s.get('lastError','')}
def available_accounts(accounts, state):
now=time.time(); out=[]
for a in accounts:
s=state.get('accounts',{}).get(a['email'],{})
if s.get('enabled') is False: continue
if float(s.get('disabledUntil') or 0)>now: continue
out.append(a)
return out
def pick_account(preferred=None, exclude=None):
accounts=load_accounts(); state=load_state(); exclude=set(exclude or [])
pool=[a for a in available_accounts(accounts,state) if a['email'] not in exclude]
if preferred:
preferred=preferred.lower().strip()
for a in pool:
if a['email']==preferred: return a,state
if not pool: raise RuntimeError('no available 2925 account')
def key(a):
s=state.get('accounts',{}).get(a['email'],{})
return (float(s.get('lastUsedAt') or 0), a['email'])
return sorted(pool,key=key)[0], state
def generate_alias_for(account):
local=account['email'].split('@',1)[0].lower()
suffix=''.join(random.choice(string.ascii_lowercase+string.digits) for _ in range(SUFFIX_LEN))
alias=f'{local}{suffix}@2925.com'
assert alias.startswith(local) and alias.endswith('@2925.com')
return alias
def mark(email, **patch):
state=load_state(); d=state.setdefault('accounts',{}).setdefault(email.lower(),{})
d.update(patch); save_state(state)
def cmd_list(args):
accounts=load_accounts(); state=load_state()
print(json.dumps({'count':len(accounts),'accounts':[public_account(a,state) for a in accounts]}, ensure_ascii=False))
def cmd_pick(args):
a,state=pick_account(args.preferred, args.exclude or [])
alias=generate_alias_for(a)
mark(a['email'], lastUsedAt=int(time.time()), lastError='')
print(json.dumps({'email':a['email'],'alias':alias,'baseLocal':a['email'].split('@')[0],'aliasMatchesAccount':alias.startswith(a['email'].split('@')[0])}, ensure_ascii=False))
def cmd_limit(args):
email=args.email.lower().strip(); until=int(time.time()+COOLDOWN_SECONDS)
mark(email, lastLimitAt=int(time.time()), disabledUntil=until, lastError=args.reason or '子邮箱已达上限邮箱')
print(json.dumps({'email':email,'disabledUntil':until,'cooldownSeconds':COOLDOWN_SECONDS}, ensure_ascii=False))
def cmd_mark_login(args):
mark(args.email.lower().strip(), lastLoginAt=int(time.time()), lastError='')
print(json.dumps({'email':args.email.lower().strip(),'ok':True}, ensure_ascii=False))
def cmd_selftest(args):
accounts=load_accounts(); state=load_state()
failures=[]
for a in accounts:
for _ in range(5):
alias=generate_alias_for(a); local=a['email'].split('@')[0]
if not (alias.startswith(local) and alias.endswith('@2925.com') and len(alias.split('@')[0])==len(local)+SUFFIX_LEN):
failures.append({'email':a['email'],'alias':alias})
st=ACCOUNTS_FILE.stat()
ok_mode=(stat.S_IMODE(st.st_mode)==0o600)
print(json.dumps({'accounts':len(accounts),'aliasFailures':failures,'secretMode':oct(stat.S_IMODE(st.st_mode)),'ok':not failures and ok_mode}, ensure_ascii=False))
raise SystemExit(0 if not failures and ok_mode else 1)
def main():
p=argparse.ArgumentParser()
sub=p.add_subparsers(dest='cmd', required=True)
sub.add_parser('list').set_defaults(func=cmd_list)
q=sub.add_parser('pick'); q.add_argument('--preferred'); q.add_argument('--exclude', action='append'); q.set_defaults(func=cmd_pick)
l=sub.add_parser('mark-limit'); l.add_argument('email'); l.add_argument('--reason', default=''); l.set_defaults(func=cmd_limit)
m=sub.add_parser('mark-login'); m.add_argument('email'); m.set_defaults(func=cmd_mark_login)
sub.add_parser('selftest').set_defaults(func=cmd_selftest)
args=p.parse_args(); args.func(args)
if __name__=='__main__': main()