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: } } }