mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-05-06 20:02:51 +08:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -5,8 +5,8 @@
|
||||
# 监听主机(默认 0.0.0.0)
|
||||
# APP_HOST=0.0.0.0
|
||||
|
||||
# 监听端口(默认 8000)
|
||||
# APP_PORT=8000
|
||||
# 监听端口(默认 15555)
|
||||
# APP_PORT=15555
|
||||
|
||||
# Web UI 访问密钥(默认 admin123,强烈建议修改)
|
||||
# APP_ACCESS_PASSWORD=your_secret_password
|
||||
|
||||
@@ -4,12 +4,14 @@ FROM python:3.11-slim
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
ARG DEFAULT_WEBUI_PORT=15555
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# WebUI 默认配置
|
||||
WEBUI_HOST=0.0.0.0 \
|
||||
WEBUI_PORT=15555 \
|
||||
WEBUI_PORT=${DEFAULT_WEBUI_PORT} \
|
||||
LOG_LEVEL=info \
|
||||
DEBUG=0
|
||||
|
||||
@@ -30,7 +32,7 @@ RUN pip install --no-cache-dir --upgrade pip \
|
||||
COPY . .
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 15555
|
||||
EXPOSE ${DEFAULT_WEBUI_PORT}
|
||||
|
||||
# 启动 WebUI
|
||||
CMD ["python", "webui.py"]
|
||||
|
||||
47
README.md
47
README.md
@@ -104,6 +104,20 @@ cp .env.example .env
|
||||
|
||||
> 优先级:命令行参数 > 环境变量(`.env`)> 数据库设置 > 默认值
|
||||
|
||||
### 修改端口
|
||||
|
||||
默认端口是 `15555`。现在已经收敛到少数几个固定入口:
|
||||
|
||||
- 本地临时启动改端口:直接用 `python webui.py --port 18080`
|
||||
- 本地通过 `.env` 改端口:设置 `APP_PORT=18080`
|
||||
- 源码里的默认端口:修改 `src/config/constants.py` 里的 `DEFAULT_WEBUI_PORT`
|
||||
- Docker Compose 默认端口:修改 `docker-compose.yml` 顶部的 `x-webui-port`
|
||||
- Docker 镜像构建默认端口:修改 `Dockerfile` 里的 `ARG DEFAULT_WEBUI_PORT`
|
||||
|
||||
补充说明:
|
||||
- `src/config/constants.py` 的 `DEFAULT_WEBUI_PORT` 会同时影响默认 Web UI 端口、默认回调地址和 e2e 脚本默认地址。
|
||||
- `docker-compose.yml` 里已经把端口映射、容器内 `WEBUI_PORT` 和健康检查统一绑到同一个 `x-webui-port`,改一处就够。
|
||||
|
||||
### 启动 Web UI
|
||||
|
||||
```bash
|
||||
@@ -141,6 +155,18 @@ docker-compose up -d
|
||||
```
|
||||
你可以在 `docker-compose.yml` 中修改相关的环境变量,例如配置端口或者设置 `WEBUI_ACCESS_PASSWORD` 访问密码。
|
||||
|
||||
如果要修改 Docker Compose 对外端口,直接改文件顶部这一行即可:
|
||||
|
||||
```yaml
|
||||
x-webui-port: &webui-port 15555
|
||||
```
|
||||
|
||||
这一个值会同时同步到:
|
||||
|
||||
- 宿主机端口映射
|
||||
- 容器内 `WEBUI_PORT`
|
||||
- 健康检查访问地址
|
||||
|
||||
#### 直接使用 docker run
|
||||
|
||||
如果你不想使用 docker-compose,也可以直接拉取并运行镜像:
|
||||
@@ -165,6 +191,19 @@ docker run -d \
|
||||
|
||||
> **注意**:`-v $(pwd)/data:/app/data` 挂载参数非常重要,它确保了你的数据库文件和账户信息在容器重启或更新后不会丢失。
|
||||
|
||||
如果你要把容器端口改成 `18080`,`-p` 和 `WEBUI_PORT` 需要一起改:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 18080:18080 \
|
||||
-e WEBUI_HOST=0.0.0.0 \
|
||||
-e WEBUI_PORT=18080 \
|
||||
-e WEBUI_ACCESS_PASSWORD=your_secure_password \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--name codex-register \
|
||||
ghcr.io/yunxilyf/codex-register:latest
|
||||
```
|
||||
|
||||
### 使用远程 PostgreSQL
|
||||
|
||||
通过环境变量指定数据库连接字符串:
|
||||
@@ -335,7 +374,7 @@ docker-compose up -d
|
||||
|
||||
### 配置说明
|
||||
|
||||
**端口映射**:默认 `15555` 端口,可在 `docker-compose.yml` 中修改。
|
||||
**端口映射**:默认 `15555` 端口,修改 `docker-compose.yml` 顶部的 `x-webui-port` 即可。
|
||||
|
||||
**数据持久化**:
|
||||
```yaml
|
||||
@@ -347,9 +386,9 @@ volumes:
|
||||
**环境变量配置**:
|
||||
```yaml
|
||||
environment:
|
||||
- APP_ACCESS_PASSWORD=mypassword
|
||||
- APP_HOST=0.0.0.0
|
||||
- APP_PORT=15555
|
||||
WEBUI_ACCESS_PASSWORD: mypassword
|
||||
WEBUI_HOST: 0.0.0.0
|
||||
WEBUI_PORT: 15555
|
||||
```
|
||||
|
||||
### 常用命令
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
x-webui-port: &webui-port 15555
|
||||
|
||||
services:
|
||||
webui:
|
||||
build: .
|
||||
ports:
|
||||
- "15555:15555"
|
||||
- target: *webui-port
|
||||
published: *webui-port
|
||||
protocol: tcp
|
||||
environment:
|
||||
- WEBUI_HOST=0.0.0.0
|
||||
- WEBUI_PORT=15555
|
||||
- WEBUI_ACCESS_PASSWORD=admin123
|
||||
- DEBUG=0
|
||||
- LOG_LEVEL=info
|
||||
WEBUI_HOST: 0.0.0.0
|
||||
WEBUI_PORT: *webui-port
|
||||
WEBUI_ACCESS_PASSWORD: admin123
|
||||
DEBUG: 0
|
||||
LOG_LEVEL: info
|
||||
volumes:
|
||||
# 挂载数据目录以持久化数据库和日志
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import urllib.request; urllib.request.urlopen('http://127.0.0.1:15555/', timeout=5).read()
|
||||
- CMD-SHELL
|
||||
- python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ['WEBUI_PORT'] + '/', timeout=5).read()"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
@@ -48,6 +48,25 @@ class EmailServiceType(str, Enum):
|
||||
APP_NAME = "OpenAI/Codex CLI 自动注册系统"
|
||||
APP_VERSION = "2.0.0"
|
||||
APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统"
|
||||
DEFAULT_WEBUI_HOST = "0.0.0.0"
|
||||
DEFAULT_WEBUI_PORT = 15555
|
||||
DEFAULT_WEBUI_LOCAL_HOST = "127.0.0.1"
|
||||
|
||||
|
||||
def build_http_url(host: str, port: int, path: str = "") -> str:
|
||||
"""构造本地 HTTP URL。"""
|
||||
normalized_path = path if not path or path.startswith("/") else f"/{path}"
|
||||
return f"http://{host}:{port}{normalized_path}"
|
||||
|
||||
|
||||
def build_ws_url(host: str, port: int, path: str = "") -> str:
|
||||
"""构造本地 WebSocket URL。"""
|
||||
normalized_path = path if not path or path.startswith("/") else f"/{path}"
|
||||
return f"ws://{host}:{port}{normalized_path}"
|
||||
|
||||
|
||||
DEFAULT_WEBUI_BASE_URL = build_http_url(DEFAULT_WEBUI_LOCAL_HOST, DEFAULT_WEBUI_PORT)
|
||||
DEFAULT_WEBUI_WS_BASE_URL = build_ws_url(DEFAULT_WEBUI_LOCAL_HOST, DEFAULT_WEBUI_PORT)
|
||||
|
||||
# ============================================================================
|
||||
# OpenAI OAuth 相关常量
|
||||
@@ -57,7 +76,7 @@ APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统"
|
||||
OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
OAUTH_AUTH_URL = "https://auth.openai.com/oauth/authorize"
|
||||
OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
OAUTH_REDIRECT_URI = "http://localhost:15555/auth/callback"
|
||||
OAUTH_REDIRECT_URI = build_http_url("localhost", DEFAULT_WEBUI_PORT, "/auth/callback")
|
||||
OAUTH_SCOPE = "openid email profile offline_access"
|
||||
|
||||
# Codex CLI 专用 OAuth 参数(用于生成 Codex 兼容的 auth.json)
|
||||
@@ -280,8 +299,8 @@ DEFAULT_SETTINGS = [
|
||||
("registration.max_retries", "3", "最大重试次数", "registration"),
|
||||
("registration.timeout", "120", "超时时间(秒)", "registration"),
|
||||
("registration.default_password_length", "12", "默认密码长度", "registration"),
|
||||
("webui.host", "0.0.0.0", "Web UI 监听主机", "webui"),
|
||||
("webui.port", "15555", "Web UI 监听端口", "webui"),
|
||||
("webui.host", DEFAULT_WEBUI_HOST, "Web UI 监听主机", "webui"),
|
||||
("webui.port", str(DEFAULT_WEBUI_PORT), "Web UI 监听端口", "webui"),
|
||||
("webui.debug", "true", "调试模式", "webui"),
|
||||
]
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ from pydantic import BaseModel, field_validator
|
||||
from pydantic.types import SecretStr
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .constants import APP_NAME, APP_VERSION, DEFAULT_WEBUI_HOST, DEFAULT_WEBUI_PORT
|
||||
|
||||
|
||||
class SettingCategory(str, Enum):
|
||||
"""设置分类"""
|
||||
@@ -42,13 +44,13 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
|
||||
# 应用信息
|
||||
"app_name": SettingDefinition(
|
||||
db_key="app.name",
|
||||
default_value="OpenAI/Codex CLI 自动注册系统",
|
||||
default_value=APP_NAME,
|
||||
category=SettingCategory.GENERAL,
|
||||
description="应用名称"
|
||||
),
|
||||
"app_version": SettingDefinition(
|
||||
db_key="app.version",
|
||||
default_value="2.0.0",
|
||||
default_value=APP_VERSION,
|
||||
category=SettingCategory.GENERAL,
|
||||
description="应用版本"
|
||||
),
|
||||
@@ -70,13 +72,13 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
|
||||
# Web UI 配置
|
||||
"webui_host": SettingDefinition(
|
||||
db_key="webui.host",
|
||||
default_value="0.0.0.0",
|
||||
default_value=DEFAULT_WEBUI_HOST,
|
||||
category=SettingCategory.WEBUI,
|
||||
description="Web UI 监听地址"
|
||||
),
|
||||
"webui_port": SettingDefinition(
|
||||
db_key="webui.port",
|
||||
default_value=15555,
|
||||
default_value=DEFAULT_WEBUI_PORT,
|
||||
category=SettingCategory.WEBUI,
|
||||
description="Web UI 监听端口"
|
||||
),
|
||||
@@ -591,8 +593,8 @@ class Settings(BaseModel):
|
||||
"""
|
||||
|
||||
# 应用信息
|
||||
app_name: str = "OpenAI/Codex CLI 自动注册系统"
|
||||
app_version: str = "2.0.0"
|
||||
app_name: str = APP_NAME
|
||||
app_version: str = APP_VERSION
|
||||
debug: bool = False
|
||||
|
||||
# 数据库配置
|
||||
@@ -615,8 +617,8 @@ class Settings(BaseModel):
|
||||
return v
|
||||
|
||||
# Web UI 配置
|
||||
webui_host: str = "0.0.0.0"
|
||||
webui_port: int = 15555
|
||||
webui_host: str = DEFAULT_WEBUI_HOST
|
||||
webui_port: int = DEFAULT_WEBUI_PORT
|
||||
webui_secret_key: SecretStr = SecretStr("your-secret-key-change-in-production")
|
||||
webui_access_password: SecretStr = SecretStr("admin123")
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ def generate_team_link(
|
||||
"&elements_session_client[is_aggregation_expected]=false"
|
||||
"&client_attribution_metadata[merchant_integration_additional_elements][0]=payment"
|
||||
"&client_attribution_metadata[merchant_integration_additional_elements][1]=address"
|
||||
f"&key={data["publishable_key"]}"
|
||||
f"&key={data['publishable_key']}"
|
||||
,
|
||||
proxies=_build_proxies(proxy),
|
||||
timeout=30,
|
||||
|
||||
@@ -1693,6 +1693,10 @@ class RegistrationEngine:
|
||||
try:
|
||||
# 获取默认 client_id
|
||||
settings = get_settings()
|
||||
metadata = dict(result.metadata or {})
|
||||
verification_state = self.email_service.export_verification_state(result.email or self.email)
|
||||
if verification_state["used_codes"] or verification_state["seen_messages"]:
|
||||
metadata["verification_state"] = verification_state
|
||||
|
||||
with get_db() as db:
|
||||
# 保存账户信息
|
||||
@@ -1711,7 +1715,7 @@ class RegistrationEngine:
|
||||
id_token=result.id_token,
|
||||
cookies=result.cookies,
|
||||
proxy_used=self.proxy_url,
|
||||
extra_data=result.metadata,
|
||||
extra_data=metadata,
|
||||
source=result.source
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from enum import Enum
|
||||
|
||||
@@ -146,6 +147,8 @@ class BaseEmailService(abc.ABC):
|
||||
self._status = EmailServiceStatus.HEALTHY
|
||||
self._last_error = None
|
||||
self._provider_backoff = reset_adaptive_backoff()
|
||||
self._used_verification_codes: Dict[str, set] = {}
|
||||
self._seen_verification_messages: Dict[str, set] = {}
|
||||
|
||||
_EMAIL_ADDRESS_PATTERN = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||
|
||||
@@ -299,6 +302,140 @@ class BaseEmailService(abc.ABC):
|
||||
|
||||
return None
|
||||
|
||||
def _get_used_verification_codes(self, email: str) -> set:
|
||||
"""获取邮箱对应的已使用验证码集合。"""
|
||||
key = str(email or "").strip().lower()
|
||||
if key not in self._used_verification_codes:
|
||||
self._used_verification_codes[key] = set()
|
||||
return self._used_verification_codes[key]
|
||||
|
||||
def _get_seen_verification_messages(self, email: str) -> set:
|
||||
"""获取邮箱对应的已处理消息标识集合。"""
|
||||
key = str(email or "").strip().lower()
|
||||
if key not in self._seen_verification_messages:
|
||||
self._seen_verification_messages[key] = set()
|
||||
return self._seen_verification_messages[key]
|
||||
|
||||
def load_verification_state(
|
||||
self,
|
||||
email: str,
|
||||
used_codes: Optional[List[str]] = None,
|
||||
seen_messages: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""将持久化的验证码状态恢复到当前服务实例。"""
|
||||
if used_codes:
|
||||
self._get_used_verification_codes(email).update(
|
||||
str(code) for code in used_codes if code
|
||||
)
|
||||
if seen_messages:
|
||||
self._get_seen_verification_messages(email).update(
|
||||
str(marker) for marker in seen_messages if marker
|
||||
)
|
||||
|
||||
def export_verification_state(self, email: str) -> Dict[str, List[str]]:
|
||||
"""导出当前邮箱的验证码状态,用于跨请求复用。"""
|
||||
return {
|
||||
"used_codes": sorted(self._get_used_verification_codes(email)),
|
||||
"seen_messages": sorted(self._get_seen_verification_messages(email)),
|
||||
}
|
||||
|
||||
def _remember_verification_code(self, email: str, code: str) -> bool:
|
||||
"""记录验证码;若已用过则返回 False。"""
|
||||
used_codes = self._get_used_verification_codes(email)
|
||||
if code in used_codes:
|
||||
return False
|
||||
used_codes.add(code)
|
||||
return True
|
||||
|
||||
def _remember_verification_message(self, email: str, message_marker: Optional[str]) -> bool:
|
||||
"""记录消息标识;若已处理过则返回 False。"""
|
||||
if not message_marker:
|
||||
return True
|
||||
|
||||
seen_messages = self._get_seen_verification_messages(email)
|
||||
if message_marker in seen_messages:
|
||||
return False
|
||||
seen_messages.add(message_marker)
|
||||
return True
|
||||
|
||||
def _accept_verification_code(
|
||||
self,
|
||||
email: str,
|
||||
code: str,
|
||||
message_marker: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
决定是否接受验证码。
|
||||
|
||||
若有可靠的新邮件标识,优先按消息去重,这样新邮件即便验证码重复也能被接受;
|
||||
否则退回到按验证码去重,避免旧码被重复消费。
|
||||
"""
|
||||
if message_marker:
|
||||
if not self._remember_verification_message(email, message_marker):
|
||||
return False
|
||||
self._get_used_verification_codes(email).add(code)
|
||||
return True
|
||||
|
||||
return self._remember_verification_code(email, code)
|
||||
|
||||
def _parse_message_timestamp(self, value: Any) -> Optional[float]:
|
||||
"""将常见邮件时间字段解析为 Unix 时间戳。"""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
|
||||
if isinstance(value, datetime):
|
||||
return value.timestamp()
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return self._normalize_unix_timestamp(float(value))
|
||||
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
try:
|
||||
return self._normalize_unix_timestamp(float(text))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
normalized = text.replace("Z", "+00:00") if text.endswith("Z") else text
|
||||
try:
|
||||
return datetime.fromisoformat(normalized).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _normalize_unix_timestamp(self, value: float) -> float:
|
||||
"""将秒/毫秒/微秒级 Unix 时间统一归一到秒。"""
|
||||
absolute = abs(value)
|
||||
if absolute >= 1e14:
|
||||
return value / 1_000_000
|
||||
if absolute >= 1e11:
|
||||
return value / 1_000
|
||||
return value
|
||||
|
||||
def _is_message_before_otp(self, message_time: Any, otp_sent_at: Optional[float], tolerance_seconds: int = 1) -> bool:
|
||||
"""
|
||||
判断邮件是否早于当前 OTP 发送窗口。
|
||||
|
||||
允许少量时钟误差,避免接口时间与本地时间有轻微偏移时误伤新邮件。
|
||||
"""
|
||||
if not otp_sent_at:
|
||||
return False
|
||||
|
||||
message_ts = self._parse_message_timestamp(message_time)
|
||||
if message_ts is None:
|
||||
return False
|
||||
|
||||
return message_ts + tolerance_seconds < otp_sent_at
|
||||
|
||||
def _sort_items_by_message_time(self, items: List[Any], value_getter) -> List[Any]:
|
||||
"""按邮件时间倒序排列,优先处理最新邮件。"""
|
||||
return sorted(
|
||||
items,
|
||||
key=lambda item: self._parse_message_timestamp(value_getter(item)) or float("-inf"),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def wait_for_email(
|
||||
self,
|
||||
email: str,
|
||||
|
||||
@@ -271,7 +271,12 @@ class DuckMailService(BaseEmailService):
|
||||
)
|
||||
messages = response.get("hydra:member", [])
|
||||
|
||||
for message in messages:
|
||||
ordered_messages = self._sort_items_by_message_time(
|
||||
messages,
|
||||
lambda item: item.get("createdAt") if isinstance(item, dict) else None,
|
||||
)
|
||||
|
||||
for message in ordered_messages:
|
||||
message_id = str(message.get("id") or "").strip()
|
||||
if not message_id or message_id in seen_message_ids:
|
||||
continue
|
||||
@@ -281,6 +286,7 @@ class DuckMailService(BaseEmailService):
|
||||
continue
|
||||
|
||||
seen_message_ids.add(message_id)
|
||||
message_marker = f"id:{message_id}"
|
||||
detail = self._make_request(
|
||||
"GET",
|
||||
f"/messages/{message_id}",
|
||||
@@ -293,8 +299,11 @@ class DuckMailService(BaseEmailService):
|
||||
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
code = match.group(1)
|
||||
if not self._accept_verification_code(email, code, message_marker):
|
||||
continue
|
||||
self.update_status(True)
|
||||
return match.group(1)
|
||||
return code
|
||||
except Exception as e:
|
||||
logger.debug(f"DuckMail 轮询验证码失败: {e}")
|
||||
|
||||
|
||||
@@ -221,12 +221,29 @@ class FreemailService(BaseEmailService):
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
for mail in mails:
|
||||
ordered_mails = self._sort_items_by_message_time(
|
||||
mails,
|
||||
lambda item: (
|
||||
item.get("created_at")
|
||||
or item.get("createdAt")
|
||||
or item.get("received_at")
|
||||
or item.get("receivedAt")
|
||||
) if isinstance(item, dict) else None,
|
||||
)
|
||||
|
||||
for mail in ordered_mails:
|
||||
mail_id = mail.get("id")
|
||||
if not mail_id or mail_id in seen_mail_ids:
|
||||
continue
|
||||
|
||||
seen_mail_ids.add(mail_id)
|
||||
message_marker = f"id:{mail_id}"
|
||||
|
||||
if self._is_message_before_otp(
|
||||
mail.get("created_at") or mail.get("createdAt") or mail.get("received_at") or mail.get("receivedAt"),
|
||||
otp_sent_at,
|
||||
):
|
||||
continue
|
||||
|
||||
sender = str(mail.get("sender", "")).lower()
|
||||
subject = str(mail.get("subject", ""))
|
||||
@@ -239,25 +256,30 @@ class FreemailService(BaseEmailService):
|
||||
|
||||
code = self._extract_otp_from_text(content, pattern)
|
||||
if code:
|
||||
if not self._accept_verification_code(email, code, message_marker):
|
||||
continue
|
||||
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
|
||||
self.update_status(True)
|
||||
return code
|
||||
|
||||
v_code = str(mail.get("verification_code") or "").strip()
|
||||
|
||||
# 如果依然未找到,获取邮件详情进行匹配
|
||||
try:
|
||||
detail = self._make_request("GET", f"/api/email/{mail_id}")
|
||||
full_content = str(detail.get("content", "")) + "\n" + str(detail.get("html_content", ""))
|
||||
code = self._extract_otp_from_text(full_content, pattern)
|
||||
if code:
|
||||
if not self._accept_verification_code(email, code, message_marker):
|
||||
continue
|
||||
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
|
||||
self.update_status(True)
|
||||
return code
|
||||
except Exception as e:
|
||||
logger.debug(f"获取 Freemail 邮件详情失败: {e}")
|
||||
|
||||
v_code = str(mail.get("verification_code") or "").strip()
|
||||
if re.fullmatch(r"\d{6}", v_code):
|
||||
if not self._accept_verification_code(email, v_code, message_marker):
|
||||
continue
|
||||
logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {v_code}")
|
||||
self.update_status(True)
|
||||
return v_code
|
||||
|
||||
@@ -316,12 +316,29 @@ class MeoMailEmailService(BaseEmailService):
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
for message in messages:
|
||||
ordered_messages = self._sort_items_by_message_time(
|
||||
messages,
|
||||
lambda item: (
|
||||
item.get("created_at")
|
||||
or item.get("createdAt")
|
||||
or item.get("received_at")
|
||||
or item.get("receivedAt")
|
||||
) if isinstance(item, dict) else None,
|
||||
)
|
||||
|
||||
for message in ordered_messages:
|
||||
message_id = message.get("id")
|
||||
if not message_id or message_id in seen_message_ids:
|
||||
continue
|
||||
|
||||
seen_message_ids.add(message_id)
|
||||
message_marker = f"id:{message_id}"
|
||||
|
||||
if self._is_message_before_otp(
|
||||
message.get("created_at") or message.get("createdAt") or message.get("received_at") or message.get("receivedAt"),
|
||||
otp_sent_at,
|
||||
):
|
||||
continue
|
||||
|
||||
# 检查是否是目标邮件
|
||||
sender = str(message.get("from_address", "")).lower()
|
||||
@@ -343,6 +360,8 @@ class MeoMailEmailService(BaseEmailService):
|
||||
match = re.search(pattern, re.sub(email_pattern, "", content))
|
||||
if match:
|
||||
code = match.group(1)
|
||||
if not self._accept_verification_code(email, code, message_marker):
|
||||
continue
|
||||
logger.info(f"从自定义域名邮箱 {email} 找到验证码: {code}")
|
||||
self.update_status(True)
|
||||
return code
|
||||
|
||||
@@ -335,12 +335,29 @@ class TempMailService(BaseEmailService):
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
for mail in mails:
|
||||
ordered_mails = self._sort_items_by_message_time(
|
||||
mails,
|
||||
lambda item: (
|
||||
item.get("createdAt")
|
||||
or item.get("created_at")
|
||||
or item.get("receivedAt")
|
||||
or item.get("received_at")
|
||||
) if isinstance(item, dict) else None,
|
||||
)
|
||||
|
||||
for mail in ordered_mails:
|
||||
mail_id = mail.get("id")
|
||||
if not mail_id or mail_id in seen_mail_ids:
|
||||
continue
|
||||
|
||||
seen_mail_ids.add(mail_id)
|
||||
message_marker = f"id:{mail_id}"
|
||||
|
||||
if self._is_message_before_otp(
|
||||
mail.get("createdAt") or mail.get("created_at") or mail.get("receivedAt") or mail.get("received_at"),
|
||||
otp_sent_at,
|
||||
):
|
||||
continue
|
||||
|
||||
parsed = self._extract_mail_fields(mail)
|
||||
sender = parsed["sender"].lower()
|
||||
@@ -355,6 +372,8 @@ class TempMailService(BaseEmailService):
|
||||
|
||||
code = self._extract_otp_from_text(content, pattern)
|
||||
if code:
|
||||
if not self._accept_verification_code(email, code, message_marker):
|
||||
continue
|
||||
logger.info(f"从 TempMail 邮箱 {email} 找到验证码: {code}")
|
||||
self.update_status(True)
|
||||
return code
|
||||
|
||||
@@ -241,7 +241,12 @@ class TempmailService(BaseEmailService):
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
for msg in email_list:
|
||||
ordered_emails = self._sort_items_by_message_time(
|
||||
email_list,
|
||||
lambda item: item.get("date") if isinstance(item, dict) else None,
|
||||
)
|
||||
|
||||
for msg in ordered_emails:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
|
||||
@@ -260,6 +265,7 @@ class TempmailService(BaseEmailService):
|
||||
if not message_id or message_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(message_id)
|
||||
message_marker = f"id:{message_id}"
|
||||
|
||||
sender = str(msg.get("from", "")).lower()
|
||||
subject = str(msg.get("subject", ""))
|
||||
@@ -276,6 +282,8 @@ class TempmailService(BaseEmailService):
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
code = match.group(1)
|
||||
if not self._accept_verification_code(email, code, message_marker):
|
||||
continue
|
||||
logger.info(f"找到验证码: {code}")
|
||||
self.update_status(True)
|
||||
return code
|
||||
|
||||
@@ -1566,6 +1566,29 @@ def _build_inbox_config(db, service_type, email: str) -> dict:
|
||||
return cfg
|
||||
|
||||
|
||||
def _load_account_verification_state(account: Account) -> dict:
|
||||
"""从账号扩展信息中读取验证码去重状态。"""
|
||||
extra = account.extra_data or {}
|
||||
state = extra.get("verification_state") if isinstance(extra, dict) else {}
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
return {
|
||||
"used_codes": [str(code) for code in (state.get("used_codes") or []) if code],
|
||||
"seen_messages": [str(marker) for marker in (state.get("seen_messages") or []) if marker],
|
||||
}
|
||||
|
||||
|
||||
def _save_account_verification_state(db, account: Account, service) -> None:
|
||||
"""将当前收件箱消费状态持久化到账号表,支持跨请求延续。"""
|
||||
state = service.export_verification_state(account.email)
|
||||
if not state["used_codes"] and not state["seen_messages"]:
|
||||
return
|
||||
|
||||
extra = dict(account.extra_data or {})
|
||||
extra["verification_state"] = state
|
||||
crud.update_account(db, account.id, extra_data=extra)
|
||||
|
||||
|
||||
@router.post("/{account_id}/inbox-code")
|
||||
async def get_account_inbox_code(account_id: int):
|
||||
"""查询账号邮箱收件箱最新验证码"""
|
||||
@@ -1587,6 +1610,10 @@ async def get_account_inbox_code(account_id: int):
|
||||
|
||||
try:
|
||||
svc = EmailServiceFactory.create(service_type, config)
|
||||
svc.load_verification_state(
|
||||
account.email,
|
||||
**_load_account_verification_state(account),
|
||||
)
|
||||
code = svc.get_verification_code(
|
||||
account.email,
|
||||
email_id=account.email_service_id,
|
||||
@@ -1598,4 +1625,6 @@ async def get_account_inbox_code(account_id: int):
|
||||
if not code:
|
||||
return {"success": False, "error": "未收到验证码邮件"}
|
||||
|
||||
_save_account_verification_state(db, account, svc)
|
||||
|
||||
return {"success": True, "code": code, "email": account.email}
|
||||
|
||||
@@ -10,6 +10,8 @@ from typing import Any, Dict, List
|
||||
import httpx
|
||||
import websockets
|
||||
|
||||
from src.config.constants import DEFAULT_WEBUI_BASE_URL, DEFAULT_WEBUI_WS_BASE_URL
|
||||
|
||||
|
||||
STALE_ERROR = "服务启动时检测到未完成的历史任务,已标记失败,请重新发起。"
|
||||
|
||||
@@ -254,8 +256,8 @@ def run_verify_recovery_mode(base_url: str, db_path: Path, state_path: Path, rep
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="真实服务功能可用性验证脚本")
|
||||
parser.add_argument("--mode", choices=["live", "prepare-recovery", "verify-recovery"], required=True)
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:15555")
|
||||
parser.add_argument("--ws-url", default="ws://127.0.0.1:15555")
|
||||
parser.add_argument("--base-url", default=DEFAULT_WEBUI_BASE_URL)
|
||||
parser.add_argument("--ws-url", default=DEFAULT_WEBUI_WS_BASE_URL)
|
||||
parser.add_argument("--db-path", required=True)
|
||||
parser.add_argument("--report-path", default="tests_runtime/runtime_functionality_report.json")
|
||||
parser.add_argument("--state-path", default="tests_runtime/runtime_recovery_state.json")
|
||||
|
||||
156
tests/test_account_inbox_code_state.py
Normal file
156
tests/test_account_inbox_code_state.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import asyncio
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from src.config.constants import EmailServiceType
|
||||
from src.core.register import RegistrationEngine, RegistrationResult
|
||||
from src.database.models import Account, Base
|
||||
from src.database.session import DatabaseSessionManager
|
||||
from src.services.base import BaseEmailService
|
||||
from src.web.routes import accounts as accounts_routes
|
||||
|
||||
|
||||
class DummySettings:
|
||||
openai_client_id = "client-1"
|
||||
openai_auth_url = "https://auth.openai.test/authorize"
|
||||
openai_token_url = "https://auth.openai.test/token"
|
||||
openai_redirect_uri = "https://localhost/callback"
|
||||
openai_scope = "openid profile email offline_access"
|
||||
tempmail_base_url = "https://api.tempmail.test"
|
||||
tempmail_timeout = 30
|
||||
tempmail_max_retries = 3
|
||||
|
||||
|
||||
class FakeStatefulTempmailService(BaseEmailService):
|
||||
def __init__(self, config=None, name=None):
|
||||
super().__init__(EmailServiceType.TEMPMAIL, name)
|
||||
self.messages = [
|
||||
("id:msg-1", "111111"),
|
||||
("id:msg-2", "222222"),
|
||||
]
|
||||
|
||||
def create_email(self, config=None):
|
||||
return {"email": "tester@example.com", "service_id": "token-1"}
|
||||
|
||||
def get_verification_code(
|
||||
self,
|
||||
email: str,
|
||||
email_id: str = None,
|
||||
timeout: int = 120,
|
||||
pattern: str = r"(?<!\d)(\d{6})(?!\d)",
|
||||
otp_sent_at=None,
|
||||
):
|
||||
for marker, code in self.messages:
|
||||
if self._accept_verification_code(email, code, marker):
|
||||
return code
|
||||
return None
|
||||
|
||||
def list_emails(self, **kwargs):
|
||||
return []
|
||||
|
||||
def delete_email(self, email_id: str) -> bool:
|
||||
return True
|
||||
|
||||
def check_health(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _build_test_db(name: str) -> DatabaseSessionManager:
|
||||
runtime_dir = Path("tests_runtime")
|
||||
runtime_dir.mkdir(exist_ok=True)
|
||||
db_path = runtime_dir / name
|
||||
if db_path.exists():
|
||||
db_path.unlink()
|
||||
|
||||
manager = DatabaseSessionManager(f"sqlite:///{db_path}")
|
||||
Base.metadata.create_all(bind=manager.engine)
|
||||
return manager
|
||||
|
||||
|
||||
def test_account_inbox_code_persists_verification_state_across_requests(monkeypatch):
|
||||
manager = _build_test_db("account_inbox_code_state.db")
|
||||
|
||||
with manager.session_scope() as session:
|
||||
account = Account(
|
||||
email="tester@example.com",
|
||||
email_service="tempmail",
|
||||
email_service_id="token-1",
|
||||
status="active",
|
||||
extra_data={},
|
||||
)
|
||||
session.add(account)
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
account_id = account.id
|
||||
|
||||
@contextmanager
|
||||
def fake_get_db():
|
||||
session = manager.SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
monkeypatch.setattr(accounts_routes, "get_db", fake_get_db)
|
||||
monkeypatch.setattr(accounts_routes, "get_settings", lambda: DummySettings())
|
||||
monkeypatch.setattr(
|
||||
"src.services.base.EmailServiceFactory.create",
|
||||
lambda service_type, config, name=None: FakeStatefulTempmailService(config, name),
|
||||
)
|
||||
|
||||
first = asyncio.run(accounts_routes.get_account_inbox_code(account_id))
|
||||
second = asyncio.run(accounts_routes.get_account_inbox_code(account_id))
|
||||
|
||||
assert first["success"] is True
|
||||
assert first["code"] == "111111"
|
||||
assert second["success"] is True
|
||||
assert second["code"] == "222222"
|
||||
|
||||
with manager.session_scope() as session:
|
||||
saved = session.query(Account).filter(Account.id == account_id).first()
|
||||
verification_state = (saved.extra_data or {}).get("verification_state") or {}
|
||||
assert verification_state["used_codes"] == ["111111", "222222"]
|
||||
assert verification_state["seen_messages"] == ["id:msg-1", "id:msg-2"]
|
||||
|
||||
|
||||
def test_save_to_database_persists_verification_state(monkeypatch):
|
||||
manager = _build_test_db("registration_verification_state.db")
|
||||
|
||||
@contextmanager
|
||||
def fake_get_db():
|
||||
session = manager.SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
monkeypatch.setattr("src.core.register.get_db", fake_get_db)
|
||||
monkeypatch.setattr("src.core.register.get_settings", lambda: DummySettings())
|
||||
|
||||
email_service = FakeStatefulTempmailService()
|
||||
email_service._accept_verification_code("tester@example.com", "111111", "id:msg-1")
|
||||
|
||||
engine = RegistrationEngine(email_service=email_service, proxy_url="http://proxy.test")
|
||||
engine.email_info = {"service_id": "token-1"}
|
||||
|
||||
result = RegistrationResult(
|
||||
success=True,
|
||||
email="tester@example.com",
|
||||
password="secret",
|
||||
account_id="acct-1",
|
||||
workspace_id="ws-1",
|
||||
access_token="access-token",
|
||||
refresh_token="refresh-token",
|
||||
id_token="id-token",
|
||||
session_token="session-token",
|
||||
metadata={"registered_at": "2026-03-26T00:00:00"},
|
||||
source="register",
|
||||
)
|
||||
|
||||
assert engine.save_to_database(result) is True
|
||||
|
||||
with manager.session_scope() as session:
|
||||
saved = session.query(Account).filter(Account.email == "tester@example.com").first()
|
||||
verification_state = (saved.extra_data or {}).get("verification_state") or {}
|
||||
assert verification_state["used_codes"] == ["111111"]
|
||||
assert verification_state["seen_messages"] == ["id:msg-1"]
|
||||
19
tests/test_default_webui_port_config.py
Normal file
19
tests/test_default_webui_port_config.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from src.config.constants import (
|
||||
DEFAULT_SETTINGS,
|
||||
DEFAULT_WEBUI_BASE_URL,
|
||||
DEFAULT_WEBUI_PORT,
|
||||
DEFAULT_WEBUI_WS_BASE_URL,
|
||||
OAUTH_REDIRECT_URI,
|
||||
)
|
||||
from src.config.settings import SETTING_DEFINITIONS, Settings
|
||||
|
||||
|
||||
def test_default_webui_port_is_shared_from_one_constant():
|
||||
default_settings_map = {key: value for key, value, *_ in DEFAULT_SETTINGS}
|
||||
|
||||
assert SETTING_DEFINITIONS["webui_port"].default_value == DEFAULT_WEBUI_PORT
|
||||
assert Settings().webui_port == DEFAULT_WEBUI_PORT
|
||||
assert default_settings_map["webui.port"] == str(DEFAULT_WEBUI_PORT)
|
||||
assert DEFAULT_WEBUI_BASE_URL == f"http://127.0.0.1:{DEFAULT_WEBUI_PORT}"
|
||||
assert DEFAULT_WEBUI_WS_BASE_URL == f"ws://127.0.0.1:{DEFAULT_WEBUI_PORT}"
|
||||
assert OAUTH_REDIRECT_URI == f"http://localhost:{DEFAULT_WEBUI_PORT}/auth/callback"
|
||||
523
tests/test_mail_code_reuse_guard.py
Normal file
523
tests/test_mail_code_reuse_guard.py
Normal file
@@ -0,0 +1,523 @@
|
||||
from src.services.duck_mail import DuckMailService
|
||||
from src.services.freemail import FreemailService
|
||||
from src.services.moe_mail import MeoMailEmailService
|
||||
from src.services.temp_mail import TempMailService
|
||||
from src.services.tempmail import TempmailService
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text
|
||||
self.headers = {}
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json payload")
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeRequestHTTPClient:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
def request(self, method, url, **kwargs):
|
||||
self.calls.append({
|
||||
"method": method,
|
||||
"url": url,
|
||||
"kwargs": kwargs,
|
||||
})
|
||||
if not self.responses:
|
||||
raise AssertionError(f"未准备响应: {method} {url}")
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
class FakeGetHTTPClient:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
self.calls.append({
|
||||
"method": "GET",
|
||||
"url": url,
|
||||
"kwargs": kwargs,
|
||||
})
|
||||
if not self.responses:
|
||||
raise AssertionError(f"未准备响应: GET {url}")
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
def test_tempmail_service_skips_code_returned_by_previous_fetch():
|
||||
service = TempmailService({"base_url": "https://api.tempmail.test"})
|
||||
service.http_client = FakeGetHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"emails": [
|
||||
{
|
||||
"date": 1000,
|
||||
"from": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 111111",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
FakeResponse(
|
||||
payload={
|
||||
"emails": [
|
||||
{
|
||||
"date": 1000,
|
||||
"from": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 111111",
|
||||
},
|
||||
{
|
||||
"date": 1003,
|
||||
"from": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 654321",
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
])
|
||||
|
||||
first_code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
email_id="token-1",
|
||||
timeout=1,
|
||||
otp_sent_at=1000,
|
||||
)
|
||||
second_code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
email_id="token-1",
|
||||
timeout=1,
|
||||
otp_sent_at=1002,
|
||||
)
|
||||
|
||||
assert first_code == "111111"
|
||||
assert second_code == "654321"
|
||||
|
||||
|
||||
def test_temp_mail_service_skips_code_returned_by_previous_fetch():
|
||||
service = TempMailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_password": "admin-secret",
|
||||
"domain": "example.com",
|
||||
})
|
||||
service.http_client = FakeRequestHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"source": "OpenAI <noreply@openai.com>",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 111111",
|
||||
"createdAt": "2026-03-19T10:00:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"source": "OpenAI <noreply@openai.com>",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 111111",
|
||||
"createdAt": "2026-03-19T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "msg-2",
|
||||
"source": "OpenAI <noreply@openai.com>",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 654321",
|
||||
"createdAt": "2026-03-19T10:00:03Z",
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
])
|
||||
|
||||
first_code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
timeout=1,
|
||||
otp_sent_at=1742378400,
|
||||
)
|
||||
second_code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
timeout=1,
|
||||
otp_sent_at=1742378402,
|
||||
)
|
||||
|
||||
assert first_code == "111111"
|
||||
assert second_code == "654321"
|
||||
|
||||
|
||||
def test_temp_mail_service_accepts_same_code_from_newer_message():
|
||||
service = TempMailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_password": "admin-secret",
|
||||
"domain": "example.com",
|
||||
})
|
||||
service.http_client = FakeRequestHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"source": "OpenAI <noreply@openai.com>",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 111111",
|
||||
"createdAt": "2026-03-19T10:00:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"source": "OpenAI <noreply@openai.com>",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 111111",
|
||||
"createdAt": "2026-03-19T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "msg-2",
|
||||
"source": "OpenAI <noreply@openai.com>",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 111111",
|
||||
"createdAt": "2026-03-19T10:00:03Z",
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
])
|
||||
|
||||
first_code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
timeout=1,
|
||||
otp_sent_at=1742378400,
|
||||
)
|
||||
second_code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
timeout=1,
|
||||
otp_sent_at=1742378402,
|
||||
)
|
||||
|
||||
assert first_code == "111111"
|
||||
assert second_code == "111111"
|
||||
|
||||
|
||||
def test_freemail_service_skips_code_returned_by_previous_fetch():
|
||||
service = FreemailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_token": "jwt-token",
|
||||
})
|
||||
service.http_client = FakeRequestHTTPClient([
|
||||
FakeResponse(
|
||||
payload=[
|
||||
{
|
||||
"id": "msg-1",
|
||||
"sender": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"preview": "Your OpenAI verification code is 111111",
|
||||
"verification_code": "111111",
|
||||
"created_at": "2026-03-19T10:00:00Z",
|
||||
}
|
||||
]
|
||||
),
|
||||
FakeResponse(
|
||||
payload=[
|
||||
{
|
||||
"id": "msg-1",
|
||||
"sender": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"preview": "Your OpenAI verification code is 111111",
|
||||
"verification_code": "111111",
|
||||
"created_at": "2026-03-19T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "msg-2",
|
||||
"sender": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"preview": "Your OpenAI verification code is 654321",
|
||||
"verification_code": "654321",
|
||||
"created_at": "2026-03-19T10:00:03Z",
|
||||
},
|
||||
]
|
||||
),
|
||||
])
|
||||
|
||||
first_code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
timeout=1,
|
||||
otp_sent_at=1742378400,
|
||||
)
|
||||
second_code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
timeout=1,
|
||||
otp_sent_at=1742378402,
|
||||
)
|
||||
|
||||
assert first_code == "111111"
|
||||
assert second_code == "654321"
|
||||
|
||||
|
||||
def test_duck_mail_service_skips_previously_used_code_even_with_small_timestamp_gap():
|
||||
service = DuckMailService({
|
||||
"base_url": "https://api.duckmail.test",
|
||||
"default_domain": "duckmail.sbs",
|
||||
})
|
||||
service.http_client = FakeRequestHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"hydra:member": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"from": {
|
||||
"name": "OpenAI",
|
||||
"address": "noreply@openai.com",
|
||||
},
|
||||
"subject": "Your verification code",
|
||||
"createdAt": "2026-03-19T10:00:01Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
FakeResponse(
|
||||
payload={
|
||||
"id": "msg-1",
|
||||
"text": "Your OpenAI verification code is 111111",
|
||||
"html": [],
|
||||
}
|
||||
),
|
||||
FakeResponse(
|
||||
payload={
|
||||
"hydra:member": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"from": {
|
||||
"name": "OpenAI",
|
||||
"address": "noreply@openai.com",
|
||||
},
|
||||
"subject": "Your verification code",
|
||||
"createdAt": "2026-03-19T10:00:01Z",
|
||||
},
|
||||
{
|
||||
"id": "msg-2",
|
||||
"from": {
|
||||
"name": "OpenAI",
|
||||
"address": "noreply@openai.com",
|
||||
},
|
||||
"subject": "Your verification code",
|
||||
"createdAt": "2026-03-19T10:00:03Z",
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
FakeResponse(
|
||||
payload={
|
||||
"id": "msg-2",
|
||||
"text": "Your OpenAI verification code is 654321",
|
||||
"html": [],
|
||||
}
|
||||
),
|
||||
FakeResponse(
|
||||
payload={
|
||||
"id": "msg-1",
|
||||
"text": "Your OpenAI verification code is 111111",
|
||||
"html": [],
|
||||
}
|
||||
),
|
||||
])
|
||||
service._accounts_by_email["tester@duckmail.sbs"] = {
|
||||
"email": "tester@duckmail.sbs",
|
||||
"service_id": "account-1",
|
||||
"account_id": "account-1",
|
||||
"token": "token-123",
|
||||
}
|
||||
|
||||
first_code = service.get_verification_code(
|
||||
email="tester@duckmail.sbs",
|
||||
email_id="account-1",
|
||||
timeout=1,
|
||||
otp_sent_at=1742378401,
|
||||
)
|
||||
second_code = service.get_verification_code(
|
||||
email="tester@duckmail.sbs",
|
||||
email_id="account-1",
|
||||
timeout=1,
|
||||
otp_sent_at=1742378402,
|
||||
)
|
||||
|
||||
assert first_code == "111111"
|
||||
assert second_code == "654321"
|
||||
|
||||
|
||||
def test_moe_mail_service_filters_old_messages_with_millisecond_timestamps():
|
||||
service = MeoMailEmailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"api_key": "api-key",
|
||||
})
|
||||
|
||||
def fake_make_request(method, endpoint, **kwargs):
|
||||
if endpoint == "/api/emails/email-1":
|
||||
return {
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-old",
|
||||
"from_address": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"received_at": 1742378400000,
|
||||
},
|
||||
{
|
||||
"id": "msg-new",
|
||||
"from_address": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"received_at": 1742378403000,
|
||||
},
|
||||
]
|
||||
}
|
||||
if endpoint == "/api/emails/email-1/msg-old":
|
||||
return {
|
||||
"message": {
|
||||
"content": "Your OpenAI verification code is 111111",
|
||||
}
|
||||
}
|
||||
if endpoint == "/api/emails/email-1/msg-new":
|
||||
return {
|
||||
"message": {
|
||||
"content": "Your OpenAI verification code is 654321",
|
||||
}
|
||||
}
|
||||
raise AssertionError(f"未准备响应: {method} {endpoint}")
|
||||
|
||||
service._make_request = fake_make_request
|
||||
|
||||
code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
email_id="email-1",
|
||||
timeout=1,
|
||||
otp_sent_at=1742378402,
|
||||
)
|
||||
|
||||
assert code == "654321"
|
||||
|
||||
|
||||
def test_moe_mail_service_cross_request_state_prefers_latest_of_three_messages():
|
||||
first_service = MeoMailEmailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"api_key": "api-key",
|
||||
})
|
||||
|
||||
first_responses = [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"from_address": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"received_at": 1742378400000,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
"message": {
|
||||
"content": "Your OpenAI verification code is 111111",
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
def fake_make_request_first(method, endpoint, **kwargs):
|
||||
if not first_responses:
|
||||
raise AssertionError(f"未准备响应: {method} {endpoint}")
|
||||
return first_responses.pop(0)
|
||||
|
||||
first_service._make_request = fake_make_request_first
|
||||
|
||||
first_code = first_service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
email_id="email-1",
|
||||
timeout=1,
|
||||
)
|
||||
state = first_service.export_verification_state("tester@example.com")
|
||||
|
||||
second_service = MeoMailEmailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"api_key": "api-key",
|
||||
})
|
||||
second_service.load_verification_state("tester@example.com", **state)
|
||||
|
||||
second_calls = []
|
||||
second_responses = {
|
||||
"/api/emails/email-1": {
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"from_address": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"received_at": 1742378400000,
|
||||
},
|
||||
{
|
||||
"id": "msg-2",
|
||||
"from_address": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"received_at": 1742378403000,
|
||||
},
|
||||
{
|
||||
"id": "msg-3",
|
||||
"from_address": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"received_at": 1742378406000,
|
||||
},
|
||||
]
|
||||
},
|
||||
"/api/emails/email-1/msg-3": {
|
||||
"message": {
|
||||
"content": "Your OpenAI verification code is 333333",
|
||||
}
|
||||
},
|
||||
"/api/emails/email-1/msg-2": {
|
||||
"message": {
|
||||
"content": "Your OpenAI verification code is 222222",
|
||||
}
|
||||
},
|
||||
"/api/emails/email-1/msg-1": {
|
||||
"message": {
|
||||
"content": "Your OpenAI verification code is 111111",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
def fake_make_request_second(method, endpoint, **kwargs):
|
||||
second_calls.append(endpoint)
|
||||
if endpoint not in second_responses:
|
||||
raise AssertionError(f"未准备响应: {method} {endpoint}")
|
||||
return second_responses[endpoint]
|
||||
|
||||
second_service._make_request = fake_make_request_second
|
||||
|
||||
second_code = second_service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
email_id="email-1",
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
assert first_code == "111111"
|
||||
assert state == {
|
||||
"used_codes": ["111111"],
|
||||
"seen_messages": ["id:msg-1"],
|
||||
}
|
||||
assert second_code == "333333"
|
||||
assert second_calls == [
|
||||
"/api/emails/email-1",
|
||||
"/api/emails/email-1/msg-3",
|
||||
]
|
||||
258
tests/test_mail_latest_code_preference.py
Normal file
258
tests/test_mail_latest_code_preference.py
Normal file
@@ -0,0 +1,258 @@
|
||||
from src.services.duck_mail import DuckMailService
|
||||
from src.services.freemail import FreemailService
|
||||
from src.services.moe_mail import MeoMailEmailService
|
||||
from src.services.temp_mail import TempMailService
|
||||
from src.services.tempmail import TempmailService
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text
|
||||
self.headers = {}
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json payload")
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeRequestHTTPClient:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
def request(self, method, url, **kwargs):
|
||||
self.calls.append({
|
||||
"method": method,
|
||||
"url": url,
|
||||
"kwargs": kwargs,
|
||||
})
|
||||
if not self.responses:
|
||||
raise AssertionError(f"未准备响应: {method} {url}")
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
class FakeGetHTTPClient:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
self.calls.append({
|
||||
"method": "GET",
|
||||
"url": url,
|
||||
"kwargs": kwargs,
|
||||
})
|
||||
if not self.responses:
|
||||
raise AssertionError(f"未准备响应: GET {url}")
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
def test_tempmail_service_prefers_latest_matching_message_without_otp_timestamp():
|
||||
service = TempmailService({"base_url": "https://api.tempmail.test"})
|
||||
service.http_client = FakeGetHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"emails": [
|
||||
{
|
||||
"date": 1000,
|
||||
"from": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 111111",
|
||||
},
|
||||
{
|
||||
"date": 1003,
|
||||
"from": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 654321",
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
])
|
||||
|
||||
code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
email_id="token-1",
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
assert code == "654321"
|
||||
|
||||
|
||||
def test_temp_mail_service_prefers_latest_matching_message_without_otp_timestamp():
|
||||
service = TempMailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_password": "admin-secret",
|
||||
"domain": "example.com",
|
||||
})
|
||||
service.http_client = FakeRequestHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"source": "OpenAI <noreply@openai.com>",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 111111",
|
||||
"createdAt": "2026-03-19T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "msg-2",
|
||||
"source": "OpenAI <noreply@openai.com>",
|
||||
"subject": "Your verification code",
|
||||
"body": "Your OpenAI verification code is 654321",
|
||||
"createdAt": "2026-03-19T10:00:03Z",
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
])
|
||||
|
||||
code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
assert code == "654321"
|
||||
|
||||
|
||||
def test_moe_mail_service_prefers_latest_matching_message_without_otp_timestamp():
|
||||
service = MeoMailEmailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"api_key": "api-key",
|
||||
})
|
||||
|
||||
def fake_make_request(method, endpoint, **kwargs):
|
||||
if endpoint == "/api/emails/email-1":
|
||||
return {
|
||||
"messages": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"from_address": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"received_at": 1742378400000,
|
||||
},
|
||||
{
|
||||
"id": "msg-2",
|
||||
"from_address": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"received_at": 1742378403000,
|
||||
},
|
||||
]
|
||||
}
|
||||
if endpoint == "/api/emails/email-1/msg-1":
|
||||
return {
|
||||
"message": {
|
||||
"content": "Your OpenAI verification code is 111111",
|
||||
}
|
||||
}
|
||||
if endpoint == "/api/emails/email-1/msg-2":
|
||||
return {
|
||||
"message": {
|
||||
"content": "Your OpenAI verification code is 654321",
|
||||
}
|
||||
}
|
||||
raise AssertionError(f"未准备响应: {method} {endpoint}")
|
||||
|
||||
service._make_request = fake_make_request
|
||||
|
||||
code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
email_id="email-1",
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
assert code == "654321"
|
||||
|
||||
|
||||
def test_freemail_service_prefers_latest_matching_message_without_otp_timestamp():
|
||||
service = FreemailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_token": "jwt-token",
|
||||
})
|
||||
service.http_client = FakeRequestHTTPClient([
|
||||
FakeResponse(
|
||||
payload=[
|
||||
{
|
||||
"id": "msg-1",
|
||||
"sender": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"preview": "Your OpenAI verification code is 111111",
|
||||
"verification_code": "111111",
|
||||
"created_at": "2026-03-19T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "msg-2",
|
||||
"sender": "noreply@openai.com",
|
||||
"subject": "Your verification code",
|
||||
"preview": "Your OpenAI verification code is 654321",
|
||||
"verification_code": "654321",
|
||||
"created_at": "2026-03-19T10:00:03Z",
|
||||
},
|
||||
]
|
||||
),
|
||||
])
|
||||
|
||||
code = service.get_verification_code(
|
||||
email="tester@example.com",
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
assert code == "654321"
|
||||
|
||||
|
||||
def test_duck_mail_service_prefers_latest_matching_message_without_otp_timestamp():
|
||||
service = DuckMailService({
|
||||
"base_url": "https://api.duckmail.test",
|
||||
"default_domain": "duckmail.sbs",
|
||||
})
|
||||
service.http_client = FakeRequestHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"hydra:member": [
|
||||
{
|
||||
"id": "msg-1",
|
||||
"from": {
|
||||
"name": "OpenAI",
|
||||
"address": "noreply@openai.com",
|
||||
},
|
||||
"subject": "Your verification code",
|
||||
"createdAt": "2026-03-19T10:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": "msg-2",
|
||||
"from": {
|
||||
"name": "OpenAI",
|
||||
"address": "noreply@openai.com",
|
||||
},
|
||||
"subject": "Your verification code",
|
||||
"createdAt": "2026-03-19T10:00:03Z",
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
FakeResponse(
|
||||
payload={
|
||||
"id": "msg-2",
|
||||
"text": "Your OpenAI verification code is 654321",
|
||||
"html": [],
|
||||
}
|
||||
),
|
||||
])
|
||||
service._accounts_by_email["tester@duckmail.sbs"] = {
|
||||
"email": "tester@duckmail.sbs",
|
||||
"service_id": "account-1",
|
||||
"account_id": "account-1",
|
||||
"token": "token-123",
|
||||
}
|
||||
|
||||
code = service.get_verification_code(
|
||||
email="tester@duckmail.sbs",
|
||||
email_id="account-1",
|
||||
timeout=1,
|
||||
)
|
||||
|
||||
assert code == "654321"
|
||||
92
tests/test_register_otp_integration.py
Normal file
92
tests/test_register_otp_integration.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.core.register import RegistrationEngine
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text
|
||||
self.headers = {}
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json payload")
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self):
|
||||
self.get_calls = []
|
||||
self.post_calls = []
|
||||
self.cookies = SimpleNamespace(get=lambda name: None)
|
||||
|
||||
def get(self, url, **kwargs):
|
||||
self.get_calls.append({
|
||||
"url": url,
|
||||
"kwargs": kwargs,
|
||||
})
|
||||
return FakeResponse(status_code=200)
|
||||
|
||||
def post(self, url, **kwargs):
|
||||
self.post_calls.append({
|
||||
"url": url,
|
||||
"kwargs": kwargs,
|
||||
})
|
||||
if url.endswith("/email-otp/validate"):
|
||||
code = json.loads(kwargs["data"])["code"]
|
||||
return FakeResponse(status_code=200 if code == "654321" else 401)
|
||||
return FakeResponse(status_code=200)
|
||||
|
||||
|
||||
class FakeEmailService:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def get_verification_code(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
if len(self.calls) == 1:
|
||||
return None
|
||||
return "654321"
|
||||
|
||||
|
||||
def test_registration_engine_resend_flow_propagates_new_otp_timestamp_and_validates_code(monkeypatch):
|
||||
engine = RegistrationEngine.__new__(RegistrationEngine)
|
||||
engine.logs = []
|
||||
engine._log = lambda message, level="info": None
|
||||
engine.email = "tester@example.com"
|
||||
engine.email_info = {"service_id": "email-1"}
|
||||
engine.email_service = FakeEmailService()
|
||||
engine.session = FakeSession()
|
||||
engine._otp_sent_at = None
|
||||
engine.phase_history = []
|
||||
|
||||
issued_timestamps = iter([1000.0, 1000.0, 1000.0, 1005.0, 1005.0, 1005.0])
|
||||
monkeypatch.setattr("src.core.register.time.time", lambda: next(issued_timestamps))
|
||||
|
||||
assert engine._send_verification_code() is True
|
||||
first_otp_sent_at = engine._otp_sent_at
|
||||
first_code = engine._get_verification_code()
|
||||
|
||||
assert first_otp_sent_at == 1000.0
|
||||
assert first_code is None
|
||||
|
||||
assert engine._send_verification_code() is True
|
||||
second_otp_sent_at = engine._otp_sent_at
|
||||
second_code = engine._get_verification_code()
|
||||
|
||||
assert second_otp_sent_at == 1005.0
|
||||
assert second_code == "654321"
|
||||
assert engine._validate_verification_code(second_code) is True
|
||||
|
||||
assert len(engine.email_service.calls) == 2
|
||||
assert engine.email_service.calls[0]["otp_sent_at"] == 1000.0
|
||||
assert engine.email_service.calls[1]["otp_sent_at"] == 1005.0
|
||||
assert engine.email_service.calls[0]["email_id"] == "email-1"
|
||||
assert engine.email_service.calls[1]["email_id"] == "email-1"
|
||||
|
||||
validate_call = engine.session.post_calls[-1]
|
||||
assert validate_call["url"].endswith("/email-otp/validate")
|
||||
assert json.loads(validate_call["kwargs"]["data"]) == {"code": "654321"}
|
||||
Reference in New Issue
Block a user