Add 2925 account pool binding helper
This commit is contained in:
@@ -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.
|
||||||
Executable
+137
@@ -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()
|
||||||
Reference in New Issue
Block a user