diff --git a/Taskfile.yml b/Taskfile.yml index 109c092..b16238f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -46,11 +46,31 @@ tasks: - git tag {{.tag}} - git push origin {{.tag}} preconditions: + - sh: 'echo "{{.tag}}" | grep -Eq "^v[0-9]+\.[0-9]+\.[0-9]+$"' + msg: "tag {{.tag}} must be a release formatted: v0.0.0 (use release:pre for prereleases)" - sh: '[ -z "$(git ls-remote --tags origin refs/tags/{{.tag}})" ]' msg: "tag {{.tag}} already exists on remote" - sh: '[ "$(git branch --show-current)" = "main" ]' msg: make sure you're on main + release:pre: + desc: release a new cli prerelease version + aliases: [prerelease] + deps: + - release:check + requires: + vars: + - tag + cmds: + - git pull --rebase + - git tag {{.tag}} + - git push origin {{.tag}} + preconditions: + - sh: 'echo "{{.tag}}" | grep -Eq "^v[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+$"' + msg: "tag {{.tag}} must be prerelease formatted: v0.0.0-some-tag" + - sh: '[ -z "$(git ls-remote --tags origin refs/tags/{{.tag}})" ]' + msg: "tag {{.tag}} already exists on remote" + release:local: desc: build with goreleaser dotenv: [.env.macos-signing] diff --git a/cmd/campaigns.go b/cmd/campaigns.go new file mode 100644 index 0000000..4d72d67 --- /dev/null +++ b/cmd/campaigns.go @@ -0,0 +1,184 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/loops-so/cli/internal/api" + "github.com/loops-so/cli/internal/config" + "github.com/spf13/cobra" +) + +func fromEmailUsername(s string) string { + before, _, _ := strings.Cut(s, "@") + return before +} + +func runCampaignsGet(cfg *config.Config, id string) (*api.Campaign, error) { + return newAPIClient(cfg).GetCampaign(id) +} + +func runCampaignsList(cfg *config.Config, params api.PaginationParams) ([]api.Campaign, error) { + client := newAPIClient(cfg) + if params.Cursor != "" { + campaigns, _, err := client.ListCampaigns(params) + return campaigns, err + } + return api.Paginate(func(cursor string) ([]api.Campaign, *api.Pagination, error) { + return client.ListCampaigns(api.PaginationParams{ + PerPage: params.PerPage, + Cursor: cursor, + }) + }) +} + +var campaignsCmd = &cobra.Command{ + Use: "campaigns", + Short: "Manage campaigns", +} + +var campaignsListCmd = &cobra.Command{ + Use: "list", + Short: "List campaigns", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + campaigns, err := runCampaignsList(cfg, paginationParams(cmd)) + if err != nil { + return err + } + + if isJSONOutput() { + if campaigns == nil { + campaigns = []api.Campaign{} + } + return printJSON(cmd.OutOrStdout(), campaigns) + } + + if len(campaigns) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No campaigns found.") + return nil + } + + t := newStyledTable(cmd.OutOrStdout(), "ID", "MESSAGE ID", "NAME", "STATUS", "SUBJECT", "UPDATED") + for _, c := range campaigns { + t.Row( + c.CampaignID, + deref(c.EmailMessageID), + c.Name, + c.Status, + c.Subject, + c.UpdatedAt, + ) + } + return t.Render() + }, +} + +func runCampaignsCreate(cfg *config.Config, req api.CreateCampaignRequest) (*api.CampaignCreateResponse, error) { + return newAPIClient(cfg).CreateCampaign(req) +} + +var campaignsCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a draft campaign", + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + params, err := emailMessageFieldParamsFromCmd(cmd) + if err != nil { + return err + } + + req := api.CreateCampaignRequest{Name: name} + if len(params.Set) > 0 { + req.EmailMessage = &api.EmailMessageFields{ + Subject: params.Subject, + PreviewText: params.PreviewText, + FromName: params.FromName, + FromEmail: params.FromEmail, + ReplyToEmail: params.ReplyToEmail, + LMX: params.LMX, + } + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + resp, err := runCampaignsCreate(cfg, req) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), resp) + } + + emailMessageID := "" + if resp.EmailMessage != nil { + emailMessageID = resp.EmailMessage.EmailMessageID + } + fmt.Fprintf(cmd.OutOrStdout(), "Created. (id: %s, emailMessageId: %s)\n", resp.CampaignID, emailMessageID) + + if len(resp.Warnings) > 0 { + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintln(cmd.OutOrStdout(), "Warnings:") + for _, warn := range resp.Warnings { + if warn.Path != "" { + fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s (%s)\n", warn.Rule, warn.Message, warn.Path) + } else { + fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s\n", warn.Rule, warn.Message) + } + } + } + + return nil + }, +} + +var campaignsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a campaign", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + c, err := runCampaignsGet(cfg, args[0]) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), c) + } + + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("campaignId", c.CampaignID) + t.Row("emailMessageId", deref(c.EmailMessageID)) + t.Row("name", c.Name) + t.Row("status", c.Status) + t.Row("createdAt", c.CreatedAt) + t.Row("updatedAt", c.UpdatedAt) + return t.Render() + }, +} + +func init() { + addPaginationFlags(campaignsListCmd) + campaignsCmd.AddCommand(campaignsListCmd) + campaignsCmd.AddCommand(campaignsGetCmd) + + campaignsCreateCmd.Flags().StringP("name", "n", "", "Campaign name (required)") + addEmailMessageFieldFlags(campaignsCreateCmd) + campaignsCreateCmd.MarkFlagRequired("name") + campaignsCmd.AddCommand(campaignsCreateCmd) + + rootCmd.AddCommand(campaignsCmd) +} diff --git a/cmd/campaigns_create_test.go b/cmd/campaigns_create_test.go new file mode 100644 index 0000000..20dc3b5 --- /dev/null +++ b/cmd/campaigns_create_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "net/http" + "testing" + + "github.com/loops-so/cli/internal/api" +) + +func TestRunCampaignsCreate(t *testing.T) { + body := `{ + "success": true, + "campaignId": "cmp_new", + "name": "Spring", + "status": "Draft", + "createdAt": "2026-04-20T10:00:00Z", + "updatedAt": "2026-04-20T10:00:00Z", + "emailMessage": { + "emailMessageId": "em_new", + "campaignId": "cmp_new", + "subject": "Hello", + "previewText": "", + "fromName": "", + "fromEmail": "", + "replyToEmail": "", + "lmx": "", + "contentRevisionId": "rev_1", + "updatedAt": "2026-04-20T10:00:00Z" + } + }` + + t.Run("returns response on success", func(t *testing.T) { + serveJSON(t, http.StatusCreated, body) + resp, err := runCampaignsCreate(cfg(t), api.CreateCampaignRequest{Name: "Spring"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.CampaignID != "cmp_new" { + t.Errorf("CampaignID = %q, want cmp_new", resp.CampaignID) + } + if resp.EmailMessage == nil || resp.EmailMessage.EmailMessageID != "em_new" { + t.Errorf("EmailMessage = %v, want em_new", resp.EmailMessage) + } + }) + + t.Run("returns error on non-201 response", func(t *testing.T) { + serveJSON(t, http.StatusBadRequest, `{"success":false,"message":"name is required"}`) + _, err := runCampaignsCreate(cfg(t), api.CreateCampaignRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestFromEmailUsername(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"hello", "hello"}, + {"hello@acme.com", "hello"}, + {"hello@", "hello"}, + {"", ""}, + {"@acme.com", ""}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + if got := fromEmailUsername(tt.in); got != tt.want { + t.Errorf("fromEmailUsername(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/cmd/campaigns_get_test.go b/cmd/campaigns_get_test.go new file mode 100644 index 0000000..d9e7528 --- /dev/null +++ b/cmd/campaigns_get_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "net/http" + "testing" +) + +func TestRunCampaignsGet(t *testing.T) { + body := `{ + "success": true, + "campaignId": "cmp_abc123", + "emailMessageId": "em_abc123", + "name": "Spring Launch", + "status": "Draft", + "createdAt": "2026-04-01T10:00:00Z", + "updatedAt": "2026-04-02T10:00:00Z" + }` + + t.Run("returns the campaign", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + c, err := runCampaignsGet(cfg(t), "cmp_abc123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.CampaignID != "cmp_abc123" { + t.Errorf("CampaignID = %q, want cmp_abc123", c.CampaignID) + } + if deref(c.EmailMessageID) != "em_abc123" { + t.Errorf("EmailMessageID = %q, want em_abc123", deref(c.EmailMessageID)) + } + if c.Name != "Spring Launch" { + t.Errorf("Name = %q, want Spring Launch", c.Name) + } + if c.Status != "Draft" { + t.Errorf("Status = %q, want Draft", c.Status) + } + }) + + t.Run("returns error on non-200 response", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"success":false,"message":"Campaign not found"}`) + _, err := runCampaignsGet(cfg(t), "cmp_missing") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/cmd/campaigns_list_test.go b/cmd/campaigns_list_test.go new file mode 100644 index 0000000..a7e4459 --- /dev/null +++ b/cmd/campaigns_list_test.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "net/http" + "testing" + + "github.com/loops-so/cli/internal/api" +) + +func TestRunCampaignsList(t *testing.T) { + t.Run("returns campaigns", func(t *testing.T) { + serveJSON(t, http.StatusOK, `{"pagination":{"nextCursor":""},"data":[{"campaignId":"cmp_1","emailMessageId":"em_1","name":"Spring","subject":"Hi","status":"Draft","createdAt":"2026-04-01","updatedAt":"2026-04-02"}]}`) + campaigns, err := runCampaignsList(cfg(t), api.PaginationParams{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(campaigns) != 1 { + t.Fatalf("expected 1 campaign, got %d", len(campaigns)) + } + if campaigns[0].CampaignID != "cmp_1" { + t.Errorf("CampaignID = %q, want cmp_1", campaigns[0].CampaignID) + } + if deref(campaigns[0].EmailMessageID) != "em_1" { + t.Errorf("EmailMessageID = %q, want em_1", deref(campaigns[0].EmailMessageID)) + } + }) + + t.Run("returns error on api failure", func(t *testing.T) { + serveJSON(t, http.StatusUnauthorized, `{"error":"unauthorized"}`) + _, err := runCampaignsList(cfg(t), api.PaginationParams{}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/cmd/email_messages.go b/cmd/email_messages.go new file mode 100644 index 0000000..02e35f2 --- /dev/null +++ b/cmd/email_messages.go @@ -0,0 +1,223 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/loops-so/cli/internal/api" + "github.com/loops-so/cli/internal/config" + "github.com/spf13/cobra" +) + +// emailMessageFieldParams holds the six content fields shared by +// `campaigns create` and `email-messages update`. Set records which fields the +// user explicitly provided (keyed by JSON field name) so partial updates can +// send only those fields. +type emailMessageFieldParams struct { + Subject string + PreviewText string + FromName string + FromEmail string + ReplyToEmail string + LMX string + Set map[string]bool +} + +func addEmailMessageFieldFlags(cmd *cobra.Command) { + cmd.Flags().String("subject", "", "Email subject") + cmd.Flags().String("preview-text", "", "Email preview text") + cmd.Flags().String("from-name", "", "Sender name") + cmd.Flags().String("from-email", "", "Username only: a@example.com -> a") + cmd.Flags().String("reply-to", "", "Reply-to email address") + cmd.Flags().String("lmx", "", "LMX markup (inline)") + cmd.Flags().String("lmx-file", "", "Path to a file containing LMX markup") + cmd.MarkFlagsMutuallyExclusive("lmx", "lmx-file") +} + +func emailMessageFieldParamsFromCmd(cmd *cobra.Command) (emailMessageFieldParams, error) { + p := emailMessageFieldParams{Set: map[string]bool{}} + + if cmd.Flags().Changed("subject") { + p.Subject, _ = cmd.Flags().GetString("subject") + p.Set["subject"] = true + } + if cmd.Flags().Changed("preview-text") { + p.PreviewText, _ = cmd.Flags().GetString("preview-text") + p.Set["previewText"] = true + } + if cmd.Flags().Changed("from-name") { + p.FromName, _ = cmd.Flags().GetString("from-name") + p.Set["fromName"] = true + } + if cmd.Flags().Changed("from-email") { + v, _ := cmd.Flags().GetString("from-email") + p.FromEmail = fromEmailUsername(v) + p.Set["fromEmail"] = true + } + if cmd.Flags().Changed("reply-to") { + p.ReplyToEmail, _ = cmd.Flags().GetString("reply-to") + p.Set["replyToEmail"] = true + } + if cmd.Flags().Changed("lmx") { + p.LMX, _ = cmd.Flags().GetString("lmx") + p.Set["lmx"] = true + } + if cmd.Flags().Changed("lmx-file") { + path, _ := cmd.Flags().GetString("lmx-file") + data, err := os.ReadFile(path) + if err != nil { + return p, fmt.Errorf("read --lmx-file: %w", err) + } + p.LMX = string(data) + p.Set["lmx"] = true + } + return p, nil +} + +func runEmailMessagesGet(cfg *config.Config, id string) (*api.EmailMessage, error) { + return newAPIClient(cfg).GetEmailMessage(id) +} + +func runEmailMessagesUpdate(cfg *config.Config, id string, req api.UpdateEmailMessageRequest) (*api.EmailMessage, error) { + return newAPIClient(cfg).UpdateEmailMessage(id, req) +} + +func resolveExpectedRevisionID(cfg *config.Config, id, supplied string) (string, error) { + if supplied != "" { + return supplied, nil + } + msg, err := newAPIClient(cfg).GetEmailMessage(id) + if err != nil { + return "", fmt.Errorf("fetch current revision: %w", err) + } + return deref(msg.ContentRevisionID), nil +} + +var emailMessagesCmd = &cobra.Command{ + Use: "email-messages", + Short: "Manage email messages", +} + +var emailMessagesGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get an email message", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + msg, err := runEmailMessagesGet(cfg, args[0]) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), msg) + } + + return printEmailMessage(cmd, msg) + }, +} + +var emailMessagesUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a draft email message", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + params, err := emailMessageFieldParamsFromCmd(cmd) + if err != nil { + return err + } + + suppliedRevisionID, _ := cmd.Flags().GetString("expected-revision-id") + + cfg, err := loadConfig() + if err != nil { + return err + } + + expectedRevisionID, err := resolveExpectedRevisionID(cfg, args[0], suppliedRevisionID) + if err != nil { + return err + } + + req := api.UpdateEmailMessageRequest{ + EmailMessageFields: api.EmailMessageFields{ + Subject: params.Subject, + PreviewText: params.PreviewText, + FromName: params.FromName, + FromEmail: params.FromEmail, + ReplyToEmail: params.ReplyToEmail, + LMX: params.LMX, + }, + Set: params.Set, + ExpectedRevisionID: expectedRevisionID, + } + + msg, err := runEmailMessagesUpdate(cfg, args[0], req) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), msg) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Updated. (emailMessageId: %s, contentRevisionId: %s)\n", msg.EmailMessageID, deref(msg.ContentRevisionID)) + fmt.Fprintln(cmd.OutOrStdout()) + if err := printEmailMessage(cmd, msg); err != nil { + return err + } + printLmxWarnings(cmd, msg.Warnings) + return nil + }, +} + +func printEmailMessage(cmd *cobra.Command, msg *api.EmailMessage) error { + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("emailMessageId", msg.EmailMessageID) + t.Row("campaignId", deref(msg.CampaignID)) + t.Row("subject", msg.Subject) + t.Row("previewText", msg.PreviewText) + t.Row("fromName", msg.FromName) + t.Row("fromEmail", msg.FromEmail) + t.Row("replyToEmail", msg.ReplyToEmail) + t.Row("contentRevisionId", deref(msg.ContentRevisionID)) + t.Row("updatedAt", msg.UpdatedAt) + if err := t.Render(); err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintln(cmd.OutOrStdout(), "LMX:") + fmt.Fprintln(cmd.OutOrStdout(), msg.LMX) + return nil +} + +func printLmxWarnings(cmd *cobra.Command, warnings []api.LmxWarning) { + if len(warnings) == 0 { + return + } + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintln(cmd.OutOrStdout(), "Warnings:") + for _, warn := range warnings { + if warn.Path != "" { + fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s (%s)\n", warn.Rule, warn.Message, warn.Path) + } else { + fmt.Fprintf(cmd.OutOrStdout(), " [%s] %s\n", warn.Rule, warn.Message) + } + } +} + +func init() { + emailMessagesCmd.AddCommand(emailMessagesGetCmd) + + addEmailMessageFieldFlags(emailMessagesUpdateCmd) + emailMessagesUpdateCmd.Flags().StringP("expected-revision-id", "r", "", "Last-seen contentRevisionId. If omitted, the CLI fetches the current revision before posting.") + emailMessagesUpdateCmd.MarkFlagsOneRequired("subject", "preview-text", "from-name", "from-email", "reply-to", "lmx", "lmx-file") + emailMessagesCmd.AddCommand(emailMessagesUpdateCmd) + + rootCmd.AddCommand(emailMessagesCmd) +} diff --git a/cmd/email_messages_get_test.go b/cmd/email_messages_get_test.go new file mode 100644 index 0000000..286a3a1 --- /dev/null +++ b/cmd/email_messages_get_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "net/http" + "testing" +) + +func TestRunEmailMessagesGet(t *testing.T) { + body := `{ + "success": true, + "emailMessageId": "em_abc123", + "campaignId": "cmp_xyz789", + "subject": "Hello", + "previewText": "Preview", + "fromName": "Acme", + "fromEmail": "hello", + "replyToEmail": "support@acme.com", + "lmx": "Hi", + "contentRevisionId": "rev_1", + "updatedAt": "2026-04-20T10:00:00Z" + }` + + t.Run("returns the email message", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + msg, err := runEmailMessagesGet(cfg(t), "em_abc123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if msg.EmailMessageID != "em_abc123" { + t.Errorf("EmailMessageID = %q, want em_abc123", msg.EmailMessageID) + } + if deref(msg.CampaignID) != "cmp_xyz789" { + t.Errorf("CampaignID = %q, want cmp_xyz789", deref(msg.CampaignID)) + } + if msg.Subject != "Hello" { + t.Errorf("Subject = %q, want Hello", msg.Subject) + } + if deref(msg.ContentRevisionID) != "rev_1" { + t.Errorf("ContentRevisionID = %q, want rev_1", deref(msg.ContentRevisionID)) + } + }) + + t.Run("returns error on non-200 response", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"success":false,"message":"Email message not found"}`) + _, err := runEmailMessagesGet(cfg(t), "em_missing") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/cmd/email_messages_update_test.go b/cmd/email_messages_update_test.go new file mode 100644 index 0000000..a162c64 --- /dev/null +++ b/cmd/email_messages_update_test.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/loops-so/cli/internal/api" + "github.com/spf13/cobra" + "github.com/zalando/go-keyring" +) + +func TestRunEmailMessagesUpdate(t *testing.T) { + body := `{ + "success": true, + "emailMessageId": "em_abc123", + "campaignId": "cmp_xyz789", + "subject": "Updated", + "previewText": "", + "fromName": "", + "fromEmail": "", + "replyToEmail": "", + "lmx": "", + "contentRevisionId": "rev_2", + "updatedAt": "2026-04-20T11:00:00Z" + }` + + t.Run("returns updated message", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + msg, err := runEmailMessagesUpdate(cfg(t), "em_abc123", api.UpdateEmailMessageRequest{ + EmailMessageFields: api.EmailMessageFields{Subject: "Updated"}, + Set: map[string]bool{"subject": true}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if msg.EmailMessageID != "em_abc123" { + t.Errorf("EmailMessageID = %q, want em_abc123", msg.EmailMessageID) + } + if deref(msg.ContentRevisionID) != "rev_2" { + t.Errorf("ContentRevisionID = %q, want rev_2", deref(msg.ContentRevisionID)) + } + }) + + t.Run("returns error on 409 revision mismatch", func(t *testing.T) { + serveJSON(t, http.StatusConflict, `{"success":false,"message":"Revision mismatch"}`) + _, err := runEmailMessagesUpdate(cfg(t), "em_abc123", api.UpdateEmailMessageRequest{ + EmailMessageFields: api.EmailMessageFields{Subject: "Updated"}, + Set: map[string]bool{"subject": true}, + ExpectedRevisionID: "rev_stale", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestEmailMessageFieldParamsFromCmd(t *testing.T) { + t.Run("unset flags are absent from Set", func(t *testing.T) { + cmd := &cobra.Command{} + addEmailMessageFieldFlags(cmd) + cmd.ParseFlags([]string{"--subject", "Hello"}) + + params, err := emailMessageFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !params.Set["subject"] { + t.Errorf(`Set["subject"] = false, want true`) + } + for _, k := range []string{"previewText", "fromName", "fromEmail", "replyToEmail", "lmx"} { + if params.Set[k] { + t.Errorf(`Set[%q] = true, want absent`, k) + } + } + if params.Subject != "Hello" { + t.Errorf("Subject = %q, want Hello", params.Subject) + } + }) + + t.Run("empty-string flag still marks field as set", func(t *testing.T) { + cmd := &cobra.Command{} + addEmailMessageFieldFlags(cmd) + cmd.ParseFlags([]string{"--preview-text", ""}) + + params, err := emailMessageFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !params.Set["previewText"] { + t.Error("expected previewText in Set even when empty") + } + if params.PreviewText != "" { + t.Errorf("PreviewText = %q, want empty", params.PreviewText) + } + }) + + t.Run("from-email strips @domain", func(t *testing.T) { + cmd := &cobra.Command{} + addEmailMessageFieldFlags(cmd) + cmd.ParseFlags([]string{"--from-email", "hello@acme.com"}) + + params, err := emailMessageFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if params.FromEmail != "hello" { + t.Errorf("FromEmail = %q, want hello", params.FromEmail) + } + if !params.Set["fromEmail"] { + t.Error("expected fromEmail in Set") + } + }) + + t.Run("lmx-file reads file into LMX and sets lmx", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "msg.lmx") + if err := os.WriteFile(path, []byte("From file"), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + cmd := &cobra.Command{} + addEmailMessageFieldFlags(cmd) + cmd.ParseFlags([]string{"--lmx-file", path}) + + params, err := emailMessageFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if params.LMX != "From file" { + t.Errorf("LMX = %q", params.LMX) + } + if !params.Set["lmx"] { + t.Error("expected lmx in Set when --lmx-file is used") + } + }) + + t.Run("missing lmx-file returns error", func(t *testing.T) { + cmd := &cobra.Command{} + addEmailMessageFieldFlags(cmd) + cmd.ParseFlags([]string{"--lmx-file", "/does/not/exist.lmx"}) + + if _, err := emailMessageFieldParamsFromCmd(cmd); err == nil { + t.Fatal("expected error for missing file, got nil") + } + }) +} + +func TestResolveExpectedRevisionID(t *testing.T) { + t.Run("supplied value is returned without any HTTP call", func(t *testing.T) { + var called bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + keyring.MockInit() + t.Setenv("LOOPS_CONFIG_DIR", t.TempDir()) + t.Setenv("LOOPS_API_KEY", "test-key") + t.Setenv("LOOPS_ENDPOINT_URL", srv.URL) + + got, err := resolveExpectedRevisionID(cfg(t), "em_abc123", "rev_supplied") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "rev_supplied" { + t.Errorf("got %q, want rev_supplied", got) + } + if called { + t.Error("expected no HTTP call when revision id supplied") + } + }) + + t.Run("empty supplied triggers GET and returns current contentRevisionId", func(t *testing.T) { + body := `{ + "success": true, + "emailMessageId": "em_abc123", + "campaignId": "cmp_xyz789", + "subject": "Hello", + "previewText": "", + "fromName": "", + "fromEmail": "", + "replyToEmail": "", + "lmx": "", + "contentRevisionId": "rev_current", + "updatedAt": "2026-04-20T10:00:00Z" + }` + serveJSON(t, http.StatusOK, body) + + got, err := resolveExpectedRevisionID(cfg(t), "em_abc123", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "rev_current" { + t.Errorf("got %q, want rev_current", got) + } + }) + + t.Run("GET failure surfaces a wrapped error", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"success":false,"message":"Email message not found"}`) + + _, err := resolveExpectedRevisionID(cfg(t), "em_missing", "") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/cmd/root.go b/cmd/root.go index d12f04a..fd9ce77 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,8 +2,10 @@ package cmd import ( "context" + "errors" "io" "os" + "strings" "time" "charm.land/fang/v2" @@ -39,11 +41,13 @@ var rootCmd = &cobra.Command{ } func jsonAwareErrorHandler(w io.Writer, styles fang.Styles, err error) { + // fang always appends a period to error output. if err has a trailing period, strip it. + msg := strings.TrimRight(err.Error(), ".") if isJSONOutput() { - _ = printJSON(w, Result{Success: false, Message: err.Error()}) + _ = printJSON(w, Result{Success: false, Message: msg}) return } - fang.DefaultErrorHandler(w, styles, err) + fang.DefaultErrorHandler(w, styles, errors.New(msg)) } func Execute() { diff --git a/internal/api/campaigns.go b/internal/api/campaigns.go new file mode 100644 index 0000000..19f101d --- /dev/null +++ b/internal/api/campaigns.go @@ -0,0 +1,143 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +type Campaign struct { + CampaignID string `json:"campaignId"` + EmailMessageID *string `json:"emailMessageId"` + Name string `json:"name"` + Subject string `json:"subject"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type LmxWarning struct { + Rule string `json:"rule"` + Severity string `json:"severity"` + Message string `json:"message"` + Path string `json:"path,omitempty"` +} + +type EmailMessageFields struct { + Subject string `json:"subject,omitempty"` + PreviewText string `json:"previewText,omitempty"` + FromName string `json:"fromName,omitempty"` + FromEmail string `json:"fromEmail,omitempty"` + ReplyToEmail string `json:"replyToEmail,omitempty"` + LMX string `json:"lmx,omitempty"` +} + +type CreateCampaignRequest struct { + Name string `json:"name"` + EmailMessage *EmailMessageFields `json:"emailMessage,omitempty"` +} + +type CampaignCreateResponse struct { + CampaignID string `json:"campaignId"` + Name string `json:"name"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + EmailMessage *EmailMessage `json:"emailMessage,omitempty"` + Warnings []LmxWarning `json:"warnings,omitempty"` +} + +func (c *Client) CreateCampaign(req CreateCampaignRequest) (*CampaignCreateResponse, error) { + b, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to encode request: %w", err) + } + + httpReq, err := c.newRequest(http.MethodPost, "/campaigns", bytes.NewReader(b)) + if err != nil { + return nil, err + } + + resp, err := c.do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return nil, errorFromResponse(resp) + } + + var result CampaignCreateResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +func (c *Client) GetCampaign(id string) (*Campaign, error) { + req, err := c.newRequest(http.MethodGet, "/campaigns/"+id, nil) + if err != nil { + return nil, err + } + + resp, err := c.do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errorFromResponse(resp) + } + + var result Campaign + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +func (c *Client) ListCampaigns(params PaginationParams) ([]Campaign, *Pagination, error) { + q := url.Values{} + if params.PerPage != "" { + q.Set("perPage", params.PerPage) + } + if params.Cursor != "" { + q.Set("cursor", params.Cursor) + } + + path := "/campaigns" + if len(q) > 0 { + path += "?" + q.Encode() + } + + req, err := c.newRequest(http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + resp, err := c.do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, nil, errorFromResponse(resp) + } + + var result struct { + Pagination Pagination `json:"pagination"` + Data []Campaign `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, nil, fmt.Errorf("failed to decode response: %w", err) + } + + return result.Data, &result.Pagination, nil +} diff --git a/internal/api/campaigns_test.go b/internal/api/campaigns_test.go new file mode 100644 index 0000000..470885e --- /dev/null +++ b/internal/api/campaigns_test.go @@ -0,0 +1,475 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const listCampaignsResponse = `{ + "success": true, + "pagination": { + "totalResults": 2, + "returnedResults": 2, + "perPage": 20, + "totalPages": 1, + "nextCursor": "", + "nextPage": "" + }, + "data": [ + { + "campaignId": "cmp_1", + "emailMessageId": "em_1", + "name": "Spring Launch", + "subject": "New arrivals", + "status": "Draft", + "createdAt": "2026-04-01T10:00:00Z", + "updatedAt": "2026-04-02T10:00:00Z" + }, + { + "campaignId": "cmp_2", + "emailMessageId": null, + "name": "Summer Sale", + "subject": "50% off", + "status": "Sent", + "createdAt": "2026-03-01T10:00:00Z", + "updatedAt": "2026-03-05T10:00:00Z" + } + ] +}` + +const createCampaignResponse = `{ + "success": true, + "campaignId": "cmp_new", + "name": "Spring Launch", + "status": "Draft", + "createdAt": "2026-04-20T10:00:00Z", + "updatedAt": "2026-04-20T10:00:00Z", + "emailMessage": { + "emailMessageId": "em_new", + "campaignId": "cmp_new", + "subject": "Hello", + "previewText": "Preview", + "fromName": "Acme", + "fromEmail": "hello", + "replyToEmail": "support@acme.com", + "lmx": "Hi", + "contentRevisionId": "rev_1", + "updatedAt": "2026-04-20T10:00:00Z" + }, + "warnings": [ + {"rule":"unknown_attr","severity":"warning","message":" has unknown attribute \"foo\"","path":"body.0"} + ] +}` + +func TestCreateCampaign(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantAPIErr *APIError + wantErrMsg string + }{ + { + name: "success", + statusCode: http.StatusCreated, + body: createCampaignResponse, + }, + { + name: "bad request", + statusCode: http.StatusBadRequest, + body: `{"success":false,"message":"name is required"}`, + wantAPIErr: &APIError{StatusCode: http.StatusBadRequest, Message: "name is required"}, + }, + { + name: "lmx compile failure", + statusCode: http.StatusUnprocessableEntity, + body: `{"success":false,"message":"LMX failed to compile"}`, + wantAPIErr: &APIError{StatusCode: http.StatusUnprocessableEntity, Message: "LMX failed to compile"}, + }, + { + name: "invalid json", + statusCode: http.StatusCreated, + body: `not json`, + wantErrMsg: "failed to decode response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + resp, err := client.CreateCampaign(CreateCampaignRequest{Name: "Spring Launch"}) + + if tt.wantAPIErr != nil { + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != tt.wantAPIErr.StatusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode) + } + if apiErr.Message != tt.wantAPIErr.Message { + t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message) + } + return + } + + if tt.wantErrMsg != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg) + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrMsg) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.CampaignID != "cmp_new" { + t.Errorf("CampaignID = %q, want cmp_new", resp.CampaignID) + } + if resp.EmailMessage == nil || resp.EmailMessage.EmailMessageID != "em_new" { + t.Errorf("EmailMessage.EmailMessageID = %v, want em_new", resp.EmailMessage) + } + if len(resp.Warnings) != 1 || resp.Warnings[0].Rule != "unknown_attr" { + t.Errorf("Warnings = %v, want [unknown_attr]", resp.Warnings) + } + }) + } +} + +func TestCreateCampaign_RequestBody(t *testing.T) { + tests := []struct { + name string + req CreateCampaignRequest + wantName string + wantEmail bool + }{ + { + name: "name only", + req: CreateCampaignRequest{Name: "Spring"}, + wantName: "Spring", + }, + { + name: "with email message", + req: CreateCampaignRequest{ + Name: "Spring", + EmailMessage: &EmailMessageFields{ + Subject: "Hello", + LMX: "Hi", + }, + }, + wantName: "Spring", + wantEmail: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &body) + w.WriteHeader(http.StatusCreated) + w.Write([]byte(createCampaignResponse)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + if _, err := client.CreateCampaign(tt.req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if body["name"] != tt.wantName { + t.Errorf("name = %v, want %q", body["name"], tt.wantName) + } + _, hasEmail := body["emailMessage"] + if hasEmail != tt.wantEmail { + t.Errorf("emailMessage present = %v, want %v", hasEmail, tt.wantEmail) + } + if tt.wantEmail { + em, _ := body["emailMessage"].(map[string]any) + if em["subject"] != "Hello" { + t.Errorf("emailMessage.subject = %v, want Hello", em["subject"]) + } + if em["lmx"] != "Hi" { + t.Errorf("emailMessage.lmx = %v", em["lmx"]) + } + } + }) + } +} + +func TestGetCampaign(t *testing.T) { + body := `{ + "success": true, + "campaignId": "cmp_abc123", + "emailMessageId": "em_abc123", + "name": "Spring Launch", + "status": "Draft", + "createdAt": "2026-04-01T10:00:00Z", + "updatedAt": "2026-04-02T10:00:00Z" + }` + + tests := []struct { + name string + id string + statusCode int + body string + wantAPIErr *APIError + wantErrMsg string + wantID string + }{ + { + name: "success", + id: "cmp_abc123", + statusCode: http.StatusOK, + body: body, + wantID: "cmp_abc123", + }, + { + name: "not found", + id: "cmp_missing", + statusCode: http.StatusNotFound, + body: `{"success":false,"message":"Campaign not found"}`, + wantAPIErr: &APIError{StatusCode: http.StatusNotFound, Message: "Campaign not found"}, + }, + { + name: "invalid id", + id: "bad", + statusCode: http.StatusBadRequest, + body: `{"success":false,"message":"Invalid campaignId"}`, + wantAPIErr: &APIError{StatusCode: http.StatusBadRequest, Message: "Invalid campaignId"}, + }, + { + name: "invalid json", + id: "cmp_abc123", + statusCode: http.StatusOK, + body: `not json`, + wantErrMsg: "failed to decode response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + result, err := client.GetCampaign(tt.id) + + if tt.wantAPIErr != nil { + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != tt.wantAPIErr.StatusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode) + } + if apiErr.Message != tt.wantAPIErr.Message { + t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message) + } + return + } + + if tt.wantErrMsg != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg) + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrMsg) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := "/campaigns/" + tt.id; gotPath != want { + t.Errorf("path = %q, want %q", gotPath, want) + } + if result.CampaignID != tt.wantID { + t.Errorf("CampaignID = %q, want %q", result.CampaignID, tt.wantID) + } + if result.EmailMessageID == nil || *result.EmailMessageID != "em_abc123" { + t.Errorf("EmailMessageID = %v, want em_abc123", result.EmailMessageID) + } + if result.Name != "Spring Launch" { + t.Errorf("Name = %q, want Spring Launch", result.Name) + } + if result.Status != "Draft" { + t.Errorf("Status = %q, want Draft", result.Status) + } + }) + } +} + +func TestListCampaigns(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantAPIErr *APIError + wantErrMsg string + wantCount int + }{ + { + name: "success", + statusCode: http.StatusOK, + body: listCampaignsResponse, + wantCount: 2, + }, + { + name: "empty list", + statusCode: http.StatusOK, + body: `{"success":true,"pagination":{"totalResults":0},"data":[]}`, + wantCount: 0, + }, + { + name: "unauthorized", + statusCode: http.StatusUnauthorized, + body: `{"success":false,"error":"Invalid API key"}`, + wantAPIErr: &APIError{StatusCode: http.StatusUnauthorized, Message: "Invalid API key"}, + }, + { + name: "invalid json", + statusCode: http.StatusOK, + body: `not json`, + wantErrMsg: "failed to decode response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + campaigns, pagination, err := client.ListCampaigns(PaginationParams{}) + + if tt.wantAPIErr != nil { + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != tt.wantAPIErr.StatusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode) + } + if tt.wantAPIErr.Message != "" && apiErr.Message != tt.wantAPIErr.Message { + t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message) + } + return + } + + if tt.wantErrMsg != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg) + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrMsg) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(campaigns) != tt.wantCount { + t.Errorf("len(campaigns) = %d, want %d", len(campaigns), tt.wantCount) + } + if pagination == nil { + t.Fatal("expected pagination, got nil") + } + }) + } +} + +func TestListCampaigns_ResponseData(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(listCampaignsResponse)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + campaigns, _, err := client.ListCampaigns(PaginationParams{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if campaigns[0].CampaignID != "cmp_1" { + t.Errorf("CampaignID = %q, want cmp_1", campaigns[0].CampaignID) + } + if campaigns[0].EmailMessageID == nil || *campaigns[0].EmailMessageID != "em_1" { + t.Errorf("EmailMessageID = %v, want em_1", campaigns[0].EmailMessageID) + } + if campaigns[0].Status != "Draft" { + t.Errorf("Status = %q, want Draft", campaigns[0].Status) + } + if campaigns[1].EmailMessageID != nil { + t.Errorf("expected nil EmailMessageID, got %v", campaigns[1].EmailMessageID) + } +} + +func TestListCampaigns_QueryParams(t *testing.T) { + tests := []struct { + name string + params PaginationParams + wantPerPage string + wantCursor string + }{ + { + name: "no params", + params: PaginationParams{}, + }, + { + name: "both params", + params: PaginationParams{PerPage: "10", Cursor: "xyz"}, + wantPerPage: "10", + wantCursor: "xyz", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPerPage, gotCursor string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPerPage = r.URL.Query().Get("perPage") + gotCursor = r.URL.Query().Get("cursor") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"pagination":{},"data":[]}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + client.ListCampaigns(tt.params) + + if gotPerPage != tt.wantPerPage { + t.Errorf("perPage = %q, want %q", gotPerPage, tt.wantPerPage) + } + if gotCursor != tt.wantCursor { + t.Errorf("cursor = %q, want %q", gotCursor, tt.wantCursor) + } + }) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index 4fe8f5c..8411e6b 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -70,6 +70,26 @@ func errorFromResponse(resp *http.Response) *APIError { return &APIError{StatusCode: resp.StatusCode, Message: fmt.Sprintf("unexpected status: %d", resp.StatusCode)} } +func (c *Client) logResponse(resp *http.Response) { + raw, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "[debug] Response: %s (body read failed: %v)\n", resp.Status, err) + resp.Body = io.NopCloser(bytes.NewReader(nil)) + return + } + resp.Body = io.NopCloser(bytes.NewReader(raw)) + fmt.Fprintf(os.Stderr, "[debug] Response: %s (%d bytes)\n", resp.Status, len(raw)) + if len(raw) == 0 { + return + } + var pretty bytes.Buffer + if json.Indent(&pretty, raw, "", " ") == nil { + fmt.Fprintf(os.Stderr, "[debug] Body:\n%s\n", pretty.String()) + } else { + fmt.Fprintf(os.Stderr, "[debug] Body: %s\n", raw) + } +} + func (c *Client) do(req *http.Request) (*http.Response, error) { var ( resp *http.Response @@ -96,6 +116,9 @@ func (c *Client) do(req *http.Request) (*http.Response, error) { continue } if !isRetryable(resp.StatusCode) { + if c.debug { + c.logResponse(resp) + } return resp, nil } if attempt < maxRetries { diff --git a/internal/api/email_messages.go b/internal/api/email_messages.go new file mode 100644 index 0000000..2614719 --- /dev/null +++ b/internal/api/email_messages.go @@ -0,0 +1,104 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type EmailMessage struct { + EmailMessageID string `json:"emailMessageId"` + CampaignID *string `json:"campaignId"` + Subject string `json:"subject"` + PreviewText string `json:"previewText"` + FromName string `json:"fromName"` + FromEmail string `json:"fromEmail"` + ReplyToEmail string `json:"replyToEmail"` + LMX string `json:"lmx"` + ContentRevisionID *string `json:"contentRevisionId"` + UpdatedAt string `json:"updatedAt"` + Warnings []LmxWarning `json:"warnings,omitempty"` +} + +type UpdateEmailMessageRequest struct { + EmailMessageFields + Set map[string]bool + ExpectedRevisionID string +} + +func (c *Client) GetEmailMessage(id string) (*EmailMessage, error) { + req, err := c.newRequest(http.MethodGet, "/email-messages/"+id, nil) + if err != nil { + return nil, err + } + + resp, err := c.do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errorFromResponse(resp) + } + + var result EmailMessage + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +func (c *Client) UpdateEmailMessage(id string, req UpdateEmailMessageRequest) (*EmailMessage, error) { + body := map[string]any{} + if req.Set["subject"] { + body["subject"] = req.Subject + } + if req.Set["previewText"] { + body["previewText"] = req.PreviewText + } + if req.Set["fromName"] { + body["fromName"] = req.FromName + } + if req.Set["fromEmail"] { + body["fromEmail"] = req.FromEmail + } + if req.Set["replyToEmail"] { + body["replyToEmail"] = req.ReplyToEmail + } + if req.Set["lmx"] { + body["lmx"] = req.LMX + } + if req.ExpectedRevisionID != "" { + body["expectedRevisionId"] = req.ExpectedRevisionID + } + + b, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to encode request: %w", err) + } + + httpReq, err := c.newRequest(http.MethodPost, "/email-messages/"+id, bytes.NewReader(b)) + if err != nil { + return nil, err + } + + resp, err := c.do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errorFromResponse(resp) + } + + var result EmailMessage + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} diff --git a/internal/api/email_messages_test.go b/internal/api/email_messages_test.go new file mode 100644 index 0000000..7c736ea --- /dev/null +++ b/internal/api/email_messages_test.go @@ -0,0 +1,316 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGetEmailMessage(t *testing.T) { + body := `{ + "success": true, + "emailMessageId": "em_abc123", + "campaignId": "cmp_xyz789", + "subject": "Hello", + "previewText": "Preview", + "fromName": "Acme", + "fromEmail": "hello", + "replyToEmail": "support@acme.com", + "lmx": "HiBody text.", + "contentRevisionId": "rev_1", + "updatedAt": "2026-04-20T10:00:00Z" + }` + + tests := []struct { + name string + id string + statusCode int + body string + wantAPIErr *APIError + wantErrMsg string + wantID string + }{ + { + name: "success", + id: "em_abc123", + statusCode: http.StatusOK, + body: body, + wantID: "em_abc123", + }, + { + name: "not found", + id: "em_missing", + statusCode: http.StatusNotFound, + body: `{"success":false,"message":"Email message not found"}`, + wantAPIErr: &APIError{StatusCode: http.StatusNotFound, Message: "Email message not found"}, + }, + { + name: "mjml conflict", + id: "em_mjml", + statusCode: http.StatusConflict, + body: `{"success":false,"message":"Email message uses MJML format"}`, + wantAPIErr: &APIError{StatusCode: http.StatusConflict, Message: "Email message uses MJML format"}, + }, + { + name: "invalid json", + id: "em_abc123", + statusCode: http.StatusOK, + body: `not json`, + wantErrMsg: "failed to decode response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + result, err := client.GetEmailMessage(tt.id) + + if tt.wantAPIErr != nil { + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != tt.wantAPIErr.StatusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode) + } + if apiErr.Message != tt.wantAPIErr.Message { + t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message) + } + return + } + + if tt.wantErrMsg != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErrMsg) + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tt.wantErrMsg) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := "/email-messages/" + tt.id; gotPath != want { + t.Errorf("path = %q, want %q", gotPath, want) + } + if result.EmailMessageID != tt.wantID { + t.Errorf("EmailMessageID = %q, want %q", result.EmailMessageID, tt.wantID) + } + if result.CampaignID == nil || *result.CampaignID != "cmp_xyz789" { + t.Errorf("CampaignID = %v, want cmp_xyz789", result.CampaignID) + } + if result.Subject != "Hello" { + t.Errorf("Subject = %q, want Hello", result.Subject) + } + if result.LMX != "HiBody text." { + t.Errorf("LMX = %q", result.LMX) + } + if result.ContentRevisionID == nil || *result.ContentRevisionID != "rev_1" { + t.Errorf("ContentRevisionID = %v, want rev_1", result.ContentRevisionID) + } + }) + } +} + +const updateEmailMessageResponse = `{ + "success": true, + "emailMessageId": "em_abc123", + "campaignId": "cmp_xyz789", + "subject": "Updated", + "previewText": "new preview", + "fromName": "Acme", + "fromEmail": "hello", + "replyToEmail": "support@acme.com", + "lmx": "Hi", + "contentRevisionId": "rev_2", + "updatedAt": "2026-04-20T11:00:00Z", + "warnings": [ + {"rule":"unknown_attr","severity":"warning","message":"unknown","path":"body.0"} + ] +}` + +func TestUpdateEmailMessage(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + wantAPIErr *APIError + }{ + { + name: "success", + statusCode: http.StatusOK, + body: updateEmailMessageResponse, + }, + { + name: "revision conflict", + statusCode: http.StatusConflict, + body: `{"success":false,"message":"Revision mismatch"}`, + wantAPIErr: &APIError{StatusCode: http.StatusConflict, Message: "Revision mismatch"}, + }, + { + name: "not found", + statusCode: http.StatusNotFound, + body: `{"success":false,"message":"Email message not found"}`, + wantAPIErr: &APIError{StatusCode: http.StatusNotFound, Message: "Email message not found"}, + }, + { + name: "lmx compile failure", + statusCode: http.StatusUnprocessableEntity, + body: `{"success":false,"message":"LMX failed to compile"}`, + wantAPIErr: &APIError{StatusCode: http.StatusUnprocessableEntity, Message: "LMX failed to compile"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPath, gotMethod string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.body)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + req := UpdateEmailMessageRequest{ + EmailMessageFields: EmailMessageFields{Subject: "Updated"}, + Set: map[string]bool{"subject": true}, + } + result, err := client.UpdateEmailMessage("em_abc123", req) + + if tt.wantAPIErr != nil { + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != tt.wantAPIErr.StatusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantAPIErr.StatusCode) + } + if apiErr.Message != tt.wantAPIErr.Message { + t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantAPIErr.Message) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodPost { + t.Errorf("method = %q, want POST", gotMethod) + } + if want := "/email-messages/em_abc123"; gotPath != want { + t.Errorf("path = %q, want %q", gotPath, want) + } + if result.EmailMessageID != "em_abc123" { + t.Errorf("EmailMessageID = %q, want em_abc123", result.EmailMessageID) + } + if result.ContentRevisionID == nil || *result.ContentRevisionID != "rev_2" { + t.Errorf("ContentRevisionID = %v, want rev_2", result.ContentRevisionID) + } + if len(result.Warnings) != 1 || result.Warnings[0].Rule != "unknown_attr" { + t.Errorf("Warnings = %v, want [unknown_attr]", result.Warnings) + } + }) + } +} + +func TestUpdateEmailMessage_RequestBody(t *testing.T) { + tests := []struct { + name string + req UpdateEmailMessageRequest + wantFields map[string]any + absent []string + }{ + { + name: "only subject set", + req: UpdateEmailMessageRequest{ + EmailMessageFields: EmailMessageFields{ + Subject: "Hello", + PreviewText: "ignored-not-in-set", + }, + Set: map[string]bool{"subject": true}, + }, + wantFields: map[string]any{"subject": "Hello"}, + absent: []string{"previewText", "fromName", "fromEmail", "replyToEmail", "lmx", "expectedRevisionId"}, + }, + { + name: "blank string is sent when set", + req: UpdateEmailMessageRequest{ + EmailMessageFields: EmailMessageFields{PreviewText: ""}, + Set: map[string]bool{"previewText": true}, + }, + wantFields: map[string]any{"previewText": ""}, + absent: []string{"subject"}, + }, + { + name: "expected revision id included when non-empty", + req: UpdateEmailMessageRequest{ + EmailMessageFields: EmailMessageFields{Subject: "Hi"}, + Set: map[string]bool{"subject": true}, + ExpectedRevisionID: "rev_1", + }, + wantFields: map[string]any{"subject": "Hi", "expectedRevisionId": "rev_1"}, + }, + { + name: "all six content fields", + req: UpdateEmailMessageRequest{ + EmailMessageFields: EmailMessageFields{ + Subject: "s", PreviewText: "p", FromName: "n", + FromEmail: "u", ReplyToEmail: "r@x.com", LMX: "

", + }, + Set: map[string]bool{ + "subject": true, "previewText": true, "fromName": true, + "fromEmail": true, "replyToEmail": true, "lmx": true, + }, + }, + wantFields: map[string]any{ + "subject": "s", "previewText": "p", "fromName": "n", + "fromEmail": "u", "replyToEmail": "r@x.com", "lmx": "

", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + json.Unmarshal(b, &body) + w.WriteHeader(http.StatusOK) + w.Write([]byte(updateEmailMessageResponse)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", false) + if _, err := client.UpdateEmailMessage("em_abc123", tt.req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + for k, want := range tt.wantFields { + if body[k] != want { + t.Errorf("body[%q] = %v, want %v", k, body[k], want) + } + } + for _, k := range tt.absent { + if _, present := body[k]; present { + t.Errorf("body[%q] should not be present, got %v", k, body[k]) + } + } + }) + } +}