Add Cloudflare email and collaboration workflows
This commit is contained in:
160
apps/server/internal/email/cloudflare.go
Normal file
160
apps/server/internal/email/cloudflare.go
Normal file
@@ -0,0 +1,160 @@
|
||||
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, "; ")
|
||||
}
|
||||
Reference in New Issue
Block a user