diff --git a/cmd/transactional.go b/cmd/transactional.go index 044485b..0f52b35 100644 --- a/cmd/transactional.go +++ b/cmd/transactional.go @@ -112,10 +112,12 @@ func transactionalSendRunE(cmd *cobra.Command, args []string) error { email, _ := cmd.Flags().GetString("email") id, _ := cmd.Flags().GetString("id") dataRaw, _ := cmd.Flags().GetString("data") + idempotencyKey, _ := cmd.Flags().GetString("idempotency-key") req := api.SendTransactionalRequest{ Email: email, TransactionalID: id, + IdempotencyKey: idempotencyKey, } if cmd.Flags().Changed("add-to-audience") { @@ -155,6 +157,7 @@ func addTransactionalSendFlags(cmd *cobra.Command) { cmd.Flags().BoolP("add-to-audience", "a", false, "Create a contact if one doesn't exist") cmd.Flags().String("data", "", "Data variables as a JSON object") cmd.Flags().StringArrayP("attachment", "A", nil, "Path to a file to attach (repeatable)") + cmd.Flags().String("idempotency-key", "", "Idempotency key to prevent duplicate sends") cmd.MarkFlagRequired("email") cmd.MarkFlagRequired("id") } diff --git a/internal/api/transactional.go b/internal/api/transactional.go index fe66423..bd9b461 100644 --- a/internal/api/transactional.go +++ b/internal/api/transactional.go @@ -27,6 +27,7 @@ type SendTransactionalRequest struct { AddToAudience *bool `json:"addToAudience,omitempty"` DataVariables map[string]any `json:"dataVariables,omitempty"` Attachments []Attachment `json:"attachments,omitempty"` + IdempotencyKey string `json:"-"` } func (c *Client) SendTransactional(req SendTransactionalRequest) error { @@ -39,6 +40,9 @@ func (c *Client) SendTransactional(req SendTransactionalRequest) error { if err != nil { return err } + if req.IdempotencyKey != "" { + httpReq.Header.Set("Idempotency-Key", req.IdempotencyKey) + } resp, err := c.do(httpReq) if err != nil { diff --git a/internal/api/transactional_test.go b/internal/api/transactional_test.go index d3a661c..be4f831 100644 --- a/internal/api/transactional_test.go +++ b/internal/api/transactional_test.go @@ -285,6 +285,54 @@ func TestSendTransactional(t *testing.T) { } } +func TestSendTransactional_IdempotencyKey(t *testing.T) { + tests := []struct { + name string + idempotencyKey string + wantHeader string + }{ + { + name: "sets header when provided", + idempotencyKey: "my-key-123", + wantHeader: "my-key-123", + }, + { + name: "omits header when empty", + idempotencyKey: "", + wantHeader: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotHeader string + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("Idempotency-Key") + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &body) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key") + client.SendTransactional(SendTransactionalRequest{ + Email: "a@b.com", + TransactionalID: "abc", + IdempotencyKey: tt.idempotencyKey, + }) + + if gotHeader != tt.wantHeader { + t.Errorf("Idempotency-Key header = %q, want %q", gotHeader, tt.wantHeader) + } + if _, ok := body["idempotencyKey"]; ok { + t.Error("idempotencyKey should not appear in request body") + } + }) + } +} + func TestSendTransactional_RequestBody(t *testing.T) { addToAudience := true tests := []struct {