#18: implement SMTP Submission (587/465), SASL PLAIN/LOGIN auth and preflight diagnostics

This commit is contained in:
Lucas Rayan committed 2026-08-01 22:49:02 -03:00
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},
}