feat: add mail service for managing email configurations, generating accounts, and fetching emails with verification code support.
This commit is contained in:
@@ -16,6 +16,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codex-pool/internal/mail"
|
||||||
|
|
||||||
utls "github.com/refraction-networking/utls"
|
utls "github.com/refraction-networking/utls"
|
||||||
"golang.org/x/net/http2"
|
"golang.org/x/net/http2"
|
||||||
)
|
)
|
||||||
@@ -513,17 +515,61 @@ func (c *CodexAPIAuth) ObtainAuthorizationCode() (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果需要邮箱验证,这是新账号的问题
|
// 如果需要邮箱验证,尝试获取验证码并完成验证
|
||||||
if nextPageType == "email_otp_verification" {
|
if nextPageType == "email_otp_verification" {
|
||||||
c.logError(StepInputPassword, "账号需要邮箱验证,无法继续 Codex 授权流程")
|
c.logStep(StepInputPassword, "账号需要邮箱验证,正在获取验证码...")
|
||||||
return "", fmt.Errorf("账号需要邮箱验证,请使用浏览器模式或等待账号状态更新")
|
|
||||||
|
// 等待获取验证码 (最多60秒)
|
||||||
|
// 邮件标题格式: "Your ChatGPT code is 016547"
|
||||||
|
otpCode, err := mail.GetVerificationCode(c.email, 60*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
c.logError(StepInputPassword, "获取验证码失败: %v", err)
|
||||||
|
return "", fmt.Errorf("获取验证码失败: %v", err)
|
||||||
|
}
|
||||||
|
c.logStep(StepInputPassword, "获取到验证码: %s", otpCode)
|
||||||
|
|
||||||
|
// 提交验证码到 /api/accounts/email-otp/validate
|
||||||
|
// 先获取 Sentinel token (可能需要 PoW)
|
||||||
|
if !c.callSentinelReq("email_otp_verification__auto") {
|
||||||
|
// 如果失败,尝试 password_verify__auto
|
||||||
|
if !c.callSentinelReq("password_verify__auto") {
|
||||||
|
return "", fmt.Errorf("Sentinel 请求失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyOtpHeaders := make(map[string]string)
|
||||||
|
for k, v := range headers {
|
||||||
|
verifyOtpHeaders[k] = v
|
||||||
|
}
|
||||||
|
verifyOtpHeaders["OpenAI-Sentinel-Token"] = c.getSentinelHeader("email_otp_validate")
|
||||||
|
|
||||||
|
// 请求体格式: {"code":"016547"}
|
||||||
|
otpPayload := map[string]string{
|
||||||
|
"code": otpCode,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body, err = c.doRequest("POST", "https://auth.openai.com/api/accounts/email-otp/validate", otpPayload, verifyOtpHeaders)
|
||||||
|
if err != nil || resp.StatusCode != 200 {
|
||||||
|
c.logError(StepInputPassword, "验证码验证失败: %d - %s", resp.StatusCode, string(body[:min(200, len(body))]))
|
||||||
|
return "", fmt.Errorf("验证码验证失败: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
c.logStep(StepInputPassword, "邮箱验证成功")
|
||||||
|
|
||||||
|
// 重新解析响应,检查下一步
|
||||||
|
json.Unmarshal(body, &data)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
c.logStep(StepInputPassword, "跳过密码验证步骤 (服务器未要求)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 选择工作区
|
// 6. 选择工作区
|
||||||
c.logStep(StepSelectWorkspace, "选择工作区: %s", c.workspaceID)
|
c.logStep(StepSelectWorkspace, "选择工作区: %s", c.workspaceID)
|
||||||
if !c.callSentinelReq("password_verify__auto") {
|
// 根据前面的流程选择正确的 Sentinel 请求
|
||||||
return "", fmt.Errorf("Sentinel 请求失败")
|
if !c.callSentinelReq("email_otp_validate__auto") {
|
||||||
|
// 如果 email_otp_validate__auto 失败,尝试 password_verify__auto
|
||||||
|
if !c.callSentinelReq("password_verify__auto") {
|
||||||
|
return "", fmt.Errorf("Sentinel 请求失败")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选择工作区时带上 Sentinel Header
|
// 选择工作区时带上 Sentinel Header
|
||||||
|
|||||||
@@ -363,21 +363,30 @@ func (m *Client) WaitForCode(email string, timeout time.Duration) (string, error
|
|||||||
for _, mail := range emails {
|
for _, mail := range emails {
|
||||||
subject := strings.ToLower(mail.Subject)
|
subject := strings.ToLower(mail.Subject)
|
||||||
// 匹配多种可能的验证码邮件主题
|
// 匹配多种可能的验证码邮件主题
|
||||||
|
// 包括 "Your ChatGPT code is 016547" 格式
|
||||||
isCodeEmail := strings.Contains(subject, "code") ||
|
isCodeEmail := strings.Contains(subject, "code") ||
|
||||||
strings.Contains(subject, "verify") ||
|
strings.Contains(subject, "verify") ||
|
||||||
strings.Contains(subject, "verification") ||
|
strings.Contains(subject, "verification") ||
|
||||||
strings.Contains(subject, "openai") ||
|
strings.Contains(subject, "openai") ||
|
||||||
strings.Contains(subject, "confirm")
|
strings.Contains(subject, "confirm") ||
|
||||||
|
strings.Contains(subject, "chatgpt")
|
||||||
|
|
||||||
if !isCodeEmail {
|
if !isCodeEmail {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先从标题中提取验证码 (如 "Your ChatGPT code is 016547")
|
||||||
|
matches := codeRegex.FindStringSubmatch(mail.Subject)
|
||||||
|
if len(matches) >= 2 {
|
||||||
|
return matches[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果标题中没有,从内容中提取
|
||||||
content := mail.Content
|
content := mail.Content
|
||||||
if content == "" {
|
if content == "" {
|
||||||
content = mail.Text
|
content = mail.Text
|
||||||
}
|
}
|
||||||
matches := codeRegex.FindStringSubmatch(content)
|
matches = codeRegex.FindStringSubmatch(content)
|
||||||
if len(matches) >= 2 {
|
if len(matches) >= 2 {
|
||||||
return matches[1], nil
|
return matches[1], nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Menu, Moon, Sun } from 'lucide-react'
|
import { Menu, Moon, Sun, Database, UserPlus } from 'lucide-react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTeamStatus } from '../../hooks/useTeamStatus'
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onMenuClick: () => void
|
onMenuClick: () => void
|
||||||
@@ -8,6 +9,7 @@ interface HeaderProps {
|
|||||||
|
|
||||||
export default function Header({ onMenuClick, isConnected = false }: HeaderProps) {
|
export default function Header({ onMenuClick, isConnected = false }: HeaderProps) {
|
||||||
const [isDark, setIsDark] = useState(false)
|
const [isDark, setIsDark] = useState(false)
|
||||||
|
const teamStatus = useTeamStatus()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check for saved theme preference or system preference
|
// Check for saved theme preference or system preference
|
||||||
@@ -55,6 +57,23 @@ export default function Header({ onMenuClick, isConnected = false }: HeaderProps
|
|||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Team Status Indicators */}
|
||||||
|
{teamStatus.isProcessing && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-900/50">
|
||||||
|
<Database className="h-4 w-4 animate-pulse" />
|
||||||
|
<span className="hidden sm:inline">入库中</span>
|
||||||
|
<span className="font-bold">{teamStatus.processingTeams}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{teamStatus.isRegistering && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium bg-purple-50 text-purple-700 border border-purple-200 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-900/50">
|
||||||
|
<UserPlus className="h-4 w-4 animate-pulse" />
|
||||||
|
<span className="hidden sm:inline">注册中</span>
|
||||||
|
<span className="font-bold">{teamStatus.registeringTeams}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Connection status */}
|
{/* Connection status */}
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${isConnected
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${isConnected
|
||||||
|
|||||||
90
frontend/src/hooks/useTeamStatus.ts
Normal file
90
frontend/src/hooks/useTeamStatus.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface TeamProcessStatus {
|
||||||
|
running: boolean
|
||||||
|
total_teams: number
|
||||||
|
completed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamRegStatus {
|
||||||
|
running: boolean
|
||||||
|
config?: {
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamStatus {
|
||||||
|
// 入库状态
|
||||||
|
processingTeams: number // 正在入库的team数量
|
||||||
|
processedTeams: number // 已完成入库的team数量
|
||||||
|
isProcessing: boolean // 是否正在入库
|
||||||
|
// 注册状态
|
||||||
|
registeringTeams: number // 正在注册的team数量
|
||||||
|
isRegistering: boolean // 是否正在注册
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTeamStatus(pollInterval = 3000) {
|
||||||
|
const [status, setStatus] = useState<TeamStatus>({
|
||||||
|
processingTeams: 0,
|
||||||
|
processedTeams: 0,
|
||||||
|
isProcessing: false,
|
||||||
|
registeringTeams: 0,
|
||||||
|
isRegistering: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// 并行获取两个状态
|
||||||
|
const [processRes, regRes] = await Promise.all([
|
||||||
|
fetch('/api/team/status').catch(() => null),
|
||||||
|
fetch('/api/team-reg/status').catch(() => null),
|
||||||
|
])
|
||||||
|
|
||||||
|
let newStatus: TeamStatus = {
|
||||||
|
processingTeams: 0,
|
||||||
|
processedTeams: 0,
|
||||||
|
isProcessing: false,
|
||||||
|
registeringTeams: 0,
|
||||||
|
isRegistering: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析入库状态
|
||||||
|
if (processRes?.ok) {
|
||||||
|
const data = await processRes.json()
|
||||||
|
if (data.code === 0 && data.data) {
|
||||||
|
const processData = data.data as TeamProcessStatus
|
||||||
|
newStatus.isProcessing = processData.running
|
||||||
|
newStatus.processingTeams = processData.running
|
||||||
|
? processData.total_teams - processData.completed
|
||||||
|
: 0
|
||||||
|
newStatus.processedTeams = processData.completed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析注册状态
|
||||||
|
if (regRes?.ok) {
|
||||||
|
const regData = await regRes.json() as TeamRegStatus
|
||||||
|
newStatus.isRegistering = regData.running
|
||||||
|
newStatus.registeringTeams = regData.running && regData.config
|
||||||
|
? regData.config.count
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(newStatus)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch team status:', error)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 立即获取一次
|
||||||
|
fetchStatus()
|
||||||
|
|
||||||
|
// 定时轮询
|
||||||
|
const interval = setInterval(fetchStatus, pollInterval)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchStatus, pollInterval])
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user