Files
cairnquire/apps/server/internal/email/cloudflare.go

161 lines
4.7 KiB
Go

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, "; ")
}