#18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics
This commit is contained in:
1 parent
7249419efc
commit
d56f29d084
70 files changed
+5163
-659
No files matched your search
@@ -5,240 +5,392 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
const (
|
||||
// rateLimitMaxAttempts bounds the 429 retry loop so a throttled zone
|
||||
// delays the reconcile instead of blocking it forever.
|
||||
rateLimitMaxAttempts = 4
|
||||
rateLimitMaxBackoff = 30 * time.Second
|
||||
recordsPerPage = 100
|
||||
)
|
||||
|
||||
type CloudflareAdapter struct {
|
||||
apiToken string
|
||||
baseURL string
|
||||
apiToken string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewCloudflareAdapter(apiToken string) *CloudflareAdapter {
|
||||
return &CloudflareAdapter{
|
||||
apiToken: apiToken,
|
||||
baseURL: "https://api.cloudflare.com/client/v4",
|
||||
httpClient: &http.Client{},
|
||||
apiToken: apiToken,
|
||||
baseURL: "https://api.cloudflare.com/client/v4",
|
||||
// Without a timeout a stalled API call would hold the reconcile - and
|
||||
// on the periodic drift job, the goroutine - open indefinitely.
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type cfError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type cfResultInfo struct {
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type cfZoneResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Result []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"result"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
Errors []cfError `json:"errors"`
|
||||
}
|
||||
|
||||
type cfRecord struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
TTL int `json:"ttl"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Proxied bool `json:"proxied,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content,omitempty"`
|
||||
TTL int `json:"ttl"`
|
||||
// Pointers so that "absent" and "explicitly false" stay distinguishable:
|
||||
// Cloudflare applies its own default for an omitted proxied flag, and a
|
||||
// proxied mail host silently breaks SMTP (PLAN.md section 7.1).
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Proxied *bool `json:"proxied,omitempty"`
|
||||
// Data carries the structured form Cloudflare requires for SRV and TLSA;
|
||||
// those types reject a flat content string.
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
type cfRecordResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Result []cfRecord `json:"result"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
Success bool `json:"success"`
|
||||
Result []cfRecord `json:"result"`
|
||||
ResultInfo cfResultInfo `json:"result_info"`
|
||||
Errors []cfError `json:"errors"`
|
||||
}
|
||||
|
||||
type cfSingleRecordResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Result cfRecord `json:"result"`
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
Success bool `json:"success"`
|
||||
Result cfRecord `json:"result"`
|
||||
Errors []cfError `json:"errors"`
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) GetZoneID(ctx context.Context, domainName string) (string, error) {
|
||||
reqURL := fmt.Sprintf("%s/zones?name=%s", a.baseURL, url.QueryEscape(domainName))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.setHeaders(req)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data cfZoneResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return "", fmt.Errorf("decode json: %w", err)
|
||||
if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !data.Success || len(data.Result) == 0 {
|
||||
errMsg := "zone not found"
|
||||
if len(data.Errors) > 0 {
|
||||
errMsg = data.Errors[0].Message
|
||||
}
|
||||
return "", fmt.Errorf("cloudflare API error: %s", errMsg)
|
||||
return "", fmt.Errorf("cloudflare: zone %q not found or token lacks Zone:Read on it", domainName)
|
||||
}
|
||||
|
||||
return data.Result[0].ID, nil
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) ListRecords(ctx context.Context, zoneID string) ([]entity.DnsRecord, error) {
|
||||
reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=100", a.baseURL, zoneID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.setHeaders(req)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data cfRecordResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("decode json: %w", err)
|
||||
}
|
||||
|
||||
if !data.Success {
|
||||
return nil, fmt.Errorf("cloudflare API error listing records")
|
||||
}
|
||||
|
||||
var out []entity.DnsRecord
|
||||
for _, r := range data.Result {
|
||||
out = append(out, entity.DnsRecord{
|
||||
ID: r.ID,
|
||||
Type: r.Type,
|
||||
Name: r.Name,
|
||||
Value: r.Content,
|
||||
TTL: r.TTL,
|
||||
Priority: r.Priority,
|
||||
Proxied: r.Proxied,
|
||||
Comment: r.Comment,
|
||||
})
|
||||
|
||||
for page := 1; ; page++ {
|
||||
reqURL := fmt.Sprintf("%s/zones/%s/dns_records?per_page=%d&page=%d", a.baseURL, zoneID, recordsPerPage, page)
|
||||
|
||||
var data cfRecordResponse
|
||||
if err := a.doJSON(ctx, http.MethodGet, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !data.Success {
|
||||
return nil, fmt.Errorf("cloudflare: listing records for zone %s failed", zoneID)
|
||||
}
|
||||
|
||||
for _, r := range data.Result {
|
||||
rec := entity.DnsRecord{
|
||||
ID: r.ID,
|
||||
Type: r.Type,
|
||||
Name: r.Name,
|
||||
Value: r.Content,
|
||||
TTL: r.TTL,
|
||||
Priority: r.Priority,
|
||||
Comment: r.Comment,
|
||||
}
|
||||
if r.Proxied != nil {
|
||||
rec.Proxied = *r.Proxied
|
||||
}
|
||||
// Cloudflare returns SRV and TLSA with content already rendered,
|
||||
// but older zones and some responses only populate data; fall back
|
||||
// to it so a structured record is not seen as an empty value and
|
||||
// rewritten on every reconcile.
|
||||
if rec.Value == "" && len(r.Data) > 0 {
|
||||
rec.Value = renderStructuredValue(r.Type, r.Data)
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
|
||||
// Zones larger than one page would otherwise look like they are
|
||||
// missing records, and the reconciler would try to re-create them.
|
||||
if data.ResultInfo.TotalPages <= page || len(data.Result) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) CreateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
|
||||
reqURL := fmt.Sprintf("%s/zones/%s/dns_records", a.baseURL, zoneID)
|
||||
cfBody := cfRecord{
|
||||
Type: record.Type,
|
||||
Name: record.Name,
|
||||
Content: record.Value,
|
||||
TTL: record.TTL,
|
||||
Priority: record.Priority,
|
||||
Proxied: record.Proxied,
|
||||
Comment: record.Comment,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(cfBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setHeaders(req)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data cfSingleRecordResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return err
|
||||
if err := a.doJSON(ctx, http.MethodPost, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil {
|
||||
return fmt.Errorf("create %s %s: %w", record.Type, record.Name, err)
|
||||
}
|
||||
|
||||
if !data.Success {
|
||||
errMsg := "create record failed"
|
||||
if len(data.Errors) > 0 {
|
||||
errMsg = data.Errors[0].Message
|
||||
}
|
||||
return fmt.Errorf("cloudflare API error: %s", errMsg)
|
||||
return fmt.Errorf("cloudflare: create %s %s failed", record.Type, record.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) UpdateRecord(ctx context.Context, zoneID string, record entity.DnsRecord) error {
|
||||
reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, record.ID)
|
||||
cfBody := cfRecord{
|
||||
Type: record.Type,
|
||||
Name: record.Name,
|
||||
Content: record.Value,
|
||||
TTL: record.TTL,
|
||||
Priority: record.Priority,
|
||||
Proxied: record.Proxied,
|
||||
Comment: record.Comment,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(cfBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setHeaders(req)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var data cfSingleRecordResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return err
|
||||
if err := a.doJSON(ctx, http.MethodPut, reqURL, toCfRecord(record), &data, func() []cfError { return data.Errors }); err != nil {
|
||||
return fmt.Errorf("update %s %s: %w", record.Type, record.Name, err)
|
||||
}
|
||||
|
||||
if !data.Success {
|
||||
return fmt.Errorf("cloudflare API error updating record")
|
||||
return fmt.Errorf("cloudflare: update %s %s failed", record.Type, record.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) DeleteRecord(ctx context.Context, zoneID string, recordID string) error {
|
||||
reqURL := fmt.Sprintf("%s/zones/%s/dns_records/%s", a.baseURL, zoneID, recordID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setHeaders(req)
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
var data cfSingleRecordResponse
|
||||
if err := a.doJSON(ctx, http.MethodDelete, reqURL, nil, &data, func() []cfError { return data.Errors }); err != nil {
|
||||
return fmt.Errorf("delete record %s: %w", recordID, err)
|
||||
}
|
||||
if !data.Success {
|
||||
return fmt.Errorf("cloudflare: delete record %s failed", recordID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toCfRecord maps a desired record onto the API payload. The proxied flag is
|
||||
// only meaningful for the record types Cloudflare can proxy; sending it for a
|
||||
// TXT or MX record is rejected.
|
||||
func toCfRecord(record entity.DnsRecord) cfRecord {
|
||||
body := cfRecord{
|
||||
ID: record.ID,
|
||||
Type: record.Type,
|
||||
Name: record.Name,
|
||||
Content: record.Value,
|
||||
TTL: record.TTL,
|
||||
Priority: record.Priority,
|
||||
Comment: record.Comment,
|
||||
}
|
||||
|
||||
switch strings.ToUpper(record.Type) {
|
||||
case "A", "AAAA", "CNAME":
|
||||
proxied := record.Proxied
|
||||
body.Proxied = &proxied
|
||||
|
||||
case "SRV", "TLSA":
|
||||
// These types are rejected with a flat content string; the API wants
|
||||
// the components broken out.
|
||||
if data := structuredData(record); data != nil {
|
||||
body.Data = data
|
||||
body.Content = ""
|
||||
body.Priority = nil
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// structuredData splits an SRV or TLSA record value into the field map the
|
||||
// Cloudflare API expects. It returns nil when the value is not in the
|
||||
// canonical presentation form, letting the caller fall back to content.
|
||||
func structuredData(record entity.DnsRecord) map[string]any {
|
||||
fields := strings.Fields(record.Value)
|
||||
|
||||
switch strings.ToUpper(record.Type) {
|
||||
case "SRV":
|
||||
// Presentation form: "<priority> <weight> <port> <target>".
|
||||
if len(fields) != 4 {
|
||||
return nil
|
||||
}
|
||||
priority, err1 := strconv.Atoi(fields[0])
|
||||
weight, err2 := strconv.Atoi(fields[1])
|
||||
port, err3 := strconv.Atoi(fields[2])
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"priority": priority,
|
||||
"weight": weight,
|
||||
"port": port,
|
||||
"target": strings.TrimSuffix(fields[3], "."),
|
||||
}
|
||||
|
||||
case "TLSA":
|
||||
// Presentation form: "<usage> <selector> <matching type> <data>".
|
||||
if len(fields) != 4 {
|
||||
return nil
|
||||
}
|
||||
usage, err1 := strconv.Atoi(fields[0])
|
||||
selector, err2 := strconv.Atoi(fields[1])
|
||||
matchingType, err3 := strconv.Atoi(fields[2])
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"usage": usage,
|
||||
"selector": selector,
|
||||
"matching_type": matchingType,
|
||||
"certificate": fields[3],
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *CloudflareAdapter) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+a.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// renderStructuredValue is the inverse of structuredData: it rebuilds the
|
||||
// presentation form so the reconciler can compare a published record against
|
||||
// the desired one.
|
||||
func renderStructuredValue(recordType string, data map[string]any) string {
|
||||
number := func(key string) string {
|
||||
switch v := data[key].(type) {
|
||||
case float64: // JSON numbers decode as float64
|
||||
return strconv.Itoa(int(v))
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
text := func(key string) string {
|
||||
if v, ok := data[key].(string); ok {
|
||||
return strings.TrimSuffix(v, ".")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
switch strings.ToUpper(recordType) {
|
||||
case "SRV":
|
||||
return strings.Join([]string{number("priority"), number("weight"), number("port"), text("target")}, " ")
|
||||
case "TLSA":
|
||||
return strings.Join([]string{number("usage"), number("selector"), number("matching_type"), text("certificate")}, " ")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// doJSON performs one API call, retrying while Cloudflare reports 429 and
|
||||
// honouring Retry-After (PLAN.md section 7.5). It fails on any non-2xx status
|
||||
// rather than handing a decoder an error page.
|
||||
func (a *CloudflareAdapter) doJSON(ctx context.Context, method, reqURL string, body any, out any, errsOf func() []cfError) error {
|
||||
var payload []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
if payload, err = json.Marshal(body); err != nil {
|
||||
return fmt.Errorf("encode request body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
backoff := time.Second
|
||||
|
||||
for attempt := 1; ; attempt++ {
|
||||
var reader io.Reader
|
||||
if payload != nil {
|
||||
reader = bytes.NewReader(payload)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+a.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusTooManyRequests && attempt < rateLimitMaxAttempts {
|
||||
wait := retryAfter(resp.Header.Get("Retry-After"), backoff)
|
||||
resp.Body.Close()
|
||||
backoff = min(backoff*2, rateLimitMaxBackoff)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
respBody, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read response body: %w", readErr)
|
||||
}
|
||||
|
||||
// Decode first so an error envelope can be quoted back to the operator,
|
||||
// then judge the status. Cloudflare returns its envelope on 4xx too.
|
||||
decodeErr := json.Unmarshal(respBody, out)
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("cloudflare API returned HTTP %d: %s", resp.StatusCode, describeErrors(errsOf(), respBody))
|
||||
}
|
||||
if decodeErr != nil {
|
||||
return fmt.Errorf("decode json: %w", decodeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func describeErrors(errs []cfError, raw []byte) string {
|
||||
if len(errs) > 0 {
|
||||
messages := make([]string, 0, len(errs))
|
||||
for _, e := range errs {
|
||||
messages = append(messages, e.Message)
|
||||
}
|
||||
return strings.Join(messages, "; ")
|
||||
}
|
||||
const maxRaw = 200
|
||||
if len(raw) > maxRaw {
|
||||
raw = raw[:maxRaw]
|
||||
}
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
|
||||
// retryAfter reads the header as delta-seconds (RFC 9110 section 10.2.3),
|
||||
// falling back to the caller's exponential backoff when it is absent or
|
||||
// unparseable.
|
||||
func retryAfter(header string, fallback time.Duration) time.Duration {
|
||||
if header == "" {
|
||||
return fallback
|
||||
}
|
||||
seconds, err := strconv.Atoi(strings.TrimSpace(header))
|
||||
if err != nil || seconds < 0 {
|
||||
return fallback
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
// Ensure port interface compliance
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
// A proxied A record for the mail host makes Cloudflare answer with its own
|
||||
// anycast addresses, which do not speak SMTP - the domain stops receiving mail.
|
||||
// PLAN.md section 7.1 requires proxied=false, so the flag must be sent
|
||||
// explicitly rather than left to the zone default.
|
||||
func TestCloudflareAdapter_CreateRecord_SendsProxiedFalseExplicitly(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": map[string]interface{}{"id": "rec_1"}})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
adapter := NewCloudflareAdapter("token")
|
||||
adapter.baseURL = ts.URL
|
||||
|
||||
err := adapter.CreateRecord(context.Background(), "zone1", entity.DnsRecord{
|
||||
Type: "A", Name: "mail.example.test", Value: "192.0.2.1", TTL: 1, Proxied: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRecord: %v", err)
|
||||
}
|
||||
|
||||
proxied, present := body["proxied"]
|
||||
if !present {
|
||||
t.Fatal("proxied flag was omitted from the request; Cloudflare would fall back to the zone default")
|
||||
}
|
||||
if proxied != false {
|
||||
t.Errorf("proxied = %v, want false", proxied)
|
||||
}
|
||||
}
|
||||
|
||||
// An HTTP-level failure carries no Cloudflare envelope, so a decoder-only
|
||||
// check reports a confusing JSON error - or worse, silently succeeds.
|
||||
func TestCloudflareAdapter_ReportsHttpErrorStatus(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"success":false,"errors":[{"message":"Insufficient permissions"}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
adapter := NewCloudflareAdapter("token")
|
||||
adapter.baseURL = ts.URL
|
||||
|
||||
_, err := adapter.GetZoneID(context.Background(), "example.test")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for HTTP 403")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Insufficient permissions") {
|
||||
t.Errorf("error should surface the API message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PLAN.md section 7.5 lists the record listing as paginated. Stopping at the
|
||||
// first page makes the reconciler believe records are missing and try to
|
||||
// re-create them, which the API then rejects.
|
||||
func TestCloudflareAdapter_ListRecords_FollowsPagination(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
page := r.URL.Query().Get("page")
|
||||
if page == "" || page == "1" {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": []map[string]interface{}{
|
||||
{"id": "rec_1", "type": "A", "name": "a.example.test", "content": "192.0.2.1"},
|
||||
},
|
||||
"result_info": map[string]interface{}{"page": 1, "total_pages": 2},
|
||||
})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": []map[string]interface{}{
|
||||
{"id": "rec_2", "type": "A", "name": "b.example.test", "content": "192.0.2.2"},
|
||||
},
|
||||
"result_info": map[string]interface{}{"page": 2, "total_pages": 2},
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
adapter := NewCloudflareAdapter("token")
|
||||
adapter.baseURL = ts.URL
|
||||
|
||||
records, err := adapter.ListRecords(context.Background(), "zone1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListRecords: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("got %d records, want 2 across both pages", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
// PLAN.md section 7.5 requires retrying 429 while honouring Retry-After.
|
||||
func TestCloudflareAdapter_RetriesOnRateLimit(t *testing.T) {
|
||||
var calls int
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
w.Header().Set("Retry-After", "0")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": []map[string]interface{}{{"id": "zone_1", "name": "example.test"}},
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
adapter := NewCloudflareAdapter("token")
|
||||
adapter.baseURL = ts.URL
|
||||
|
||||
zoneID, err := adapter.GetZoneID(context.Background(), "example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("GetZoneID: %v", err)
|
||||
}
|
||||
if zoneID != "zone_1" {
|
||||
t.Errorf("zoneID = %q, want zone_1", zoneID)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Errorf("made %d calls, want 2 (one rate limited, one retried)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed delete that reports success would silently leave stale records in
|
||||
// the zone.
|
||||
func TestCloudflareAdapter_DeleteRecord_ReportsFailure(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"success":false,"errors":[{"message":"Record not found"}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
adapter := NewCloudflareAdapter("token")
|
||||
adapter.baseURL = ts.URL
|
||||
|
||||
if err := adapter.DeleteRecord(context.Background(), "zone1", "rec_missing"); err == nil {
|
||||
t.Fatal("expected an error when the delete fails")
|
||||
}
|
||||
}
|
||||
|
||||
// Cloudflare rejects SRV and TLSA records submitted as a flat content string;
|
||||
// they must be broken into the structured data object.
|
||||
func TestCloudflareAdapter_SendsStructuredDataForSrvAndTlsa(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
record entity.DnsRecord
|
||||
expected map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "SRV",
|
||||
record: entity.DnsRecord{Type: "SRV", Name: "_imaps._tcp.example.test", Value: "0 1 993 mail.example.test", TTL: 1},
|
||||
expected: map[string]interface{}{
|
||||
"priority": float64(0), "weight": float64(1), "port": float64(993), "target": "mail.example.test",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TLSA",
|
||||
record: entity.DnsRecord{Type: "TLSA", Name: "_25._tcp.mail.example.test", Value: "3 1 1 " + strings.Repeat("ab", 32), TTL: 1},
|
||||
expected: map[string]interface{}{
|
||||
"usage": float64(3), "selector": float64(1), "matching_type": float64(1), "certificate": strings.Repeat("ab", 32),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var body map[string]interface{}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": map[string]interface{}{"id": "rec_1"}})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
adapter := NewCloudflareAdapter("token")
|
||||
adapter.baseURL = ts.URL
|
||||
|
||||
if err := adapter.CreateRecord(context.Background(), "zone1", tc.record); err != nil {
|
||||
t.Fatalf("CreateRecord: %v", err)
|
||||
}
|
||||
|
||||
data, ok := body["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("no structured data object sent; body = %v", body)
|
||||
}
|
||||
for key, want := range tc.expected {
|
||||
if data[key] != want {
|
||||
t.Errorf("data[%q] = %v, want %v", key, data[key], want)
|
||||
}
|
||||
}
|
||||
if content, present := body["content"]; present && content != "" {
|
||||
t.Errorf("content was sent alongside data: %v", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A record returned with only a data object must round-trip back to the
|
||||
// presentation form, or the reconciler sees an empty value and rewrites it on
|
||||
// every run.
|
||||
func TestCloudflareAdapter_ListRecords_RendersStructuredValues(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"result": []map[string]interface{}{{
|
||||
"id": "rec_1", "type": "SRV", "name": "_imaps._tcp.example.test",
|
||||
"data": map[string]interface{}{"priority": 0, "weight": 1, "port": 993, "target": "mail.example.test"},
|
||||
}},
|
||||
"result_info": map[string]interface{}{"page": 1, "total_pages": 1},
|
||||
})
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
adapter := NewCloudflareAdapter("token")
|
||||
adapter.baseURL = ts.URL
|
||||
|
||||
records, err := adapter.ListRecords(context.Background(), "zone1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListRecords: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0].Value != "0 1 993 mail.example.test" {
|
||||
t.Errorf("value = %q, want the rendered presentation form", records[0].Value)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func NewAuthRepository(pool *pgxpool.Pool) *AuthRepository {
|
||||
|
||||
func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*port.MailboxAuth, error) {
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
SELECT m.id, m.password_hash
|
||||
SELECT m.id, m.password_hash, m.email_address, d.name, m.max_recipients_per_hour
|
||||
FROM mailboxes m
|
||||
JOIN domains d ON d.id = m.domain_id
|
||||
WHERE m.email_address = $1
|
||||
@@ -31,7 +31,7 @@ func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*po
|
||||
|
||||
var rec port.MailboxAuth
|
||||
var id uuid.UUID
|
||||
if err := row.Scan(&id, &rec.PasswordHash); err != nil {
|
||||
if err := row.Scan(&id, &rec.PasswordHash, &rec.EmailAddress, &rec.DomainName, &rec.MaxRecipientsPerHour); err != nil {
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
@@ -37,8 +40,13 @@ func (r *OutboundRepository) FetchNextReady(ctx context.Context, workerID string
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
WITH ready_jobs AS (
|
||||
SELECT id FROM outbound_jobs
|
||||
WHERE status IN ('QUEUED', 'DEFERRED')
|
||||
AND next_attempt_at <= NOW()
|
||||
WHERE (
|
||||
(status IN ('QUEUED', 'DEFERRED') AND next_attempt_at <= NOW())
|
||||
-- Reclaim jobs whose worker died mid-delivery. Without this
|
||||
-- they stay SENDING forever and the message is silently
|
||||
-- lost, which contradicts ADR-002's durability guarantee.
|
||||
OR (status = 'SENDING' AND locked_until IS NOT NULL AND locked_until <= NOW())
|
||||
)
|
||||
ORDER BY next_attempt_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
@@ -90,6 +98,22 @@ func (r *OutboundRepository) FetchNextReady(ctx context.Context, workerID string
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
// CountRecipientsSince counts queued recipients, not messages: one message to
|
||||
// fifty addresses is fifty recipients against the limit, which is the number
|
||||
// that matters for abuse.
|
||||
func (r *OutboundRepository) CountRecipientsSince(ctx context.Context, mailboxID uuid.UUID, since time.Time) (int, error) {
|
||||
var count int
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM outbound_jobs
|
||||
WHERE mailbox_id = $1 AND created_at >= $2
|
||||
`, mailboxID, since).Scan(&count)
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count recent recipients for mailbox %s: %w", mailboxID, err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *OutboundRepository) UpdateJob(ctx context.Context, job *entity.OutboundJob) error {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE outbound_jobs
|
||||
|
||||
@@ -7,15 +7,16 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
)
|
||||
|
||||
type RspamdResponse struct {
|
||||
Action string `json:"action"`
|
||||
Score float64 `json:"score"`
|
||||
RequiredScore float64 `json:"required_score"`
|
||||
Action string `json:"action"`
|
||||
Score float64 `json:"score"`
|
||||
RequiredScore float64 `json:"required_score"`
|
||||
Symbols map[string]map[string]interface{} `json:"symbols"`
|
||||
}
|
||||
|
||||
@@ -29,8 +30,11 @@ func NewRspamdAdapter(baseURL string) *RspamdAdapter {
|
||||
baseURL = "http://localhost:11333"
|
||||
}
|
||||
return &RspamdAdapter{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
httpClient: &http.Client{},
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
// A scan sits in the middle of an open SMTP transaction, so an
|
||||
// unbounded request would pin the session (and the sender's socket)
|
||||
// until the peer gives up.
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +90,9 @@ func (a *RspamdAdapter) Scan(ctx context.Context, input port.ScanInput) (*valueo
|
||||
switch parsed.Action {
|
||||
case "reject":
|
||||
res.Verdict = valueobject.ScanVerdictSpamReject
|
||||
case "greylist":
|
||||
// "soft reject" is Rspamd asking the sender to retry later (rate limit or
|
||||
// transient policy); like a greylist it maps to a 4xx, never to acceptance.
|
||||
case "greylist", "soft reject":
|
||||
res.Verdict = valueobject.ScanVerdictGreylist
|
||||
case "add header", "rewrite subject":
|
||||
res.Verdict = valueobject.ScanVerdictSpamJunk
|
||||
|
||||
@@ -18,7 +18,12 @@ func TestRspamdAdapter_Scan_MapsActions(t *testing.T) {
|
||||
}{
|
||||
{"no action", valueobject.ScanVerdictClean},
|
||||
{"add header", valueobject.ScanVerdictSpamJunk},
|
||||
{"rewrite subject", valueobject.ScanVerdictSpamJunk},
|
||||
{"greylist", valueobject.ScanVerdictGreylist},
|
||||
// Rspamd's "soft reject" asks the sender to come back later (rate
|
||||
// limit, transient policy). Treating it as clean would accept mail
|
||||
// the filter explicitly refused to accept yet.
|
||||
{"soft reject", valueobject.ScanVerdictGreylist},
|
||||
{"reject", valueobject.ScanVerdictSpamReject},
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user