Initial sanitized code sync
This commit is contained in:
278
pkg/provider/email/mailgateway.go
Normal file
278
pkg/provider/email/mailgateway.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var codeRegexp = regexp.MustCompile(`\b(\d{6})\b`)
|
||||
|
||||
// MailGatewayProvider implements EmailProvider using the Mail Gateway API.
|
||||
// API docs: https://regmail.zhengmi.org/
|
||||
type MailGatewayProvider struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
provider string
|
||||
}
|
||||
|
||||
// NewMailGateway creates a new MailGatewayProvider.
|
||||
func NewMailGateway(baseURL, apiKey, provider string) *MailGatewayProvider {
|
||||
return &MailGatewayProvider{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
provider: provider,
|
||||
}
|
||||
}
|
||||
|
||||
type createMailboxResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data struct {
|
||||
MailboxID string `json:"mailbox_id"`
|
||||
Email string `json:"email"`
|
||||
Provider string `json:"provider"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type emailEntry struct {
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
Content string `json:"content"`
|
||||
Body string `json:"body"`
|
||||
Text string `json:"text"`
|
||||
ReceivedAt string `json:"received_at"`
|
||||
}
|
||||
|
||||
type listEmailsResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data struct {
|
||||
Emails []emailEntry `json:"emails"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
type verificationResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func (m *MailGatewayProvider) doRequest(req *http.Request) ([]byte, error) {
|
||||
req.Header.Set("X-API-Key", m.apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request %s: %w", req.URL.Path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s returned %d: %s", req.URL.Path, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// CreateMailbox creates a temporary mailbox via the Mail Gateway API.
|
||||
// POST /api/v1/mailboxes {"provider": "gptmail"}
|
||||
func (m *MailGatewayProvider) CreateMailbox(ctx context.Context) (string, string, error) {
|
||||
payload := fmt.Sprintf(`{"provider":"%s"}`, m.provider)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.baseURL+"/api/v1/mailboxes", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("build create mailbox request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
body, err := m.doRequest(req)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("create mailbox: %w", err)
|
||||
}
|
||||
|
||||
var result createMailboxResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", "", fmt.Errorf("parse create mailbox response: %w", err)
|
||||
}
|
||||
|
||||
if !result.OK {
|
||||
return "", "", fmt.Errorf("create mailbox failed: %s", result.Error)
|
||||
}
|
||||
|
||||
log.Printf("[email] mailbox created: %s (provider: %s, id: %s)", result.Data.Email, result.Data.Provider, result.Data.MailboxID)
|
||||
return result.Data.Email, result.Data.MailboxID, nil
|
||||
}
|
||||
|
||||
// WaitForVerificationCode polls for an OTP code, ignoring emails that existed before notBefore.
|
||||
// If notBefore is zero, all emails are considered (for fresh mailboxes).
|
||||
func (m *MailGatewayProvider) WaitForVerificationCode(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (string, error) {
|
||||
// Snapshot existing email IDs so we can skip them when filtering
|
||||
knownIDs := make(map[string]bool)
|
||||
if !notBefore.IsZero() {
|
||||
existing, _ := m.ListEmailIDs(ctx, mailboxID)
|
||||
for _, id := range existing {
|
||||
knownIDs[id] = true
|
||||
}
|
||||
log.Printf("[email] snapshotted %d existing emails to skip", len(knownIDs))
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for attempt := 1; ; attempt++ {
|
||||
log.Printf("[email] polling for verification code (attempt %d)", attempt)
|
||||
|
||||
code, err := m.pollNewEmails(ctx, mailboxID, knownIDs)
|
||||
if err == nil && code != "" {
|
||||
log.Printf("[email] verification code found: %s", code)
|
||||
return code, nil
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("timeout waiting for verification code after %s", timeout)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListEmailIDs returns the IDs of all current emails in the mailbox.
|
||||
func (m *MailGatewayProvider) ListEmailIDs(ctx context.Context, mailboxID string) ([]string, error) {
|
||||
reqURL := fmt.Sprintf("%s/api/v1/emails?mailbox_id=%s&limit=20", m.baseURL, mailboxID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := m.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result listEmailsResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []string
|
||||
for _, e := range result.Data.Emails {
|
||||
ids = append(ids, e.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// pollNewEmails lists emails and returns a code only from emails NOT in knownIDs.
|
||||
func (m *MailGatewayProvider) pollNewEmails(ctx context.Context, mailboxID string, knownIDs map[string]bool) (string, error) {
|
||||
reqURL := fmt.Sprintf("%s/api/v1/emails?mailbox_id=%s&limit=10", m.baseURL, mailboxID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := m.doRequest(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var result listEmailsResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !result.OK || len(result.Data.Emails) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
for _, e := range result.Data.Emails {
|
||||
// Skip emails that existed before our OTP send
|
||||
if len(knownIDs) > 0 && knownIDs[e.ID] {
|
||||
continue
|
||||
}
|
||||
text := e.Content
|
||||
if text == "" {
|
||||
text = e.Body
|
||||
}
|
||||
if text == "" {
|
||||
text = e.Text
|
||||
}
|
||||
if text == "" {
|
||||
text = e.Subject
|
||||
}
|
||||
matches := codeRegexp.FindStringSubmatch(text)
|
||||
if len(matches) >= 2 {
|
||||
return matches[1], nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// pollVerificationEndpoint uses the dedicated verification email endpoint.
|
||||
// GET /api/v1/emails/verification?mailbox_id=xxx&keyword=openai
|
||||
func (m *MailGatewayProvider) pollVerificationEndpoint(ctx context.Context, mailboxID string) (string, error) {
|
||||
reqURL := fmt.Sprintf("%s/api/v1/emails/verification?mailbox_id=%s&keyword=openai", m.baseURL, mailboxID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, err := m.doRequest(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var result verificationResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !result.OK || len(result.Data) == 0 {
|
||||
return "", nil // no verification email yet
|
||||
}
|
||||
|
||||
// Data can be a single object or an array — handle both
|
||||
var entries []emailEntry
|
||||
if err := json.Unmarshal(result.Data, &entries); err != nil {
|
||||
// Try as single object
|
||||
var single emailEntry
|
||||
if err2 := json.Unmarshal(result.Data, &single); err2 != nil {
|
||||
return "", fmt.Errorf("parse verification data: %w", err2)
|
||||
}
|
||||
entries = []emailEntry{single}
|
||||
}
|
||||
|
||||
// Extract 6-digit OTP from email content/body/text/subject
|
||||
for _, e := range entries {
|
||||
text := e.Content
|
||||
if text == "" {
|
||||
text = e.Body
|
||||
}
|
||||
if text == "" {
|
||||
text = e.Text
|
||||
}
|
||||
if text == "" {
|
||||
text = e.Subject
|
||||
}
|
||||
matches := codeRegexp.FindStringSubmatch(text)
|
||||
if len(matches) >= 2 {
|
||||
return matches[1], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// WaitForTeamAccountID polls for a Team workspace creation email and extracts account_id.
|
||||
// TODO: implement proper email parsing for team workspace emails.
|
||||
func (m *MailGatewayProvider) WaitForTeamAccountID(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (string, error) {
|
||||
return "", fmt.Errorf("WaitForTeamAccountID not implemented for MailGateway provider")
|
||||
}
|
||||
|
||||
386
pkg/provider/email/outlook.go
Normal file
386
pkg/provider/email/outlook.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
19
pkg/provider/email/provider.go
Normal file
19
pkg/provider/email/provider.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EmailProvider is the pluggable interface for email services.
|
||||
type EmailProvider interface {
|
||||
// CreateMailbox creates a temporary mailbox, returning the email address and an internal ID.
|
||||
CreateMailbox(ctx context.Context) (email string, mailboxID string, err error)
|
||||
// WaitForVerificationCode polls for an OTP email and extracts the verification code.
|
||||
// notBefore: only consider emails received after this time (use time.Time{} to accept all).
|
||||
WaitForVerificationCode(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (code string, err error)
|
||||
// WaitForTeamAccountID polls for a Team workspace creation email from OpenAI
|
||||
// and extracts the account_id from the embedded link (direct URL or Mandrill tracking link).
|
||||
// notBefore: only consider emails received after this time.
|
||||
WaitForTeamAccountID(ctx context.Context, mailboxID string, timeout time.Duration, notBefore time.Time) (accountID string, err error)
|
||||
}
|
||||
Reference in New Issue
Block a user