161 lines
4.7 KiB
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, "; ")
|
|
}
|