Initial sanitized code sync

This commit is contained in:
zqq61
2026-03-15 20:48:19 +08:00
commit 17ee51ba04
126 changed files with 22546 additions and 0 deletions

View File

@@ -0,0 +1,386 @@
package email
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
const (
msTokenURL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
outlookMailURL = "https://outlook.office.com/api/v2.0/me/mailFolders/inbox/messages"
)
// OutlookAccount holds credentials for a single Outlook email account.
type OutlookAccount struct {
Email string
Password string
ClientID string // Microsoft OAuth client_id (field 3 in file)
RefreshToken string // Microsoft refresh token (field 4 in file)
}
// OutlookProvider implements EmailProvider using pre-existing Outlook accounts
// and Outlook REST API for email retrieval.
type OutlookProvider struct {
accounts []OutlookAccount
mu sync.Mutex
nextIdx int
}
// NewOutlookProvider creates an OutlookProvider by loading accounts from a file.
// File format: email----password----client_id----refresh_token (one per line).
// pop3Server and pop3Port are ignored (kept for API compatibility).
func NewOutlookProvider(accountsFile, pop3Server string, pop3Port int) (*OutlookProvider, error) {
accounts, err := loadOutlookAccounts(accountsFile)
if err != nil {
return nil, err
}
if len(accounts) == 0 {
return nil, fmt.Errorf("no accounts found in %s", accountsFile)
}
log.Printf("[outlook] loaded %d accounts from %s (using REST API)", len(accounts), accountsFile)
return &OutlookProvider{
accounts: accounts,
}, nil
}
func loadOutlookAccounts(path string) ([]OutlookAccount, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open accounts file: %w", err)
}
defer f.Close()
var accounts []OutlookAccount
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 4096), 4096)
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.Split(line, "----")
if len(parts) < 2 {
log.Printf("[outlook] skipping line %d: expected at least email----password", lineNum)
continue
}
acct := OutlookAccount{
Email: strings.TrimSpace(parts[0]),
Password: strings.TrimSpace(parts[1]),
}
if len(parts) >= 3 {
acct.ClientID = strings.TrimSpace(parts[2])
}
if len(parts) >= 4 {
acct.RefreshToken = strings.TrimSpace(parts[3])
}
accounts = append(accounts, acct)
}
return accounts, scanner.Err()
}
// CreateMailbox returns the next available Outlook account email.
func (o *OutlookProvider) CreateMailbox(ctx context.Context) (string, string, error) {
o.mu.Lock()
defer o.mu.Unlock()
if o.nextIdx >= len(o.accounts) {
return "", "", fmt.Errorf("all %d outlook accounts exhausted", len(o.accounts))
}
acct := o.accounts[o.nextIdx]
mailboxID := strconv.Itoa(o.nextIdx)
o.nextIdx++
log.Printf("[outlook] using account #%s: %s", mailboxID, acct.Email)
return acct.Email, mailboxID, nil
}
// WaitForVerificationCode polls the Outlook inbox via REST API for a 6-digit OTP.
func (o *OutlookProvider) WaitForVerificationCode(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (string, error) {
idx, err := strconv.Atoi(mailboxID)
if err != nil || idx < 0 || idx >= len(o.accounts) {
return "", fmt.Errorf("invalid mailbox ID: %s", mailboxID)
}
acct := o.accounts[idx]
if acct.RefreshToken == "" || acct.ClientID == "" {
return "", fmt.Errorf("outlook account %s has no refresh_token/client_id for REST API", acct.Email)
}
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for attempt := 1; ; attempt++ {
log.Printf("[outlook] polling REST API for OTP (attempt %d, account=%s)", attempt, acct.Email)
code, err := o.checkRESTForOTP(acct, notBefore)
if err != nil {
log.Printf("[outlook] REST API error (attempt %d): %v", attempt, err)
} else if code != "" {
log.Printf("[outlook] verification code found: %s", code)
return code, nil
}
if time.Now().After(deadline) {
return "", fmt.Errorf("timeout waiting for OTP after %s (%d attempts)", timeout, attempt)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-ticker.C:
}
}
}
// checkRESTForOTP uses Outlook REST API to fetch recent emails and extract OTP.
func (o *OutlookProvider) checkRESTForOTP(acct OutlookAccount, notBefore time.Time) (string, error) {
// Exchange refresh token for access token (no scope param — token already has outlook.office.com scopes)
accessToken, err := exchangeMSToken(acct.ClientID, acct.RefreshToken)
if err != nil {
return "", fmt.Errorf("token exchange: %w", err)
}
// Fetch recent emails without $filter (Outlook v2.0 often rejects filter on
// consumer mailboxes with InefficientFilter). We filter client-side instead.
reqURL := fmt.Sprintf("%s?$top=10&$orderby=ReceivedDateTime+desc&$select=Subject,BodyPreview,ReceivedDateTime,From",
outlookMailURL)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("REST API request: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", fmt.Errorf("REST API returned %d: %s", resp.StatusCode, truncateStr(string(body), 200))
}
var result struct {
Value []struct {
Subject string `json:"Subject"`
BodyPreview string `json:"BodyPreview"`
ReceivedDateTime string `json:"ReceivedDateTime"`
From struct {
EmailAddress struct {
Address string `json:"Address"`
} `json:"EmailAddress"`
} `json:"From"`
} `json:"value"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse REST API response: %w", err)
}
log.Printf("[outlook] REST API returned %d emails", len(result.Value))
otpRegexp := regexp.MustCompile(`\b(\d{6})\b`)
for _, email := range result.Value {
subject := email.Subject
preview := email.BodyPreview
sender := strings.ToLower(email.From.EmailAddress.Address)
log.Printf("[outlook] email: from=%s subject=%s", sender, subject)
// Client-side sender filter: must be from OpenAI
if !strings.Contains(sender, "openai") {
continue
}
// Client-side time filter: skip emails before notBefore
if !notBefore.IsZero() && email.ReceivedDateTime != "" {
if t, err := time.Parse("2006-01-02T15:04:05Z", email.ReceivedDateTime); err == nil {
if t.Before(notBefore) {
log.Printf("[outlook] skipping old email (received %s, notBefore %s)", email.ReceivedDateTime, notBefore.UTC().Format(time.RFC3339))
continue
}
}
}
// Try subject first ("Your ChatGPT code is 884584")
if m := otpRegexp.FindStringSubmatch(subject); len(m) >= 2 {
return m[1], nil
}
// Try body preview
if m := otpRegexp.FindStringSubmatch(preview); len(m) >= 2 {
return m[1], nil
}
}
return "", nil
}
// exchangeMSToken exchanges a Microsoft refresh token for an access token.
// Note: do NOT pass scope parameter — the token already has outlook.office.com scopes.
func exchangeMSToken(clientID, refreshToken string) (string, error) {
data := url.Values{
"client_id": {clientID},
"grant_type": {"refresh_token"},
"refresh_token": {refreshToken},
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.PostForm(msTokenURL, data)
if err != nil {
return "", fmt.Errorf("token request: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, truncateStr(string(body), 200))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", fmt.Errorf("parse token response: %w", err)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("no access_token: error=%s desc=%s", tokenResp.Error, truncateStr(tokenResp.ErrorDesc, 100))
}
return tokenResp.AccessToken, nil
}
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
// WaitForTeamAccountID polls for a Team workspace creation email and extracts account_id.
func (o *OutlookProvider) WaitForTeamAccountID(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (string, error) {
idx, err := strconv.Atoi(mailboxID)
if err != nil || idx < 0 || idx >= len(o.accounts) {
return "", fmt.Errorf("invalid mailbox ID: %s", mailboxID)
}
acct := o.accounts[idx]
if acct.RefreshToken == "" || acct.ClientID == "" {
return "", fmt.Errorf("outlook account %s has no refresh_token/client_id", acct.Email)
}
deadline := time.Now().Add(timeout)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
teamURLRegexp := regexp.MustCompile(`account_id=([a-f0-9-]+)`)
for attempt := 1; ; attempt++ {
accessToken, tokenErr := exchangeMSToken(acct.ClientID, acct.RefreshToken)
if tokenErr != nil {
log.Printf("[outlook] token exchange error (attempt %d): %v", attempt, tokenErr)
goto wait
}
{
// Fetch recent emails without $filter (consumer mailboxes reject filters).
// We filter client-side by sender and time.
reqURL := fmt.Sprintf("%s?$top=10&$orderby=ReceivedDateTime+desc&$select=Subject,Body,ReceivedDateTime,From",
outlookMailURL)
req, _ := http.NewRequest("GET", reqURL, nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Prefer", "outlook.body-content-type=\"text\"")
httpClient := &http.Client{Timeout: 15 * time.Second}
resp, reqErr := httpClient.Do(req)
if reqErr != nil {
log.Printf("[outlook] REST API error (attempt %d): %v", attempt, reqErr)
goto wait
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
log.Printf("[outlook] REST API returned %d (attempt %d)", resp.StatusCode, attempt)
goto wait
}
var result struct {
Value []struct {
Subject string `json:"Subject"`
ReceivedDateTime string `json:"ReceivedDateTime"`
Body struct {
Content string `json:"Content"`
} `json:"Body"`
From struct {
EmailAddress struct {
Address string `json:"Address"`
} `json:"EmailAddress"`
} `json:"From"`
} `json:"value"`
}
json.Unmarshal(body, &result)
for _, email := range result.Value {
sender := strings.ToLower(email.From.EmailAddress.Address)
// Client-side sender filter
if !strings.Contains(sender, "openai") {
continue
}
// Client-side time filter
if !notBefore.IsZero() && email.ReceivedDateTime != "" {
if t, parseErr := time.Parse("2006-01-02T15:04:05Z", email.ReceivedDateTime); parseErr == nil {
if t.Before(notBefore) {
continue
}
}
}
subject := strings.ToLower(email.Subject)
if strings.Contains(subject, "team") || strings.Contains(subject, "workspace") {
if m := teamURLRegexp.FindStringSubmatch(email.Body.Content); len(m) >= 2 {
log.Printf("[outlook] found team account_id: %s", m[1])
return m[1], nil
}
}
}
}
wait:
if time.Now().After(deadline) {
return "", fmt.Errorf("timeout waiting for team email after %s (%d attempts)", timeout, attempt)
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-ticker.C:
}
}
}