#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)
}
}