279 lines
7.7 KiB
Go
279 lines
7.7 KiB
Go
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")
|
|
}
|
|
|