- Add `Domains` field to MailServiceConfig for managing additional domains under single API - Implement `matchDomain` helper function for precise and subdomain matching logic - Update `GetServiceByDomain` to check both primary domain and additional domains list - Enhance EmailConfig UI to display domain count and allow comma-separated domain input - Add domains field to mail service request/response structures in API handlers - Update frontend types to include domains array in MailService interface - Improve documentation with clarification on primary vs additional domains usage - Allows single mail service API to manage multiple email domains for verification and operations
606 lines
16 KiB
Go
606 lines
16 KiB
Go
package mail
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"math/rand"
|
||
"net/http"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"codex-pool/internal/config"
|
||
)
|
||
|
||
// 默认邮箱配置
|
||
var defaultMailServices = []config.MailServiceConfig{
|
||
{
|
||
Name: "esyteam",
|
||
APIBase: "https://mail.esyteam.edu.kg",
|
||
APIToken: "005d6f3e-5312-4c37-8125-e1f71243e1ba",
|
||
Domain: "esyteam.edu.kg",
|
||
EmailPath: "/api/public/emailList",
|
||
AddUserAPI: "/api/public/addUser",
|
||
},
|
||
}
|
||
|
||
// 全局变量
|
||
var (
|
||
currentMailServices []config.MailServiceConfig
|
||
mailServicesMutex sync.RWMutex
|
||
currentServiceIndex int
|
||
)
|
||
|
||
func init() {
|
||
currentMailServices = defaultMailServices
|
||
}
|
||
|
||
// Init 初始化邮箱服务配置
|
||
func Init(services []config.MailServiceConfig) {
|
||
mailServicesMutex.Lock()
|
||
defer mailServicesMutex.Unlock()
|
||
|
||
if len(services) > 0 {
|
||
for i := range services {
|
||
if services[i].EmailPath == "" {
|
||
services[i].EmailPath = "/api/public/emailList"
|
||
}
|
||
if services[i].AddUserAPI == "" {
|
||
services[i].AddUserAPI = "/api/public/addUser"
|
||
}
|
||
if services[i].Name == "" {
|
||
services[i].Name = fmt.Sprintf("mail-service-%d", i+1)
|
||
}
|
||
}
|
||
currentMailServices = services
|
||
fmt.Printf("[邮箱] 已加载 %d 个邮箱服务配置:\n", len(services))
|
||
for _, s := range services {
|
||
fmt.Printf(" - %s (%s) @ %s\n", s.Name, s.Domain, s.APIBase)
|
||
}
|
||
} else {
|
||
currentMailServices = defaultMailServices
|
||
fmt.Println("[邮箱] 使用默认邮箱服务配置")
|
||
}
|
||
currentServiceIndex = 0
|
||
}
|
||
|
||
// GetServices 获取当前邮箱服务配置
|
||
func GetServices() []config.MailServiceConfig {
|
||
mailServicesMutex.RLock()
|
||
defer mailServicesMutex.RUnlock()
|
||
return currentMailServices
|
||
}
|
||
|
||
// GetNextService 轮询获取下一个邮箱服务
|
||
func GetNextService() config.MailServiceConfig {
|
||
mailServicesMutex.Lock()
|
||
defer mailServicesMutex.Unlock()
|
||
|
||
if len(currentMailServices) == 0 {
|
||
return defaultMailServices[0]
|
||
}
|
||
|
||
service := currentMailServices[currentServiceIndex]
|
||
currentServiceIndex = (currentServiceIndex + 1) % len(currentMailServices)
|
||
return service
|
||
}
|
||
|
||
// GetRandomService 随机获取一个邮箱服务
|
||
func GetRandomService() config.MailServiceConfig {
|
||
mailServicesMutex.RLock()
|
||
defer mailServicesMutex.RUnlock()
|
||
|
||
if len(currentMailServices) == 0 {
|
||
return defaultMailServices[0]
|
||
}
|
||
|
||
return currentMailServices[rand.Intn(len(currentMailServices))]
|
||
}
|
||
|
||
// matchDomain 检查邮箱域名是否匹配服务域名(精确匹配或子域名匹配)
|
||
func matchDomain(emailDomain, serviceDomain string) bool {
|
||
if serviceDomain == "" {
|
||
return false
|
||
}
|
||
return emailDomain == serviceDomain || strings.HasSuffix(emailDomain, "."+serviceDomain)
|
||
}
|
||
|
||
// GetServiceByDomain 根据域名获取对应的邮箱服务
|
||
// 会同时检查 Domain(主域名)和 Domains(附加域名列表)
|
||
func GetServiceByDomain(domain string) *config.MailServiceConfig {
|
||
mailServicesMutex.RLock()
|
||
defer mailServicesMutex.RUnlock()
|
||
|
||
for i := range currentMailServices {
|
||
s := ¤tMailServices[i]
|
||
// 检查主域名
|
||
if matchDomain(domain, s.Domain) {
|
||
return s
|
||
}
|
||
// 检查附加域名列表
|
||
for _, d := range s.Domains {
|
||
if matchDomain(domain, d) {
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ==================== 邮件结构 ====================
|
||
|
||
// EmailListRequest 邮件列表请求
|
||
type EmailListRequest struct {
|
||
ToEmail string `json:"toEmail"`
|
||
TimeSort string `json:"timeSort"`
|
||
Size int `json:"size"`
|
||
}
|
||
|
||
// EmailListResponse 邮件列表响应
|
||
type EmailListResponse struct {
|
||
Code int `json:"code"`
|
||
Data []EmailItem `json:"data"`
|
||
}
|
||
|
||
// EmailItem 邮件项
|
||
type EmailItem struct {
|
||
EmailID int `json:"emailId"`
|
||
Content string `json:"content"`
|
||
Text string `json:"text"`
|
||
Subject string `json:"subject"`
|
||
}
|
||
|
||
// AddUserRequest 创建用户请求
|
||
type AddUserRequest struct {
|
||
List []AddUserItem `json:"list"`
|
||
}
|
||
|
||
// AddUserItem 用户项
|
||
type AddUserItem struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
}
|
||
|
||
// AddUserResponse 创建用户响应
|
||
type AddUserResponse struct {
|
||
Code int `json:"code"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// ==================== 邮箱生成 ====================
|
||
|
||
// GenerateEmail 生成随机邮箱并在邮件系统中创建
|
||
func GenerateEmail() string {
|
||
return GenerateEmailWithService(GetNextService())
|
||
}
|
||
|
||
// GenerateEmailWithService 使用指定服务生成随机邮箱
|
||
func GenerateEmailWithService(service config.MailServiceConfig) string {
|
||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||
b := make([]byte, 10)
|
||
for i := range b {
|
||
b[i] = charset[rand.Intn(len(charset))]
|
||
}
|
||
email := string(b) + "@" + service.Domain
|
||
|
||
if err := CreateMailboxWithService(email, service); err != nil {
|
||
fmt.Printf(" [!] 创建邮箱失败 (%s): %v (继续尝试)\n", service.Name, err)
|
||
}
|
||
|
||
return email
|
||
}
|
||
|
||
// CreateMailbox 在邮件系统中创建邮箱
|
||
func CreateMailbox(email string) error {
|
||
parts := strings.Split(email, "@")
|
||
if len(parts) != 2 {
|
||
return fmt.Errorf("无效的邮箱地址: %s", email)
|
||
}
|
||
|
||
domain := parts[1]
|
||
service := GetServiceByDomain(domain)
|
||
if service == nil {
|
||
services := GetServices()
|
||
if len(services) > 0 {
|
||
service = &services[0]
|
||
} else {
|
||
return fmt.Errorf("没有可用的邮箱服务")
|
||
}
|
||
}
|
||
|
||
return CreateMailboxWithService(email, *service)
|
||
}
|
||
|
||
// CreateMailboxWithService 使用指定服务在邮件系统中创建邮箱
|
||
func CreateMailboxWithService(email string, service config.MailServiceConfig) error {
|
||
client := &http.Client{Timeout: 10 * time.Second}
|
||
|
||
parts := strings.Split(email, "@")
|
||
if len(parts) == 2 {
|
||
domain := parts[1]
|
||
if strings.HasSuffix(domain, "."+service.Domain) {
|
||
email = parts[0] + "@" + service.Domain
|
||
}
|
||
}
|
||
|
||
payload := AddUserRequest{
|
||
List: []AddUserItem{
|
||
{Email: email, Password: GeneratePassword()},
|
||
},
|
||
}
|
||
jsonData, _ := json.Marshal(payload)
|
||
|
||
req, err := http.NewRequest("POST", service.APIBase+service.AddUserAPI, bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Authorization", service.APIToken)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var result AddUserResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return err
|
||
}
|
||
|
||
if result.Code != 200 {
|
||
if strings.Contains(result.Message, "exist") {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("API 错误: %s", result.Message)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GeneratePassword 生成随机密码
|
||
func GeneratePassword() string {
|
||
const (
|
||
upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||
lower = "abcdefghijklmnopqrstuvwxyz"
|
||
digits = "0123456789"
|
||
special = "#$%@!"
|
||
)
|
||
|
||
password := make([]byte, 12)
|
||
password[0] = upper[rand.Intn(len(upper))]
|
||
password[1] = lower[rand.Intn(len(lower))]
|
||
password[10] = digits[rand.Intn(len(digits))]
|
||
password[11] = special[rand.Intn(len(special))]
|
||
|
||
charset := upper + lower
|
||
for i := 2; i < 10; i++ {
|
||
password[i] = charset[rand.Intn(len(charset))]
|
||
}
|
||
|
||
return string(password)
|
||
}
|
||
|
||
// ==================== 邮件客户端 ====================
|
||
|
||
// Client 邮件客户端
|
||
type Client struct {
|
||
client *http.Client
|
||
service *config.MailServiceConfig
|
||
}
|
||
|
||
// NewClient 创建邮件客户端
|
||
func NewClient() *Client {
|
||
services := GetServices()
|
||
var service *config.MailServiceConfig
|
||
if len(services) > 0 {
|
||
service = &services[0]
|
||
} else {
|
||
service = &defaultMailServices[0]
|
||
}
|
||
return &Client{
|
||
client: &http.Client{Timeout: 10 * time.Second},
|
||
service: service,
|
||
}
|
||
}
|
||
|
||
// NewClientWithService 创建指定服务的邮件客户端
|
||
func NewClientWithService(service config.MailServiceConfig) *Client {
|
||
return &Client{
|
||
client: &http.Client{Timeout: 10 * time.Second},
|
||
service: &service,
|
||
}
|
||
}
|
||
|
||
// NewClientForEmail 根据邮箱地址创建对应的邮件客户端
|
||
func NewClientForEmail(email string) *Client {
|
||
parts := strings.Split(email, "@")
|
||
if len(parts) == 2 {
|
||
if service := GetServiceByDomain(parts[1]); service != nil {
|
||
return NewClientWithService(*service)
|
||
}
|
||
}
|
||
return NewClient()
|
||
}
|
||
|
||
// GetEmails 获取邮件列表
|
||
func (m *Client) GetEmails(email string, size int) ([]EmailItem, error) {
|
||
service := m.service
|
||
parts := strings.Split(email, "@")
|
||
if len(parts) == 2 {
|
||
if s := GetServiceByDomain(parts[1]); s != nil {
|
||
service = s
|
||
}
|
||
}
|
||
|
||
url := service.APIBase + service.EmailPath
|
||
|
||
payload := EmailListRequest{
|
||
ToEmail: email,
|
||
TimeSort: "desc",
|
||
Size: size,
|
||
}
|
||
jsonData, _ := json.Marshal(payload)
|
||
|
||
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||
req.Header.Set("Authorization", service.APIToken)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := m.client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != 200 {
|
||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||
}
|
||
|
||
var result EmailListResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if result.Code != 200 {
|
||
return nil, fmt.Errorf("API 错误: %d", result.Code)
|
||
}
|
||
|
||
return result.Data, nil
|
||
}
|
||
|
||
// WaitForCode 等待验证码邮件
|
||
func (m *Client) WaitForCode(email string, timeout time.Duration) (string, error) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||
defer cancel()
|
||
return m.WaitForCodeWithContext(ctx, email)
|
||
}
|
||
|
||
// WaitForCodeWithContext 等待验证码邮件(支持 context 取消)
|
||
func (m *Client) WaitForCodeWithContext(ctx context.Context, email string) (string, error) {
|
||
// 匹配6位数字验证码
|
||
codeRegex := regexp.MustCompile(`\b(\d{6})\b`)
|
||
// 专门匹配 OpenAI 验证码邮件标题格式: "Your ChatGPT code is 016547" 或 "OpenAI - Verify your email"
|
||
titleCodeRegex := regexp.MustCompile(`(?i)(?:code\s+is\s+|code:\s*|验证码[::]\s*)(\d{6})`)
|
||
|
||
// 记录已经看到的验证码,避免重复返回旧验证码
|
||
seenCodes := make(map[string]bool)
|
||
|
||
// 第一次获取邮件,记录已有的验证码(这些是旧的)
|
||
initialEmails, _ := m.GetEmails(email, 10)
|
||
for _, mail := range initialEmails {
|
||
// 从标题提取
|
||
if matches := codeRegex.FindStringSubmatch(mail.Subject); len(matches) >= 2 {
|
||
seenCodes[matches[1]] = true
|
||
}
|
||
// 从内容提取
|
||
content := mail.Content
|
||
if content == "" {
|
||
content = mail.Text
|
||
}
|
||
if matches := codeRegex.FindStringSubmatch(content); len(matches) >= 2 {
|
||
seenCodes[matches[1]] = true
|
||
}
|
||
}
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return "", fmt.Errorf("验证码获取超时")
|
||
default:
|
||
}
|
||
emails, err := m.GetEmails(email, 10)
|
||
if err == nil {
|
||
for _, mail := range emails {
|
||
subject := strings.ToLower(mail.Subject)
|
||
// 匹配 OpenAI/ChatGPT 验证码邮件
|
||
// 标题格式: "Your ChatGPT code is 016547" 或包含 "verify", "code" 等
|
||
isCodeEmail := strings.Contains(subject, "chatgpt code") ||
|
||
strings.Contains(subject, "openai") ||
|
||
strings.Contains(subject, "verify your email") ||
|
||
(strings.Contains(subject, "code") && strings.Contains(subject, "is"))
|
||
|
||
if !isCodeEmail {
|
||
continue
|
||
}
|
||
|
||
// 优先使用专门的标题正则匹配 (如 "Your ChatGPT code is 016547")
|
||
if matches := titleCodeRegex.FindStringSubmatch(mail.Subject); len(matches) >= 2 {
|
||
code := matches[1]
|
||
if !seenCodes[code] {
|
||
return code, nil
|
||
}
|
||
}
|
||
|
||
// 从标题中提取6位数字
|
||
if matches := codeRegex.FindStringSubmatch(mail.Subject); len(matches) >= 2 {
|
||
code := matches[1]
|
||
if !seenCodes[code] {
|
||
return code, nil
|
||
}
|
||
}
|
||
|
||
// 如果标题中没有,从内容中提取
|
||
content := mail.Content
|
||
if content == "" {
|
||
content = mail.Text
|
||
}
|
||
// 在内容中查找验证码,优先匹配 "enter this code:" 后面的数字
|
||
contentCodeRegex := regexp.MustCompile(`(?i)(?:enter\s+this\s+code[::]\s*|code[::]\s*)(\d{6})`)
|
||
if matches := contentCodeRegex.FindStringSubmatch(content); len(matches) >= 2 {
|
||
code := matches[1]
|
||
if !seenCodes[code] {
|
||
return code, nil
|
||
}
|
||
}
|
||
// 最后尝试普通6位数字匹配
|
||
if matches := codeRegex.FindStringSubmatch(content); len(matches) >= 2 {
|
||
code := matches[1]
|
||
if !seenCodes[code] {
|
||
return code, nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return "", fmt.Errorf("验证码获取超时")
|
||
case <-time.After(1 * time.Second):
|
||
}
|
||
}
|
||
}
|
||
|
||
// WaitForInviteLink 等待邀请邮件并提取链接
|
||
func (m *Client) WaitForInviteLink(email string, timeout time.Duration) (string, error) {
|
||
start := time.Now()
|
||
|
||
for time.Since(start) < timeout {
|
||
emails, err := m.GetEmails(email, 10)
|
||
if err == nil {
|
||
for _, mail := range emails {
|
||
content := mail.Content
|
||
if content == "" {
|
||
content = mail.Text
|
||
}
|
||
|
||
if strings.Contains(mail.Subject, "invite") ||
|
||
strings.Contains(mail.Subject, "Team") ||
|
||
strings.Contains(mail.Subject, "ChatGPT") ||
|
||
strings.Contains(content, "invite") {
|
||
|
||
link := extractInviteLink(content)
|
||
if link != "" {
|
||
return link, nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
time.Sleep(1 * time.Second)
|
||
}
|
||
|
||
return "", fmt.Errorf("等待邀请邮件超时")
|
||
}
|
||
|
||
// extractInviteLink 从邮件内容提取邀请链接
|
||
func extractInviteLink(content string) string {
|
||
patterns := []string{
|
||
`https://chatgpt\.com/invite/[^\s"'<>]+`,
|
||
`https://chat\.openai\.com/invite/[^\s"'<>]+`,
|
||
`https://chatgpt\.com/[^\s"'<>]*accept[^\s"'<>]*`,
|
||
}
|
||
|
||
for _, pattern := range patterns {
|
||
re := regexp.MustCompile(pattern)
|
||
match := re.FindString(content)
|
||
if match != "" {
|
||
match = strings.ReplaceAll(match, "&", "&")
|
||
return match
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// ==================== 便捷函数 ====================
|
||
|
||
// WaitForInviteEmail 等待邀请邮件
|
||
func WaitForInviteEmail(email string, timeout time.Duration) (string, error) {
|
||
client := NewClientForEmail(email)
|
||
return client.WaitForInviteLink(email, timeout)
|
||
}
|
||
|
||
// GetVerificationCode 获取验证码
|
||
func GetVerificationCode(email string, timeout time.Duration) (string, error) {
|
||
client := NewClientForEmail(email)
|
||
return client.WaitForCode(email, timeout)
|
||
}
|
||
|
||
// GetLatestEmailID 获取邮箱最新邮件的ID
|
||
// 基于 get_code.go 的实现,用于在发送验证码请求前记录最新邮件ID
|
||
func GetLatestEmailID(email string) int {
|
||
client := NewClientForEmail(email)
|
||
emails, err := client.GetEmails(email, 1)
|
||
if err != nil || len(emails) == 0 {
|
||
return 0
|
||
}
|
||
return emails[0].EmailID
|
||
}
|
||
|
||
// GetEmailOTPAfterID 获取指定邮件ID之后的OTP验证码
|
||
// 基于 get_code.go 的实现,只获取 afterEmailID 之后的新邮件中的验证码
|
||
func GetEmailOTPAfterID(email string, afterEmailID int, timeout time.Duration) (string, error) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||
defer cancel()
|
||
return GetEmailOTPAfterIDWithContext(ctx, email, afterEmailID)
|
||
}
|
||
|
||
// GetEmailOTPAfterIDWithContext 获取指定邮件ID之后的OTP验证码(支持 context 取消)
|
||
func GetEmailOTPAfterIDWithContext(ctx context.Context, email string, afterEmailID int) (string, error) {
|
||
client := NewClientForEmail(email)
|
||
codeRegex := regexp.MustCompile(`\b(\d{6})\b`)
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return "", fmt.Errorf("验证码获取超时 (afterEmailID=%d)", afterEmailID)
|
||
default:
|
||
}
|
||
emails, err := client.GetEmails(email, 5)
|
||
if err == nil {
|
||
for _, mail := range emails {
|
||
// 只处理 afterEmailID 之后的新邮件
|
||
if mail.EmailID <= afterEmailID {
|
||
continue
|
||
}
|
||
|
||
subject := strings.ToLower(mail.Subject)
|
||
// 匹配 OpenAI/ChatGPT 验证码邮件
|
||
if !strings.Contains(subject, "code") {
|
||
continue
|
||
}
|
||
|
||
// 从标题提取验证码
|
||
if matches := codeRegex.FindStringSubmatch(mail.Subject); len(matches) >= 2 {
|
||
return matches[1], nil
|
||
}
|
||
|
||
// 从内容提取验证码
|
||
content := mail.Content
|
||
if content == "" {
|
||
content = mail.Text
|
||
}
|
||
if matches := codeRegex.FindStringSubmatch(content); len(matches) >= 2 {
|
||
return matches[1], nil
|
||
}
|
||
}
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return "", fmt.Errorf("验证码获取超时 (afterEmailID=%d)", afterEmailID)
|
||
case <-time.After(1 * time.Second):
|
||
}
|
||
}
|
||
}
|