package email import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "strings" "time" ) const ( defaultCloudflareAPIURL = "https://api.cloudflare.com/client/v4" cloudflareTimeout = 15 * time.Second ) // CloudflareSender sends transactional email through Cloudflare Email // Service's REST API. It is intended for deployments outside Workers. type CloudflareSender struct { accountID string apiToken string apiURL string from string client *http.Client logger *slog.Logger } func NewCloudflareSender(accountID, apiToken, from, apiURL string, logger *slog.Logger) *CloudflareSender { if apiURL == "" { apiURL = defaultCloudflareAPIURL } if logger == nil { logger = slog.Default() } return &CloudflareSender{ accountID: accountID, apiToken: apiToken, apiURL: strings.TrimRight(apiURL, "/"), from: from, client: &http.Client{ Timeout: cloudflareTimeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, }, logger: logger, } } type cloudflareRequest struct { From string `json:"from"` To []string `json:"to"` Subject string `json:"subject"` Text string `json:"text"` } type cloudflareResponse struct { Success bool `json:"success"` Errors []struct { Code int `json:"code"` Message string `json:"message"` } `json:"errors"` Result *struct { Delivered []string `json:"delivered"` MessageID string `json:"message_id"` PermanentBounces []string `json:"permanent_bounces"` Queued []string `json:"queued"` } `json:"result"` } func (c *CloudflareSender) Send(ctx context.Context, to []string, subject, body string) error { if c.accountID == "" { return &PermanentError{Cause: fmt.Errorf("cloudflare account id not configured")} } if c.apiToken == "" { return &PermanentError{Cause: fmt.Errorf("cloudflare API token not configured")} } if c.from == "" { return &PermanentError{Cause: fmt.Errorf("cloudflare from address not configured")} } if len(to) == 0 { return &PermanentError{Cause: fmt.Errorf("cloudflare send requires at least one recipient")} } payload, err := json.Marshal(cloudflareRequest{ From: c.from, To: to, Subject: subject, Text: body, }) if err != nil { return fmt.Errorf("marshal cloudflare email request: %w", err) } endpoint := fmt.Sprintf("%s/accounts/%s/email/sending/send", c.apiURL, url.PathEscape(c.accountID)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("build cloudflare email request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiToken) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := c.client.Do(req) if err != nil { return &TemporaryError{Cause: fmt.Errorf("cloudflare email request: %w", err)} } defer resp.Body.Close() responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<14)) if readErr != nil { return &TemporaryError{Cause: fmt.Errorf("read cloudflare email response: %w", readErr)} } if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError { return &TemporaryError{Cause: fmt.Errorf("cloudflare email server error: status=%d body=%s", resp.StatusCode, truncate(string(responseBody)))} } if resp.StatusCode != http.StatusOK { return &PermanentError{Cause: fmt.Errorf("cloudflare email rejected: status=%d body=%s", resp.StatusCode, truncate(string(responseBody)))} } var parsed cloudflareResponse if err := json.Unmarshal(responseBody, &parsed); err != nil { return fmt.Errorf("decode cloudflare email response: %w", err) } if !parsed.Success { return &PermanentError{Cause: fmt.Errorf("cloudflare email rejected: %s", cloudflareErrors(parsed.Errors))} } if parsed.Result == nil { return fmt.Errorf("cloudflare email response missing result") } if len(parsed.Result.PermanentBounces) > 0 { return &PermanentError{Cause: fmt.Errorf("cloudflare email permanently bounced for %d recipient(s)", len(parsed.Result.PermanentBounces))} } c.logger.Info("cloudflare email accepted", "message_id", parsed.Result.MessageID, "delivered", len(parsed.Result.Delivered), "queued", len(parsed.Result.Queued), "subject", subject, ) return nil } func cloudflareErrors(errors []struct { Code int `json:"code"` Message string `json:"message"` }) string { if len(errors) == 0 { return "unknown API error" } parts := make([]string, 0, len(errors)) for _, apiErr := range errors { parts = append(parts, fmt.Sprintf("code=%d: %s", apiErr.Code, apiErr.Message)) } return strings.Join(parts, "; ") }