From ae34400009d9a6d1d9bce6d61dc50d4463eee902 Mon Sep 17 00:00:00 2001 From: Michael Rosenfeld Date: Sat, 23 May 2026 22:32:19 -0400 Subject: [PATCH] feat: add EncryptRecord API and tests Introduce EncryptRecord method to encrypt TXT record values via the Forward Email API. Add corresponding error handling and unit tests. Update tools import order for consistency. --- forwardemail/client_test.go | 12 +++---- forwardemail/encrypt.go | 49 ++++++++++++++++++++++++++ forwardemail/encrypt_test.go | 66 ++++++++++++++++++++++++++++++++++++ forwardemail/errors.go | 10 ++++++ tools/tools.go | 4 +-- 5 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 forwardemail/encrypt.go create mode 100644 forwardemail/encrypt_test.go diff --git a/forwardemail/client_test.go b/forwardemail/client_test.go index abe96b3..c31b6be 100644 --- a/forwardemail/client_test.go +++ b/forwardemail/client_test.go @@ -29,9 +29,9 @@ func TestNewClient(t *testing.T) { wantURL: "https://api.forwardemail.net", }, { - name: "with custom URL", - apiKey: "4e4d6c332b6fe62a63afe56171fd3725", - opts: []Option{WithAPIURL("https://google.com")}, + name: "with custom URL", + apiKey: "4e4d6c332b6fe62a63afe56171fd3725", + opts: []Option{WithAPIURL("https://google.com")}, wantURL: "https://google.com", }, { @@ -41,9 +41,9 @@ func TestNewClient(t *testing.T) { wantError: ErrNilHTTPClient, }, { - name: "with custom http client", - apiKey: "4e4d6c332b6fe62a63afe56171fd3725", - opts: []Option{WithHTTPClient(&http.Client{Timeout: 10 * time.Second})}, + name: "with custom http client", + apiKey: "4e4d6c332b6fe62a63afe56171fd3725", + opts: []Option{WithHTTPClient(&http.Client{Timeout: 10 * time.Second})}, wantURL: "https://api.forwardemail.net", }, } diff --git a/forwardemail/encrypt.go b/forwardemail/encrypt.go new file mode 100644 index 0000000..e1d5850 --- /dev/null +++ b/forwardemail/encrypt.go @@ -0,0 +1,49 @@ +// Copyright Forward Email 2026 +// SPDX-License-Identifier: MIT + +package forwardemail + +// Forward Email allows you to encrypt records even on the free plan at no cost. As highly requested in a Privacy Guides discussion and on our GitHub issues we've added this. + +import ( + "context" + "encoding/json" + "strings" +) + +// EncryptionResponse represents the API Response for encrypting a TXT record. +type EncryptionResponse struct { + Encrypted string `json:"encrypted"` +} + +// EncryptRecord allows you to encrypt a TXT record value so that you can safely add it to your DNS without exposing the value in plaintext. +func (c *Client) EncryptRecord(ctx context.Context, input string) (*EncryptionResponse, error) { + if ctx == nil { + return nil, ErrNilContext + } + if err := ctx.Err(); err != nil { + return nil, err + } + + if input == "" { + return nil, ErrMissingEncryptionInput + } + + payload := strings.NewReader(`{"input": "` + input + `"}`) + req, err := c.newRequest(ctx, "POST", "/v1/encrypt", payload) + if err != nil { + return nil, ErrFailedToCreateRequest + } + + res, err := c.doRequest(req) + if err != nil { + return nil, ErrFailedToDoRequest + } + + var encryptionResponse EncryptionResponse + if err := json.Unmarshal(res, &encryptionResponse); err != nil { + return nil, ErrFailedToUnmarshalResponse + } + + return &encryptionResponse, nil +} diff --git a/forwardemail/encrypt_test.go b/forwardemail/encrypt_test.go new file mode 100644 index 0000000..c6d0f50 --- /dev/null +++ b/forwardemail/encrypt_test.go @@ -0,0 +1,66 @@ +// Copyright Forward Email 2026 +// SPDX-License-Identifier: MIT + +package forwardemail + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestClient_EncryptRecord(t *testing.T) { + const input = "forward-email-site-verification=abc123" + + response := `{ + "encrypted": "forward-email-site-verification=$2a$10$abc123" + }` + want := &EncryptionResponse{ + Encrypted: "forward-email-site-verification=$2a$10$abc123", + } + + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST method, got %s", r.Method) + } + if r.URL.Path != "/v1/encrypt" { + t.Errorf("expected URL /v1/encrypt, got %s", r.URL.Path) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("failed to read request body: %v", err) + } + if string(body) != fmt.Sprintf(`{"input": "%s"}`, input) { + t.Errorf("unexpected request body %q", string(body)) + } + + fmt.Fprint(w, response) + })) + defer svr.Close() + + c, _ := NewClient("test-key", WithAPIURL(svr.URL)) + + got, err := c.EncryptRecord(context.Background(), input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("values are not the same %s", diff) + } +} + +func TestClient_EncryptRecordMissingInput(t *testing.T) { + c, _ := NewClient("test-key") + + _, err := c.EncryptRecord(context.Background(), "") + if !errors.Is(err, ErrMissingEncryptionInput) { + t.Fatalf("expected error %v, got %v", ErrMissingEncryptionInput, err) + } +} diff --git a/forwardemail/errors.go b/forwardemail/errors.go index 538d12b..84a2bfe 100644 --- a/forwardemail/errors.go +++ b/forwardemail/errors.go @@ -22,6 +22,13 @@ var ( ErrEmptyPath = errors.New("request path cannot be empty") // ErrNilHTTPClient is returned when the client has a nil HTTPClient. ErrNilHTTPClient = errors.New("HTTP client cannot be nil") + // ErrFailedToCreateRequest is returned when a new HTTP request cannot be created. + ErrFailedToCreateRequest = errors.New("failed to create HTTP request") + // ErrFailedToDoRequest is returned when an HTTP request cannot be completed. + ErrFailedToDoRequest = errors.New("failed to complete HTTP request") + // ErrFailedToUnmarshalResponse is returned when an API response cannot be unmarshaled. + ErrFailedToUnmarshalResponse = errors.New("failed to unmarshal API response") + // ErrEmptyDomain is returned when a domain parameter is empty. ErrEmptyDomain = errors.New("domain cannot be empty") // ErrEmptyAlias is returned when an alias parameter is empty. @@ -32,6 +39,9 @@ var ( ErrEmptyEmail = errors.New("email cannot be empty") // ErrEmptyGroup is returned when a group parameter is empty. ErrEmptyGroup = errors.New("group cannot be empty") + + // ErrMissingEncryptionInput is returned when required input for encryption is missing. + ErrMissingEncryptionInput = errors.New("missing required input for encryption") ) // APIError represents an error response from the Forward Email API. diff --git a/tools/tools.go b/tools/tools.go index d0b8660..047ba51 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -3,10 +3,10 @@ package tools import ( + _ "github.com/golangci/golangci-lint/v2/cmd/golangci-lint" _ "github.com/hashicorp/copywrite" - _ "mvdan.cc/gofumpt" _ "golang.org/x/tools/cmd/goimports" - _ "github.com/golangci/golangci-lint/v2/cmd/golangci-lint" + _ "mvdan.cc/gofumpt" ) // Generate copyright headers