From 05b2b067b2c11df2dae6c7b1f316d13dcaf41c6f Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:48:59 -0700 Subject: [PATCH 1/9] campaigns list: rm Subject, add Scheduling. campaigns update: set Set["name"]=true so the new selective-update api actually writes the field --- cmd/campaigns.go | 16 +++++++++++++--- cmd/campaigns_list_test.go | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 485a4e4..a8f6d32 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -8,6 +8,13 @@ import ( "github.com/spf13/cobra" ) +func formatCampaignScheduling(s loops.CampaignScheduling) string { + if s.Method == loops.CampaignSchedulingMethodSchedule && s.Timestamp != nil { + return "schedule @ " + *s.Timestamp + } + return s.Method +} + func runCampaignsGet(cfg *config.Config, id string) (*loops.Campaign, error) { return newAPIClient(cfg).GetCampaign(id) } @@ -61,7 +68,7 @@ var campaignsListCmd = &cobra.Command{ return nil } - headers := []string{"ID", "MESSAGE ID", "NAME", "STATUS", "SUBJECT", "UPDATED"} + headers := []string{"ID", "MESSAGE ID", "NAME", "STATUS", "SCHEDULING", "UPDATED"} rows := make([][]string, 0, len(campaigns)) for _, c := range campaigns { rows = append(rows, []string{ @@ -69,7 +76,7 @@ var campaignsListCmd = &cobra.Command{ deref(c.EmailMessageID), c.Name, c.Status, - c.Subject, + formatCampaignScheduling(c.Scheduling), c.UpdatedAt, }) } @@ -135,7 +142,10 @@ var campaignsUpdateCmd = &cobra.Command{ return err } - c, err := runCampaignsUpdate(cfg, args[0], loops.UpdateCampaignRequest{Name: name}) + c, err := runCampaignsUpdate(cfg, args[0], loops.UpdateCampaignRequest{ + Name: name, + Set: map[string]bool{"name": true}, + }) if err != nil { return err } diff --git a/cmd/campaigns_list_test.go b/cmd/campaigns_list_test.go index 23cfd3d..6c35323 100644 --- a/cmd/campaigns_list_test.go +++ b/cmd/campaigns_list_test.go @@ -9,7 +9,7 @@ import ( 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"}]}`) + serveJSON(t, http.StatusOK, `{"pagination":{"nextCursor":""},"data":[{"campaignId":"cmp_1","emailMessageId":"em_1","name":"Spring","status":"Draft","createdAt":"2026-04-01","updatedAt":"2026-04-02","scheduling":{"method":"now","timestamp":null}}]}`) campaigns, err := runCampaignsList(cfg(t), loops.PaginationParams{}) if err != nil { t.Fatalf("unexpected error: %v", err) From 8ff06594107b87ee20c5ccbe5ecebd482a77a36e Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:28:33 -0700 Subject: [PATCH 2/9] sync to renamed loops-go id fields --- cmd/campaigns.go | 16 ++++++++-------- cmd/campaigns_create_test.go | 6 +++--- cmd/campaigns_get_test.go | 6 +++--- cmd/campaigns_list_test.go | 6 +++--- cmd/campaigns_update_test.go | 6 +++--- cmd/components.go | 4 ++-- cmd/components_get_test.go | 6 +++--- cmd/components_list_test.go | 6 +++--- cmd/email_messages.go | 4 ++-- cmd/email_messages_get_test.go | 6 +++--- cmd/email_messages_update_test.go | 8 ++++---- cmd/themes.go | 4 ++-- cmd/themes_get_test.go | 6 +++--- cmd/themes_list_test.go | 6 +++--- 14 files changed, 45 insertions(+), 45 deletions(-) diff --git a/cmd/campaigns.go b/cmd/campaigns.go index a8f6d32..6bbf0d5 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -19,13 +19,13 @@ func runCampaignsGet(cfg *config.Config, id string) (*loops.Campaign, error) { return newAPIClient(cfg).GetCampaign(id) } -func runCampaignsList(cfg *config.Config, params loops.PaginationParams) ([]loops.CampaignListItem, error) { +func runCampaignsList(cfg *config.Config, params loops.PaginationParams) ([]loops.Campaign, error) { client := newAPIClient(cfg) if params.Cursor != "" { campaigns, _, err := client.ListCampaigns(params) return campaigns, err } - return loops.Paginate(func(cursor string) ([]loops.CampaignListItem, *loops.Pagination, error) { + return loops.Paginate(func(cursor string) ([]loops.Campaign, *loops.Pagination, error) { return client.ListCampaigns(loops.PaginationParams{ PerPage: params.PerPage, Cursor: cursor, @@ -58,7 +58,7 @@ var campaignsListCmd = &cobra.Command{ if isJSONOutput() { if campaigns == nil { - campaigns = []loops.CampaignListItem{} + campaigns = []loops.Campaign{} } return printJSON(cmd.OutOrStdout(), campaigns) } @@ -72,7 +72,7 @@ var campaignsListCmd = &cobra.Command{ rows := make([][]string, 0, len(campaigns)) for _, c := range campaigns { rows = append(rows, []string{ - c.CampaignID, + c.ID, deref(c.EmailMessageID), c.Name, c.Status, @@ -121,7 +121,7 @@ var campaignsCreateCmd = &cobra.Command{ return printJSON(cmd.OutOrStdout(), resp) } - fmt.Fprintf(cmd.OutOrStdout(), "Created. (id: %s, emailMessageId: %s, contentRevisionId: %s)\n", resp.CampaignID, deref(resp.EmailMessageID), deref(resp.EmailMessageContentRevisionID)) + fmt.Fprintf(cmd.OutOrStdout(), "Created. (id: %s, emailMessageId: %s, contentRevisionId: %s)\n", resp.ID, deref(resp.EmailMessageID), deref(resp.EmailMessageContentRevisionID)) return nil }, } @@ -154,10 +154,10 @@ var campaignsUpdateCmd = &cobra.Command{ return printJSON(cmd.OutOrStdout(), c) } - fmt.Fprintf(cmd.OutOrStdout(), "Updated. (id: %s)\n\n", c.CampaignID) + fmt.Fprintf(cmd.OutOrStdout(), "Updated. (id: %s)\n\n", c.ID) t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") - t.Row("campaignId", c.CampaignID) + t.Row("campaignId", c.ID) t.Row("emailMessageId", deref(c.EmailMessageID)) t.Row("name", c.Name) t.Row("status", c.Status) @@ -187,7 +187,7 @@ var campaignsGetCmd = &cobra.Command{ } t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") - t.Row("campaignId", c.CampaignID) + t.Row("campaignId", c.ID) t.Row("emailMessageId", deref(c.EmailMessageID)) t.Row("name", c.Name) t.Row("status", c.Status) diff --git a/cmd/campaigns_create_test.go b/cmd/campaigns_create_test.go index bcb8927..f6afb06 100644 --- a/cmd/campaigns_create_test.go +++ b/cmd/campaigns_create_test.go @@ -10,7 +10,7 @@ import ( func TestRunCampaignsCreate(t *testing.T) { body := `{ "success": true, - "campaignId": "cmp_new", + "id": "cmp_new", "name": "Spring", "status": "Draft", "createdAt": "2026-04-20T10:00:00Z", @@ -25,8 +25,8 @@ func TestRunCampaignsCreate(t *testing.T) { 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.ID != "cmp_new" { + t.Errorf("ID = %q, want cmp_new", resp.ID) } if deref(resp.EmailMessageID) != "em_new" { t.Errorf("EmailMessageID = %q, want em_new", deref(resp.EmailMessageID)) diff --git a/cmd/campaigns_get_test.go b/cmd/campaigns_get_test.go index d9e7528..d845420 100644 --- a/cmd/campaigns_get_test.go +++ b/cmd/campaigns_get_test.go @@ -8,7 +8,7 @@ import ( func TestRunCampaignsGet(t *testing.T) { body := `{ "success": true, - "campaignId": "cmp_abc123", + "id": "cmp_abc123", "emailMessageId": "em_abc123", "name": "Spring Launch", "status": "Draft", @@ -22,8 +22,8 @@ func TestRunCampaignsGet(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if c.CampaignID != "cmp_abc123" { - t.Errorf("CampaignID = %q, want cmp_abc123", c.CampaignID) + if c.ID != "cmp_abc123" { + t.Errorf("ID = %q, want cmp_abc123", c.ID) } if deref(c.EmailMessageID) != "em_abc123" { t.Errorf("EmailMessageID = %q, want em_abc123", deref(c.EmailMessageID)) diff --git a/cmd/campaigns_list_test.go b/cmd/campaigns_list_test.go index 6c35323..df4fc65 100644 --- a/cmd/campaigns_list_test.go +++ b/cmd/campaigns_list_test.go @@ -9,7 +9,7 @@ import ( 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","status":"Draft","createdAt":"2026-04-01","updatedAt":"2026-04-02","scheduling":{"method":"now","timestamp":null}}]}`) + serveJSON(t, http.StatusOK, `{"pagination":{"nextCursor":""},"data":[{"id":"cmp_1","emailMessageId":"em_1","name":"Spring","status":"Draft","createdAt":"2026-04-01","updatedAt":"2026-04-02","scheduling":{"method":"now","timestamp":null}}]}`) campaigns, err := runCampaignsList(cfg(t), loops.PaginationParams{}) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -17,8 +17,8 @@ func TestRunCampaignsList(t *testing.T) { 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 campaigns[0].ID != "cmp_1" { + t.Errorf("ID = %q, want cmp_1", campaigns[0].ID) } if deref(campaigns[0].EmailMessageID) != "em_1" { t.Errorf("EmailMessageID = %q, want em_1", deref(campaigns[0].EmailMessageID)) diff --git a/cmd/campaigns_update_test.go b/cmd/campaigns_update_test.go index 308ec9e..98ea252 100644 --- a/cmd/campaigns_update_test.go +++ b/cmd/campaigns_update_test.go @@ -10,7 +10,7 @@ import ( func TestRunCampaignsUpdate(t *testing.T) { body := `{ "success": true, - "campaignId": "cmp_abc123", + "id": "cmp_abc123", "emailMessageId": "em_abc123", "name": "Renamed", "status": "Draft", @@ -24,8 +24,8 @@ func TestRunCampaignsUpdate(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if c.CampaignID != "cmp_abc123" { - t.Errorf("CampaignID = %q, want cmp_abc123", c.CampaignID) + if c.ID != "cmp_abc123" { + t.Errorf("ID = %q, want cmp_abc123", c.ID) } if c.Name != "Renamed" { t.Errorf("Name = %q, want Renamed", c.Name) diff --git a/cmd/components.go b/cmd/components.go index 5969d73..a537142 100644 --- a/cmd/components.go +++ b/cmd/components.go @@ -64,7 +64,7 @@ var componentsListCmd = &cobra.Command{ headers := []string{"ID", "NAME"} rows := make([][]string, 0, len(components)) for _, c := range components { - rows = append(rows, []string{c.ComponentID, c.Name}) + rows = append(rows, []string{c.ID, c.Name}) } if isPicking(cmd) { @@ -101,7 +101,7 @@ var componentsGetCmd = &cobra.Command{ } t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") - t.Row("componentId", c.ComponentID) + t.Row("componentId", c.ID) t.Row("name", c.Name) if err := t.Render(); err != nil { return err diff --git a/cmd/components_get_test.go b/cmd/components_get_test.go index e9271ea..743714e 100644 --- a/cmd/components_get_test.go +++ b/cmd/components_get_test.go @@ -8,7 +8,7 @@ import ( func TestRunComponentsGet(t *testing.T) { body := `{ "success": true, - "componentId": "cmpt_abc123", + "id": "cmpt_abc123", "name": "Header", "lmx": "

Hello

" }` @@ -19,8 +19,8 @@ func TestRunComponentsGet(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if c.ComponentID != "cmpt_abc123" { - t.Errorf("ComponentID = %q, want cmpt_abc123", c.ComponentID) + if c.ID != "cmpt_abc123" { + t.Errorf("ID = %q, want cmpt_abc123", c.ID) } if c.Name != "Header" { t.Errorf("Name = %q, want Header", c.Name) diff --git a/cmd/components_list_test.go b/cmd/components_list_test.go index a741b72..ddeb66c 100644 --- a/cmd/components_list_test.go +++ b/cmd/components_list_test.go @@ -9,7 +9,7 @@ import ( func TestRunComponentsList(t *testing.T) { t.Run("returns components", func(t *testing.T) { - serveJSON(t, http.StatusOK, `{"pagination":{"nextCursor":""},"data":[{"componentId":"cmpt_1","name":"Header","lmx":"

Hello

"}]}`) + serveJSON(t, http.StatusOK, `{"pagination":{"nextCursor":""},"data":[{"id":"cmpt_1","name":"Header","lmx":"

Hello

"}]}`) components, err := runComponentsList(cfg(t), loops.PaginationParams{}) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -17,8 +17,8 @@ func TestRunComponentsList(t *testing.T) { if len(components) != 1 { t.Fatalf("expected 1 component, got %d", len(components)) } - if components[0].ComponentID != "cmpt_1" { - t.Errorf("ComponentID = %q, want cmpt_1", components[0].ComponentID) + if components[0].ID != "cmpt_1" { + t.Errorf("ID = %q, want cmpt_1", components[0].ID) } if components[0].Name != "Header" { t.Errorf("Name = %q, want Header", components[0].Name) diff --git a/cmd/email_messages.go b/cmd/email_messages.go index 278b581..2d3159a 100644 --- a/cmd/email_messages.go +++ b/cmd/email_messages.go @@ -177,7 +177,7 @@ var emailMessagesUpdateCmd = &cobra.Command{ return printJSON(cmd.OutOrStdout(), msg) } - fmt.Fprintf(cmd.OutOrStdout(), "Updated. (emailMessageId: %s, contentRevisionId: %s)\n", msg.EmailMessageID, deref(msg.ContentRevisionID)) + fmt.Fprintf(cmd.OutOrStdout(), "Updated. (emailMessageId: %s, contentRevisionId: %s)\n", msg.ID, deref(msg.ContentRevisionID)) fmt.Fprintln(cmd.OutOrStdout()) if err := printEmailMessage(cmd, msg); err != nil { return err @@ -189,7 +189,7 @@ var emailMessagesUpdateCmd = &cobra.Command{ func printEmailMessage(cmd *cobra.Command, msg *loops.EmailMessage) error { t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") - t.Row("emailMessageId", msg.EmailMessageID) + t.Row("emailMessageId", msg.ID) t.Row("campaignId", deref(msg.CampaignID)) t.Row("subject", msg.Subject) t.Row("previewText", msg.PreviewText) diff --git a/cmd/email_messages_get_test.go b/cmd/email_messages_get_test.go index 286a3a1..45d2216 100644 --- a/cmd/email_messages_get_test.go +++ b/cmd/email_messages_get_test.go @@ -8,7 +8,7 @@ import ( func TestRunEmailMessagesGet(t *testing.T) { body := `{ "success": true, - "emailMessageId": "em_abc123", + "id": "em_abc123", "campaignId": "cmp_xyz789", "subject": "Hello", "previewText": "Preview", @@ -26,8 +26,8 @@ func TestRunEmailMessagesGet(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if msg.EmailMessageID != "em_abc123" { - t.Errorf("EmailMessageID = %q, want em_abc123", msg.EmailMessageID) + if msg.ID != "em_abc123" { + t.Errorf("ID = %q, want em_abc123", msg.ID) } if deref(msg.CampaignID) != "cmp_xyz789" { t.Errorf("CampaignID = %q, want cmp_xyz789", deref(msg.CampaignID)) diff --git a/cmd/email_messages_update_test.go b/cmd/email_messages_update_test.go index be0b8e7..4e3c414 100644 --- a/cmd/email_messages_update_test.go +++ b/cmd/email_messages_update_test.go @@ -14,7 +14,7 @@ import ( func TestRunEmailMessagesUpdate(t *testing.T) { body := `{ "success": true, - "emailMessageId": "em_abc123", + "id": "em_abc123", "campaignId": "cmp_xyz789", "subject": "Updated", "previewText": "", @@ -35,8 +35,8 @@ func TestRunEmailMessagesUpdate(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if msg.EmailMessageID != "em_abc123" { - t.Errorf("EmailMessageID = %q, want em_abc123", msg.EmailMessageID) + if msg.ID != "em_abc123" { + t.Errorf("ID = %q, want em_abc123", msg.ID) } if deref(msg.ContentRevisionID) != "rev_2" { t.Errorf("ContentRevisionID = %q, want rev_2", deref(msg.ContentRevisionID)) @@ -171,7 +171,7 @@ func TestFetchLatestRevisionID(t *testing.T) { t.Run("returns current contentRevisionId from GET", func(t *testing.T) { body := `{ "success": true, - "emailMessageId": "em_abc123", + "id": "em_abc123", "campaignId": "cmp_xyz789", "subject": "Hello", "previewText": "", diff --git a/cmd/themes.go b/cmd/themes.go index f21d031..8c6f71d 100644 --- a/cmd/themes.go +++ b/cmd/themes.go @@ -66,7 +66,7 @@ var themesListCmd = &cobra.Command{ rows := make([][]string, 0, len(themes)) for _, th := range themes { rows = append(rows, []string{ - th.ThemeID, + th.ID, th.Name, strconv.FormatBool(th.IsDefault), th.UpdatedAt, @@ -107,7 +107,7 @@ var themesGetCmd = &cobra.Command{ } t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") - t.Row("themeId", th.ThemeID) + t.Row("themeId", th.ID) t.Row("name", th.Name) t.Row("isDefault", strconv.FormatBool(th.IsDefault)) t.Row("createdAt", th.CreatedAt) diff --git a/cmd/themes_get_test.go b/cmd/themes_get_test.go index 3d6695f..7949f12 100644 --- a/cmd/themes_get_test.go +++ b/cmd/themes_get_test.go @@ -8,7 +8,7 @@ import ( func TestRunThemesGet(t *testing.T) { body := `{ "success": true, - "themeId": "thm_abc123", + "id": "thm_abc123", "name": "Default", "isDefault": true, "createdAt": "2026-04-01T10:00:00Z", @@ -27,8 +27,8 @@ func TestRunThemesGet(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if th.ThemeID != "thm_abc123" { - t.Errorf("ThemeID = %q, want thm_abc123", th.ThemeID) + if th.ID != "thm_abc123" { + t.Errorf("ID = %q, want thm_abc123", th.ID) } if th.Name != "Default" { t.Errorf("Name = %q, want Default", th.Name) diff --git a/cmd/themes_list_test.go b/cmd/themes_list_test.go index ff28a9e..034f243 100644 --- a/cmd/themes_list_test.go +++ b/cmd/themes_list_test.go @@ -9,7 +9,7 @@ import ( func TestRunThemesList(t *testing.T) { t.Run("returns themes", func(t *testing.T) { - serveJSON(t, http.StatusOK, `{"pagination":{"nextCursor":""},"data":[{"themeId":"thm_1","name":"Default","isDefault":true,"createdAt":"2026-04-01","updatedAt":"2026-04-02","styles":{}}]}`) + serveJSON(t, http.StatusOK, `{"pagination":{"nextCursor":""},"data":[{"id":"thm_1","name":"Default","isDefault":true,"createdAt":"2026-04-01","updatedAt":"2026-04-02","styles":{}}]}`) themes, err := runThemesList(cfg(t), loops.PaginationParams{}) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -17,8 +17,8 @@ func TestRunThemesList(t *testing.T) { if len(themes) != 1 { t.Fatalf("expected 1 theme, got %d", len(themes)) } - if themes[0].ThemeID != "thm_1" { - t.Errorf("ThemeID = %q, want thm_1", themes[0].ThemeID) + if themes[0].ID != "thm_1" { + t.Errorf("ID = %q, want thm_1", themes[0].ID) } if !themes[0].IsDefault { t.Error("IsDefault = false, want true") From 58873e5137e7b873841e19b7776dcc6a20d770fa Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:30:27 -0700 Subject: [PATCH 3/9] campaigns: targeting, scheduling, and selective-update flags --- cmd/campaigns.go | 168 +++++++++++++++++++++++++++++------ cmd/campaigns_create_test.go | 40 +++++++++ cmd/campaigns_fields_test.go | 154 ++++++++++++++++++++++++++++++++ cmd/campaigns_update_test.go | 41 +++++++++ cmd/helpers_test.go | 28 ++++++ 5 files changed, 404 insertions(+), 27 deletions(-) create mode 100644 cmd/campaigns_fields_test.go diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 6bbf0d5..1c4e243 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -1,7 +1,9 @@ package cmd import ( + "encoding/json" "fmt" + "os" "github.com/loops-so/cli/internal/config" "github.com/loops-so/loops-go" @@ -15,6 +17,91 @@ func formatCampaignScheduling(s loops.CampaignScheduling) string { return s.Method } +func formatAudienceFilter(f *loops.AudienceFilter) string { + if f == nil { + return "" + } + return fmt.Sprintf("match=%s (%d conditions)", f.Match, len(f.Conditions)) +} + +// campaignFieldParams holds the targeting/scheduling fields shared by +// `campaigns create` and `campaigns update`. Set records which fields the +// user explicitly provided (keyed by JSON field name) so partial updates can +// send only those fields. +// +// Clearing nullable fields (mailing-list-id, audience-segment-id, +// audience-filter) is not yet supported on update; setting values is. +type campaignFieldParams struct { + Name string + CampaignGroupID string + MailingListID *string + AudienceSegmentID *string + AudienceFilter *loops.AudienceFilter + Scheduling *loops.CampaignSchedulingRequest + Set map[string]bool +} + +func addCampaignFieldFlags(cmd *cobra.Command) { + cmd.Flags().StringP("name", "n", "", "Campaign name") + cmd.Flags().String("campaign-group-id", "", "Campaign group ID") + cmd.Flags().String("mailing-list-id", "", "Mailing list ID to target") + cmd.Flags().String("audience-segment-id", "", "Audience segment ID to target") + cmd.Flags().String("audience-filter-file", "", "Path to a JSON file with an ad-hoc audience filter") + cmd.Flags().Bool("schedule-now", false, "Send immediately when published") + cmd.Flags().String("schedule-at", "", "Send at the given RFC3339 timestamp (e.g. 2026-07-01T12:00:00Z)") + cmd.MarkFlagsMutuallyExclusive("audience-segment-id", "audience-filter-file") + cmd.MarkFlagsMutuallyExclusive("schedule-now", "schedule-at") +} + +func campaignFieldParamsFromCmd(cmd *cobra.Command) (campaignFieldParams, error) { + p := campaignFieldParams{Set: map[string]bool{}} + + if cmd.Flags().Changed("name") { + p.Name, _ = cmd.Flags().GetString("name") + p.Set["name"] = true + } + if cmd.Flags().Changed("campaign-group-id") { + p.CampaignGroupID, _ = cmd.Flags().GetString("campaign-group-id") + p.Set["campaignGroupId"] = true + } + if cmd.Flags().Changed("mailing-list-id") { + v, _ := cmd.Flags().GetString("mailing-list-id") + p.MailingListID = &v + p.Set["mailingListId"] = true + } + if cmd.Flags().Changed("audience-segment-id") { + v, _ := cmd.Flags().GetString("audience-segment-id") + p.AudienceSegmentID = &v + p.Set["audienceSegmentId"] = true + } + if cmd.Flags().Changed("audience-filter-file") { + path, _ := cmd.Flags().GetString("audience-filter-file") + data, err := os.ReadFile(path) + if err != nil { + return p, fmt.Errorf("read --audience-filter-file: %w", err) + } + var f loops.AudienceFilter + if err := json.Unmarshal(data, &f); err != nil { + return p, fmt.Errorf("parse --audience-filter-file: %w", err) + } + p.AudienceFilter = &f + p.Set["audienceFilter"] = true + } + if cmd.Flags().Changed("schedule-now") { + p.Scheduling = &loops.CampaignSchedulingRequest{Method: loops.CampaignSchedulingMethodNow} + p.Set["scheduling"] = true + } + if cmd.Flags().Changed("schedule-at") { + ts, _ := cmd.Flags().GetString("schedule-at") + p.Scheduling = &loops.CampaignSchedulingRequest{ + Method: loops.CampaignSchedulingMethodSchedule, + Timestamp: ts, + } + p.Set["scheduling"] = true + } + return p, nil +} + func runCampaignsGet(cfg *config.Config, id string) (*loops.Campaign, error) { return newAPIClient(cfg).GetCampaign(id) } @@ -105,14 +192,24 @@ 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 := campaignFieldParamsFromCmd(cmd) + if err != nil { + return err + } cfg, err := loadConfig() if err != nil { return err } - resp, err := runCampaignsCreate(cfg, loops.CreateCampaignRequest{Name: name}) + resp, err := runCampaignsCreate(cfg, loops.CreateCampaignRequest{ + Name: params.Name, + CampaignGroupID: params.CampaignGroupID, + MailingListID: params.MailingListID, + AudienceSegmentID: params.AudienceSegmentID, + AudienceFilter: params.AudienceFilter, + Scheduling: params.Scheduling, + }) if err != nil { return err } @@ -121,8 +218,8 @@ var campaignsCreateCmd = &cobra.Command{ return printJSON(cmd.OutOrStdout(), resp) } - fmt.Fprintf(cmd.OutOrStdout(), "Created. (id: %s, emailMessageId: %s, contentRevisionId: %s)\n", resp.ID, deref(resp.EmailMessageID), deref(resp.EmailMessageContentRevisionID)) - return nil + fmt.Fprintf(cmd.OutOrStdout(), "Created. (id: %s, emailMessageId: %s, contentRevisionId: %s)\n\n", resp.ID, deref(resp.EmailMessageID), deref(resp.EmailMessageContentRevisionID)) + return printCampaign(cmd, &resp.Campaign) }, } @@ -135,7 +232,10 @@ var campaignsUpdateCmd = &cobra.Command{ Short: "Update a draft campaign", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - name, _ := cmd.Flags().GetString("name") + params, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + return err + } cfg, err := loadConfig() if err != nil { @@ -143,8 +243,13 @@ var campaignsUpdateCmd = &cobra.Command{ } c, err := runCampaignsUpdate(cfg, args[0], loops.UpdateCampaignRequest{ - Name: name, - Set: map[string]bool{"name": true}, + Name: params.Name, + CampaignGroupID: params.CampaignGroupID, + MailingListID: params.MailingListID, + AudienceSegmentID: params.AudienceSegmentID, + AudienceFilter: params.AudienceFilter, + Scheduling: params.Scheduling, + Set: params.Set, }) if err != nil { return err @@ -155,15 +260,7 @@ var campaignsUpdateCmd = &cobra.Command{ } fmt.Fprintf(cmd.OutOrStdout(), "Updated. (id: %s)\n\n", c.ID) - - t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") - t.Row("campaignId", c.ID) - 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() + return printCampaign(cmd, c) }, } @@ -186,29 +283,46 @@ var campaignsGetCmd = &cobra.Command{ return printJSON(cmd.OutOrStdout(), c) } - t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") - t.Row("campaignId", c.ID) - 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() + return printCampaign(cmd, c) }, } +func printCampaign(cmd *cobra.Command, c *loops.Campaign) error { + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("campaignId", c.ID) + t.Row("emailMessageId", deref(c.EmailMessageID)) + t.Row("name", c.Name) + t.Row("status", c.Status) + t.Row("campaignGroupId", deref(c.CampaignGroupID)) + t.Row("mailingListId", deref(c.MailingListID)) + t.Row("audienceSegmentId", deref(c.AudienceSegmentID)) + t.Row("audienceFilter", formatAudienceFilter(c.AudienceFilter)) + t.Row("scheduling", formatCampaignScheduling(c.Scheduling)) + t.Row("createdAt", c.CreatedAt) + t.Row("updatedAt", c.UpdatedAt) + return t.Render() +} + func init() { addPaginationFlags(campaignsListCmd) addPickFlag(campaignsListCmd) campaignsCmd.AddCommand(campaignsListCmd) campaignsCmd.AddCommand(campaignsGetCmd) - campaignsCreateCmd.Flags().StringP("name", "n", "", "Campaign name (required)") + addCampaignFieldFlags(campaignsCreateCmd) campaignsCreateCmd.MarkFlagRequired("name") campaignsCmd.AddCommand(campaignsCreateCmd) - campaignsUpdateCmd.Flags().StringP("name", "n", "", "Campaign name (required)") - campaignsUpdateCmd.MarkFlagRequired("name") + addCampaignFieldFlags(campaignsUpdateCmd) + campaignsUpdateCmd.MarkFlagsOneRequired( + "name", + "campaign-group-id", + "mailing-list-id", + "audience-segment-id", + "audience-filter-file", + "schedule-now", + "schedule-at", + ) campaignsCmd.AddCommand(campaignsUpdateCmd) rootCmd.AddCommand(campaignsCmd) diff --git a/cmd/campaigns_create_test.go b/cmd/campaigns_create_test.go index f6afb06..a2efba7 100644 --- a/cmd/campaigns_create_test.go +++ b/cmd/campaigns_create_test.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "net/http" "testing" @@ -43,4 +44,43 @@ func TestRunCampaignsCreate(t *testing.T) { t.Fatal("expected error, got nil") } }) + + t.Run("sends targeting and scheduling fields", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusCreated, body) + mailingList := "ml_1" + ts := "2026-07-01T12:00:00Z" + _, err := runCampaignsCreate(cfg(t), loops.CreateCampaignRequest{ + Name: "Spring", + CampaignGroupID: "cg_1", + MailingListID: &mailingList, + Scheduling: &loops.CampaignSchedulingRequest{ + Method: loops.CampaignSchedulingMethodSchedule, + Timestamp: ts, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + if sent["name"] != "Spring" { + t.Errorf("name = %v, want Spring", sent["name"]) + } + if sent["campaignGroupId"] != "cg_1" { + t.Errorf("campaignGroupId = %v, want cg_1", sent["campaignGroupId"]) + } + if sent["mailingListId"] != "ml_1" { + t.Errorf("mailingListId = %v, want ml_1", sent["mailingListId"]) + } + sched, ok := sent["scheduling"].(map[string]any) + if !ok { + t.Fatalf("scheduling not an object: %v", sent["scheduling"]) + } + if sched["method"] != "schedule" || sched["timestamp"] != ts { + t.Errorf("scheduling = %v", sched) + } + }) } diff --git a/cmd/campaigns_fields_test.go b/cmd/campaigns_fields_test.go new file mode 100644 index 0000000..bdb2e12 --- /dev/null +++ b/cmd/campaigns_fields_test.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/loops-so/loops-go" + "github.com/spf13/cobra" +) + +// newCampaignFieldCmd returns a bare cobra.Command wired up with the campaign +// field flags so tests can exercise campaignFieldParamsFromCmd in isolation. +func newCampaignFieldCmd() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + addCampaignFieldFlags(cmd) + return cmd +} + +func TestCampaignFieldParamsFromCmd(t *testing.T) { + t.Run("no flags returns empty Set", func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + p, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(p.Set) != 0 { + t.Errorf("Set = %v, want empty", p.Set) + } + }) + + t.Run("name flag sets only name key", func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--name", "Spring"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + p, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.Name != "Spring" { + t.Errorf("Name = %q, want Spring", p.Name) + } + if !p.Set["name"] || len(p.Set) != 1 { + t.Errorf("Set = %v, want {name:true}", p.Set) + } + }) + + t.Run("mailing-list-id is set as pointer", func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--mailing-list-id", "ml_1"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + p, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.MailingListID == nil || *p.MailingListID != "ml_1" { + t.Errorf("MailingListID = %v, want pointer to ml_1", p.MailingListID) + } + if !p.Set["mailingListId"] { + t.Errorf("Set[mailingListId] not true: %v", p.Set) + } + }) + + t.Run("schedule-now produces method now", func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--schedule-now"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + p, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.Scheduling == nil || p.Scheduling.Method != loops.CampaignSchedulingMethodNow { + t.Errorf("Scheduling = %+v, want {Method: now}", p.Scheduling) + } + if !p.Set["scheduling"] { + t.Errorf("Set[scheduling] not true: %v", p.Set) + } + }) + + t.Run("schedule-at produces method schedule with timestamp", func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--schedule-at", "2026-07-01T12:00:00Z"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + p, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.Scheduling == nil { + t.Fatal("Scheduling nil") + } + if p.Scheduling.Method != loops.CampaignSchedulingMethodSchedule { + t.Errorf("Method = %q, want schedule", p.Scheduling.Method) + } + if p.Scheduling.Timestamp != "2026-07-01T12:00:00Z" { + t.Errorf("Timestamp = %q", p.Scheduling.Timestamp) + } + }) + + t.Run("audience-filter-file parses JSON", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "filter.json") + filter := `{ + "match": "all", + "conditions": [ + {"type": "property", "key": "plan", "operator": "equals", "value": "pro"}, + {"type": "optIn", "status": "accepted"} + ] + }` + if err := os.WriteFile(path, []byte(filter), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--audience-filter-file", path}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + p, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.AudienceFilter == nil { + t.Fatal("AudienceFilter nil") + } + if p.AudienceFilter.Match != "all" { + t.Errorf("Match = %q, want all", p.AudienceFilter.Match) + } + if len(p.AudienceFilter.Conditions) != 2 { + t.Fatalf("Conditions len = %d, want 2", len(p.AudienceFilter.Conditions)) + } + if p.AudienceFilter.Conditions[0].Type != loops.AudienceConditionTypeProperty { + t.Errorf("Conditions[0].Type = %q", p.AudienceFilter.Conditions[0].Type) + } + if p.AudienceFilter.Conditions[1].Type != loops.AudienceConditionTypeOptIn { + t.Errorf("Conditions[1].Type = %q", p.AudienceFilter.Conditions[1].Type) + } + }) + + t.Run("audience-filter-file missing returns error", func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--audience-filter-file", "/no/such/file.json"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + if _, err := campaignFieldParamsFromCmd(cmd); err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/cmd/campaigns_update_test.go b/cmd/campaigns_update_test.go index 98ea252..0bf20bf 100644 --- a/cmd/campaigns_update_test.go +++ b/cmd/campaigns_update_test.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "net/http" "testing" @@ -42,4 +43,44 @@ func TestRunCampaignsUpdate(t *testing.T) { t.Fatal("expected error, got nil") } }) + + t.Run("Set map controls which fields are sent", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, body) + ts := "2026-07-01T12:00:00Z" + _, err := runCampaignsUpdate(cfg(t), "cmp_abc123", loops.UpdateCampaignRequest{ + Name: "Renamed", + Scheduling: &loops.CampaignSchedulingRequest{ + Method: loops.CampaignSchedulingMethodSchedule, + Timestamp: ts, + }, + Set: map[string]bool{ + "name": true, + "scheduling": true, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + if _, ok := sent["campaignGroupId"]; ok { + t.Errorf("unset campaignGroupId leaked into request: %v", sent) + } + if _, ok := sent["mailingListId"]; ok { + t.Errorf("unset mailingListId leaked into request: %v", sent) + } + if sent["name"] != "Renamed" { + t.Errorf("name = %v, want Renamed", sent["name"]) + } + sched, ok := sent["scheduling"].(map[string]any) + if !ok { + t.Fatalf("scheduling not an object: %v", sent["scheduling"]) + } + if sched["method"] != "schedule" || sched["timestamp"] != ts { + t.Errorf("scheduling = %v", sched) + } + }) } diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 391cc9b..3635096 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -1,6 +1,7 @@ package cmd import ( + "io" "net/http" "net/http/httptest" "testing" @@ -36,3 +37,30 @@ func serveJSON(t *testing.T, status int, body string) { t.Setenv("LOOPS_API_KEY", "test-key") t.Setenv("LOOPS_ENDPOINT_URL", srv.URL) } + +// capturedRequest records the most recent HTTP request made by the SDK during +// a test. Tests that need to assert on the request body, method, or path use +// serveJSONCapture and inspect the returned pointer after the call. +type capturedRequest struct { + Method string + Path string + Body []byte +} + +func serveJSONCapture(t *testing.T, status int, body string) *capturedRequest { + t.Helper() + keyring.MockInit() + t.Setenv("LOOPS_CONFIG_DIR", t.TempDir()) + cap := &capturedRequest{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cap.Method = r.Method + cap.Path = r.URL.RequestURI() + cap.Body, _ = io.ReadAll(r.Body) + w.WriteHeader(status) + w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + t.Setenv("LOOPS_API_KEY", "test-key") + t.Setenv("LOOPS_ENDPOINT_URL", srv.URL) + return cap +} From 38ea358599f6b62661ea4b7f96d68b4d76b0194d Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:55:12 -0700 Subject: [PATCH 4/9] email-messages: cc/bcc/language/format/fallbacks + preview subcommand --- cmd/email_messages.go | 245 ++++++++++++++++++++++++++++-- cmd/email_messages_update_test.go | 133 ++++++++++++++++ 2 files changed, 363 insertions(+), 15 deletions(-) diff --git a/cmd/email_messages.go b/cmd/email_messages.go index 2d3159a..6a4694a 100644 --- a/cmd/email_messages.go +++ b/cmd/email_messages.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + "sort" "strings" "github.com/alecthomas/chroma/v2" @@ -21,18 +22,28 @@ func fromEmailUsername(s string) string { return before } -// emailMessageFieldParams holds the six content fields shared by +// emailMessageFieldParams holds the editable 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. +// +// The *Fallbacks maps replace the server-side maps wholesale when the +// corresponding Set key is true — they do not merge. type emailMessageFieldParams struct { - Subject string - PreviewText string - FromName string - FromEmail string - ReplyToEmail string - LMX string - Set map[string]bool + Subject string + PreviewText string + FromName string + FromEmail string + ReplyToEmail string + CCEmail string + BCCEmail string + LanguageCode string + EmailFormat string + LMX string + ContactPropertiesFallbacks map[string]*string + EventPropertiesFallbacks map[string]*string + DataVariablesFallbacks map[string]*string + Set map[string]bool } func addEmailMessageFieldFlags(cmd *cobra.Command) { @@ -41,8 +52,15 @@ func addEmailMessageFieldFlags(cmd *cobra.Command) { 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("cc", "", "CC email address") + cmd.Flags().String("bcc", "", "BCC email address") + cmd.Flags().String("language-code", "", "Language/locale code (e.g. en-US)") + cmd.Flags().String("email-format", "", `"styled" or "plain"`) cmd.Flags().String("lmx", "", "LMX markup (inline)") cmd.Flags().String("lmx-file", "", "Path to a file containing LMX markup") + cmd.Flags().StringArray("contact-fallback", nil, "Contact property fallback as KEY=value (repeatable). Replaces all server-side contact fallbacks.") + cmd.Flags().StringArray("event-fallback", nil, "Event property fallback as KEY=value (repeatable). Replaces all server-side event fallbacks.") + cmd.Flags().StringArray("data-fallback", nil, "Transactional data-variable fallback as KEY=value (repeatable). Replaces all server-side data fallbacks.") cmd.MarkFlagsMutuallyExclusive("lmx", "lmx-file") } @@ -70,6 +88,26 @@ func emailMessageFieldParamsFromCmd(cmd *cobra.Command) (emailMessageFieldParams p.ReplyToEmail, _ = cmd.Flags().GetString("reply-to") p.Set["replyToEmail"] = true } + if cmd.Flags().Changed("cc") { + p.CCEmail, _ = cmd.Flags().GetString("cc") + p.Set["ccEmail"] = true + } + if cmd.Flags().Changed("bcc") { + p.BCCEmail, _ = cmd.Flags().GetString("bcc") + p.Set["bccEmail"] = true + } + if cmd.Flags().Changed("language-code") { + p.LanguageCode, _ = cmd.Flags().GetString("language-code") + p.Set["languageCode"] = true + } + if cmd.Flags().Changed("email-format") { + v, _ := cmd.Flags().GetString("email-format") + if v != loops.EmailFormatStyled && v != loops.EmailFormatPlain { + return p, fmt.Errorf("--email-format must be %q or %q", loops.EmailFormatStyled, loops.EmailFormatPlain) + } + p.EmailFormat = v + p.Set["emailFormat"] = true + } if cmd.Flags().Changed("lmx") { p.LMX, _ = cmd.Flags().GetString("lmx") p.Set["lmx"] = true @@ -83,9 +121,70 @@ func emailMessageFieldParamsFromCmd(cmd *cobra.Command) (emailMessageFieldParams p.LMX = string(data) p.Set["lmx"] = true } + if cmd.Flags().Changed("contact-fallback") { + pairs, _ := cmd.Flags().GetStringArray("contact-fallback") + m, err := parseFallbacks("contact-fallback", pairs) + if err != nil { + return p, err + } + p.ContactPropertiesFallbacks = m + p.Set["contactPropertiesFallbacks"] = true + } + if cmd.Flags().Changed("event-fallback") { + pairs, _ := cmd.Flags().GetStringArray("event-fallback") + m, err := parseFallbacks("event-fallback", pairs) + if err != nil { + return p, err + } + p.EventPropertiesFallbacks = m + p.Set["eventPropertiesFallbacks"] = true + } + if cmd.Flags().Changed("data-fallback") { + pairs, _ := cmd.Flags().GetStringArray("data-fallback") + m, err := parseFallbacks("data-fallback", pairs) + if err != nil { + return p, err + } + p.DataVariablesFallbacks = m + p.Set["dataVariablesFallbacks"] = true + } return p, nil } +// parseFallbacks parses repeated KEY=value pairs into a map[string]*string +// suitable for the SDK's *Fallbacks fields. Values are taken literally; there +// is no support for setting a key to a nil pointer (server-side clear) via the +// CLI in this round. +func parseFallbacks(flag string, pairs []string) (map[string]*string, error) { + m := make(map[string]*string, len(pairs)) + for _, pair := range pairs { + idx := strings.IndexByte(pair, '=') + if idx < 0 { + return nil, fmt.Errorf("--%s %q: expected KEY=value", flag, pair) + } + key := pair[:idx] + val := pair[idx+1:] + m[key] = &val + } + return m, nil +} + +// parseStringPropertyMap parses repeated KEY=value pairs into a map[string]string. +func parseStringPropertyMap(flag string, pairs []string) (map[string]string, error) { + if len(pairs) == 0 { + return nil, nil + } + m := make(map[string]string, len(pairs)) + for _, pair := range pairs { + idx := strings.IndexByte(pair, '=') + if idx < 0 { + return nil, fmt.Errorf("--%s %q: expected KEY=value", flag, pair) + } + m[pair[:idx]] = pair[idx+1:] + } + return m, nil +} + func runEmailMessagesGet(cfg *config.Config, id string) (*loops.EmailMessage, error) { return newAPIClient(cfg).GetEmailMessage(id) } @@ -157,12 +256,19 @@ var emailMessagesUpdateCmd = &cobra.Command{ req := loops.UpdateEmailMessageRequest{ EmailMessageFields: loops.EmailMessageFields{ - Subject: params.Subject, - PreviewText: params.PreviewText, - FromName: params.FromName, - FromEmail: params.FromEmail, - ReplyToEmail: params.ReplyToEmail, - LMX: params.LMX, + Subject: params.Subject, + PreviewText: params.PreviewText, + FromName: params.FromName, + FromEmail: params.FromEmail, + ReplyToEmail: params.ReplyToEmail, + CCEmail: params.CCEmail, + BCCEmail: params.BCCEmail, + LanguageCode: params.LanguageCode, + EmailFormat: params.EmailFormat, + LMX: params.LMX, + ContactPropertiesFallbacks: params.ContactPropertiesFallbacks, + EventPropertiesFallbacks: params.EventPropertiesFallbacks, + DataVariablesFallbacks: params.DataVariablesFallbacks, }, Set: params.Set, ExpectedRevisionID: revisionID, @@ -191,11 +297,33 @@ func printEmailMessage(cmd *cobra.Command, msg *loops.EmailMessage) error { t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") t.Row("emailMessageId", msg.ID) t.Row("campaignId", deref(msg.CampaignID)) + t.Row("transactionalId", deref(msg.TransactionalID)) 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) + if msg.CCEmail != "" { + t.Row("ccEmail", msg.CCEmail) + } + if msg.BCCEmail != "" { + t.Row("bccEmail", msg.BCCEmail) + } + if msg.LanguageCode != "" { + t.Row("languageCode", msg.LanguageCode) + } + if msg.EmailFormat != "" { + t.Row("emailFormat", msg.EmailFormat) + } + if s := formatStringMap(msg.ContactPropertiesFallbacks); s != "" { + t.Row("contactPropertiesFallbacks", s) + } + if s := formatStringMap(msg.EventPropertiesFallbacks); s != "" { + t.Row("eventPropertiesFallbacks", s) + } + if s := formatStringMap(msg.DataVariablesFallbacks); s != "" { + t.Row("dataVariablesFallbacks", s) + } t.Row("contentRevisionId", deref(msg.ContentRevisionID)) t.Row("updatedAt", msg.UpdatedAt) if err := t.Render(); err != nil { @@ -206,6 +334,24 @@ func printEmailMessage(cmd *cobra.Command, msg *loops.EmailMessage) error { return renderLMX(cmd.OutOrStdout(), msg.LMX) } +// formatStringMap renders a map as "k1=v1, k2=v2" with deterministic key order. +// Returns an empty string for nil or empty maps so callers can skip the row. +func formatStringMap(m map[string]string) string { + if len(m) == 0 { + return "" + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, len(keys)) + for i, k := range keys { + parts[i] = k + "=" + m[k] + } + return strings.Join(parts, ", ") +} + // renderLMX prints the LMX body with chroma syntax highlighting via the xml // lexer (LMX is JSX/XML-tag-shaped). The chroma style maps two token kinds to // fang.ColorScheme colors so highlighting reuses the same palette as the rest @@ -240,6 +386,62 @@ func lmxChromaStyle() *chroma.Style { }) } +func runEmailMessagesPreview(cfg *config.Config, id string, req loops.EmailMessagePreviewRequest) (*loops.EmailMessagePreviewResponse, error) { + return newAPIClient(cfg).PreviewEmailMessage(id, req) +} + +var emailMessagesPreviewCmd = &cobra.Command{ + Use: "preview ", + Short: "Send a preview of an email message to one or more addresses", + Long: "Sends a test preview of the email message to the addresses in --email.\n" + + "Variable fields accepted depend on the parent type:\n" + + " - campaign previews accept --contact-prop\n" + + " - workflow previews accept --contact-prop and --event-prop\n" + + " - transactional previews accept --var / --json-vars", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + emails, _ := cmd.Flags().GetStringArray("email") + contactPairs, _ := cmd.Flags().GetStringArray("contact-prop") + eventPairs, _ := cmd.Flags().GetStringArray("event-prop") + varPairs, _ := cmd.Flags().GetStringArray("var") + jsonFile, _ := cmd.Flags().GetString("json-vars") + + contactProps, err := parseStringPropertyMap("contact-prop", contactPairs) + if err != nil { + return err + } + eventProps, err := parseStringPropertyMap("event-prop", eventPairs) + if err != nil { + return err + } + dataVars, err := parseDataVars(varPairs, jsonFile) + if err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + resp, err := runEmailMessagesPreview(cfg, args[0], loops.EmailMessagePreviewRequest{ + Emails: emails, + ContactProperties: contactProps, + EventProperties: eventProps, + DataVariables: dataVars, + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), resp) + } + fmt.Fprintf(cmd.OutOrStdout(), "Preview sent. (id: %s)\n", resp.ID) + return nil + }, +} + func printLmxWarnings(cmd *cobra.Command, warnings []loops.LmxWarning) { if len(warnings) == 0 { return @@ -263,8 +465,21 @@ func init() { emailMessagesUpdateCmd.Flags().BoolP("force", "f", false, "Fetch the current revision and use it (overwrites any concurrent edits). Mutually exclusive with --expected-revision-id.") emailMessagesUpdateCmd.MarkFlagsMutuallyExclusive("expected-revision-id", "force") emailMessagesUpdateCmd.MarkFlagsOneRequired("expected-revision-id", "force") - emailMessagesUpdateCmd.MarkFlagsOneRequired("subject", "preview-text", "from-name", "from-email", "reply-to", "lmx", "lmx-file") + emailMessagesUpdateCmd.MarkFlagsOneRequired( + "subject", "preview-text", "from-name", "from-email", "reply-to", + "cc", "bcc", "language-code", "email-format", + "lmx", "lmx-file", + "contact-fallback", "event-fallback", "data-fallback", + ) emailMessagesCmd.AddCommand(emailMessagesUpdateCmd) + emailMessagesPreviewCmd.Flags().StringArray("email", nil, "Recipient email address (repeatable; at least one required)") + emailMessagesPreviewCmd.MarkFlagRequired("email") + emailMessagesPreviewCmd.Flags().StringArray("contact-prop", nil, "Contact property as KEY=value (repeatable)") + emailMessagesPreviewCmd.Flags().StringArray("event-prop", nil, "Event property as KEY=value (repeatable)") + emailMessagesPreviewCmd.Flags().StringArrayP("var", "v", nil, "Data variable as KEY=value (repeatable; transactional previews only)") + emailMessagesPreviewCmd.Flags().StringP("json-vars", "j", "", "Path to a JSON file of data variables") + emailMessagesCmd.AddCommand(emailMessagesPreviewCmd) + rootCmd.AddCommand(emailMessagesCmd) } diff --git a/cmd/email_messages_update_test.go b/cmd/email_messages_update_test.go index 4e3c414..8cb220a 100644 --- a/cmd/email_messages_update_test.go +++ b/cmd/email_messages_update_test.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "io" "net/http" "os" @@ -165,6 +166,138 @@ func TestEmailMessageFieldParamsFromCmd(t *testing.T) { t.Fatal("expected error for missing file, got nil") } }) + + t.Run("cc/bcc/language-code populate fields", func(t *testing.T) { + cmd := &cobra.Command{} + addEmailMessageFieldFlags(cmd) + cmd.ParseFlags([]string{ + "--cc", "cc@acme.com", + "--bcc", "bcc@acme.com", + "--language-code", "en-US", + }) + params, err := emailMessageFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if params.CCEmail != "cc@acme.com" { + t.Errorf("CCEmail = %q", params.CCEmail) + } + if params.BCCEmail != "bcc@acme.com" { + t.Errorf("BCCEmail = %q", params.BCCEmail) + } + if params.LanguageCode != "en-US" { + t.Errorf("LanguageCode = %q", params.LanguageCode) + } + for _, k := range []string{"ccEmail", "bccEmail", "languageCode"} { + if !params.Set[k] { + t.Errorf(`Set[%q] = false, want true`, k) + } + } + }) + + t.Run("email-format styled accepted", func(t *testing.T) { + cmd := &cobra.Command{} + addEmailMessageFieldFlags(cmd) + cmd.ParseFlags([]string{"--email-format", "styled"}) + params, err := emailMessageFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if params.EmailFormat != loops.EmailFormatStyled { + t.Errorf("EmailFormat = %q", params.EmailFormat) + } + }) + + t.Run("email-format other value rejected", func(t *testing.T) { + cmd := &cobra.Command{} + addEmailMessageFieldFlags(cmd) + cmd.ParseFlags([]string{"--email-format", "fancy"}) + if _, err := emailMessageFieldParamsFromCmd(cmd); err == nil { + t.Fatal("expected error for invalid --email-format, got nil") + } + }) + + t.Run("contact-fallback parses pairs into pointer map", func(t *testing.T) { + cmd := &cobra.Command{} + addEmailMessageFieldFlags(cmd) + cmd.ParseFlags([]string{ + "--contact-fallback", "firstName=Friend", + "--contact-fallback", "lastName=There", + }) + params, err := emailMessageFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(params.ContactPropertiesFallbacks) != 2 { + t.Fatalf("len = %d, want 2", len(params.ContactPropertiesFallbacks)) + } + if v := params.ContactPropertiesFallbacks["firstName"]; v == nil || *v != "Friend" { + t.Errorf("firstName = %v, want pointer to Friend", v) + } + if !params.Set["contactPropertiesFallbacks"] { + t.Error("expected contactPropertiesFallbacks in Set") + } + }) + + t.Run("malformed fallback pair returns error", func(t *testing.T) { + cmd := &cobra.Command{} + addEmailMessageFieldFlags(cmd) + cmd.ParseFlags([]string{"--data-fallback", "noequals"}) + if _, err := emailMessageFieldParamsFromCmd(cmd); err == nil { + t.Fatal("expected error for malformed pair, got nil") + } + }) +} + +func TestRunEmailMessagesPreview(t *testing.T) { + t.Run("sends emails and variables", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, `{"id":"em_abc123"}`) + resp, err := runEmailMessagesPreview(cfg(t), "em_abc123", loops.EmailMessagePreviewRequest{ + Emails: []string{"a@b.com", "c@d.com"}, + ContactProperties: map[string]string{"firstName": "Pat"}, + EventProperties: map[string]string{"signupSource": "homepage"}, + DataVariables: map[string]any{"orderId": "123"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.ID != "em_abc123" { + t.Errorf("ID = %q, want em_abc123", resp.ID) + } + if got.Path != "/email-messages/em_abc123/preview" { + t.Errorf("Path = %q", got.Path) + } + if got.Method != http.MethodPost { + t.Errorf("Method = %q", got.Method) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + emails, _ := sent["emails"].([]any) + if len(emails) != 2 { + t.Errorf("emails len = %d, want 2", len(emails)) + } + cp, _ := sent["contactProperties"].(map[string]any) + if cp["firstName"] != "Pat" { + t.Errorf("contactProperties.firstName = %v", cp["firstName"]) + } + dv, _ := sent["dataVariables"].(map[string]any) + if dv["orderId"] != "123" { + t.Errorf("dataVariables.orderId = %v", dv["orderId"]) + } + }) + + t.Run("returns error on non-200 response", func(t *testing.T) { + serveJSON(t, http.StatusBadRequest, `{"success":false,"message":"bad"}`) + _, err := runEmailMessagesPreview(cfg(t), "em_abc123", loops.EmailMessagePreviewRequest{ + Emails: []string{"a@b.com"}, + }) + if err == nil { + t.Fatal("expected error, got nil") + } + }) } func TestFetchLatestRevisionID(t *testing.T) { From 279f6a658e80643dcd778a527cd3de26d15731e5 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:58:59 -0700 Subject: [PATCH 5/9] transactional: add --transactional-group-id to create/update --- cmd/transactional.go | 15 ++++++++++-- cmd/transactional_create_test.go | 40 ++++++++++++++++++++++++++++++++ cmd/transactional_update_test.go | 24 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/cmd/transactional.go b/cmd/transactional.go index 2c2f364..859c5ad 100644 --- a/cmd/transactional.go +++ b/cmd/transactional.go @@ -183,13 +183,17 @@ var transactionalCreateCmd = &cobra.Command{ Short: "Create a transactional email with an empty draft", RunE: func(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") + groupID, _ := cmd.Flags().GetString("transactional-group-id") cfg, err := loadConfig() if err != nil { return err } - tx, err := runTransactionalCreate(cfg, loops.CreateTransactionalRequest{Name: name}) + tx, err := runTransactionalCreate(cfg, loops.CreateTransactionalRequest{ + Name: name, + TransactionalGroupID: groupID, + }) if err != nil { return err } @@ -210,13 +214,17 @@ var transactionalUpdateCmd = &cobra.Command{ Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") + groupID, _ := cmd.Flags().GetString("transactional-group-id") cfg, err := loadConfig() if err != nil { return err } - tx, err := runTransactionalUpdate(cfg, args[0], loops.UpdateTransactionalRequest{Name: name}) + tx, err := runTransactionalUpdate(cfg, args[0], loops.UpdateTransactionalRequest{ + Name: name, + TransactionalGroupID: groupID, + }) if err != nil { return err } @@ -286,6 +294,7 @@ func printTransactional(cmd *cobra.Command, tx *loops.Transactional) error { t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") t.Row("transactionalId", tx.ID) t.Row("name", tx.Name) + t.Row("transactionalGroupId", deref(tx.TransactionalGroupID)) t.Row("draftEmailMessageId", deref(tx.DraftEmailMessageID)) t.Row("publishedEmailMessageId", deref(tx.PublishedEmailMessageID)) t.Row("dataVariables", strings.Join(tx.DataVariables, ", ")) @@ -370,10 +379,12 @@ func init() { transactionalCmd.AddCommand(transactionalGetCmd) transactionalCreateCmd.Flags().StringP("name", "n", "", "Transactional email name (required)") + transactionalCreateCmd.Flags().String("transactional-group-id", "", "Transactional group ID to assign this email to") transactionalCreateCmd.MarkFlagRequired("name") transactionalCmd.AddCommand(transactionalCreateCmd) transactionalUpdateCmd.Flags().StringP("name", "n", "", "Transactional email name (required)") + transactionalUpdateCmd.Flags().String("transactional-group-id", "", "Move the email to this transactional group") transactionalUpdateCmd.MarkFlagRequired("name") transactionalCmd.AddCommand(transactionalUpdateCmd) diff --git a/cmd/transactional_create_test.go b/cmd/transactional_create_test.go index e875d13..59abfdd 100644 --- a/cmd/transactional_create_test.go +++ b/cmd/transactional_create_test.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "net/http" "testing" @@ -11,6 +12,7 @@ func TestRunTransactionalCreate(t *testing.T) { body := `{ "id": "tx_new", "name": "Welcome", + "transactionalGroupId": "tg_1", "draftEmailMessageId": "em_draft", "publishedEmailMessageId": null, "createdAt": "2026-01-01T00:00:00Z", @@ -28,6 +30,9 @@ func TestRunTransactionalCreate(t *testing.T) { if tx.ID != "tx_new" { t.Errorf("ID = %q, want tx_new", tx.ID) } + if deref(tx.TransactionalGroupID) != "tg_1" { + t.Errorf("TransactionalGroupID = %q, want tg_1", deref(tx.TransactionalGroupID)) + } if deref(tx.DraftEmailMessageID) != "em_draft" { t.Errorf("DraftEmailMessageID = %q, want em_draft", deref(tx.DraftEmailMessageID)) } @@ -43,4 +48,39 @@ func TestRunTransactionalCreate(t *testing.T) { t.Fatal("expected error, got nil") } }) + + t.Run("sends transactionalGroupId when provided", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusCreated, body) + _, err := runTransactionalCreate(cfg(t), loops.CreateTransactionalRequest{ + Name: "Welcome", + TransactionalGroupID: "tg_1", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["transactionalGroupId"] != "tg_1" { + t.Errorf("transactionalGroupId = %v, want tg_1", sent["transactionalGroupId"]) + } + }) + + t.Run("omits transactionalGroupId when empty", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusCreated, body) + _, err := runTransactionalCreate(cfg(t), loops.CreateTransactionalRequest{Name: "Welcome"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if _, ok := sent["transactionalGroupId"]; ok { + t.Errorf("transactionalGroupId should be omitted when empty, got %v", sent) + } + }) } diff --git a/cmd/transactional_update_test.go b/cmd/transactional_update_test.go index b189eaa..2b2c1ac 100644 --- a/cmd/transactional_update_test.go +++ b/cmd/transactional_update_test.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "net/http" "testing" @@ -11,6 +12,7 @@ func TestRunTransactionalUpdate(t *testing.T) { body := `{ "id": "tx_abc", "name": "Renamed", + "transactionalGroupId": "tg_2", "draftEmailMessageId": "em_draft", "publishedEmailMessageId": "em_pub", "createdAt": "2026-01-01T00:00:00Z", @@ -27,6 +29,9 @@ func TestRunTransactionalUpdate(t *testing.T) { if tx.Name != "Renamed" { t.Errorf("Name = %q, want Renamed", tx.Name) } + if deref(tx.TransactionalGroupID) != "tg_2" { + t.Errorf("TransactionalGroupID = %q, want tg_2", deref(tx.TransactionalGroupID)) + } }) t.Run("returns error on non-200 response", func(t *testing.T) { @@ -36,4 +41,23 @@ func TestRunTransactionalUpdate(t *testing.T) { t.Fatal("expected error, got nil") } }) + + t.Run("sends transactionalGroupId when provided", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, body) + _, err := runTransactionalUpdate(cfg(t), "tx_abc", loops.UpdateTransactionalRequest{ + Name: "Renamed", + TransactionalGroupID: "tg_2", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["transactionalGroupId"] != "tg_2" { + t.Errorf("transactionalGroupId = %v, want tg_2", sent["transactionalGroupId"]) + } + }) } From 620383ca381f431093fb35e8475581af432c0015 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:01:54 -0700 Subject: [PATCH 6/9] audience-segments: add list and get commands --- cmd/audience_segments.go | 135 +++++++++++++++++++++++++++++ cmd/audience_segments_get_test.go | 89 +++++++++++++++++++ cmd/audience_segments_list_test.go | 62 +++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 cmd/audience_segments.go create mode 100644 cmd/audience_segments_get_test.go create mode 100644 cmd/audience_segments_list_test.go diff --git a/cmd/audience_segments.go b/cmd/audience_segments.go new file mode 100644 index 0000000..db4c086 --- /dev/null +++ b/cmd/audience_segments.go @@ -0,0 +1,135 @@ +package cmd + +import ( + "fmt" + + "github.com/loops-so/cli/internal/config" + "github.com/loops-so/loops-go" + "github.com/spf13/cobra" +) + +// formatSegmentFilter renders the filter on an audience segment. Unlike a +// campaign's audience-filter, a nil filter on a segment means the reserved +// "all contacts" segment — call that out explicitly. +func formatSegmentFilter(f *loops.AudienceFilter) string { + if f == nil { + return "(all contacts)" + } + return fmt.Sprintf("match=%s (%d conditions)", f.Match, len(f.Conditions)) +} + +func runAudienceSegmentsGet(cfg *config.Config, id string) (*loops.AudienceSegment, error) { + return newAPIClient(cfg).GetAudienceSegment(id) +} + +func runAudienceSegmentsList(cfg *config.Config, params loops.PaginationParams) ([]loops.AudienceSegment, error) { + client := newAPIClient(cfg) + if params.Cursor != "" { + segments, _, err := client.ListAudienceSegments(params) + return segments, err + } + return loops.Paginate(func(cursor string) ([]loops.AudienceSegment, *loops.Pagination, error) { + return client.ListAudienceSegments(loops.PaginationParams{ + PerPage: params.PerPage, + Cursor: cursor, + }) + }) +} + +var audienceSegmentsCmd = &cobra.Command{ + Use: "audience-segments", + Short: "Read audience segments", +} + +var audienceSegmentsListCmd = &cobra.Command{ + Use: "list", + Short: "List audience segments", + RunE: func(cmd *cobra.Command, args []string) error { + if err := validatePickFlags(cmd); err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + segments, err := runAudienceSegmentsList(cfg, paginationParams(cmd)) + if err != nil { + return err + } + + if isJSONOutput() { + if segments == nil { + segments = []loops.AudienceSegment{} + } + return printJSON(cmd.OutOrStdout(), segments) + } + + if len(segments) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No audience segments found.") + return nil + } + + headers := []string{"ID", "NAME", "FILTER", "UPDATED"} + rows := make([][]string, 0, len(segments)) + for _, s := range segments { + rows = append(rows, []string{ + s.ID, + s.Name, + formatSegmentFilter(s.Filter), + s.UpdatedAt, + }) + } + + if isPicking(cmd) { + return runPicker(headers, rows, []pickBinding{ + copyColumnBinding("enter", "copy id", "segment ID", rows, 0, cmd.OutOrStdout()), + }) + } + + t := newStyledTable(cmd.OutOrStdout(), headers...) + for _, r := range rows { + t.Row(r...) + } + return t.Render() + }, +} + +var audienceSegmentsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get an audience segment", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + s, err := runAudienceSegmentsGet(cfg, args[0]) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), s) + } + + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("segmentId", s.ID) + t.Row("name", s.Name) + t.Row("description", deref(s.Description)) + t.Row("filter", formatSegmentFilter(s.Filter)) + t.Row("createdAt", s.CreatedAt) + t.Row("updatedAt", s.UpdatedAt) + return t.Render() + }, +} + +func init() { + addPaginationFlags(audienceSegmentsListCmd) + addPickFlag(audienceSegmentsListCmd) + audienceSegmentsCmd.AddCommand(audienceSegmentsListCmd) + audienceSegmentsCmd.AddCommand(audienceSegmentsGetCmd) + rootCmd.AddCommand(audienceSegmentsCmd) +} diff --git a/cmd/audience_segments_get_test.go b/cmd/audience_segments_get_test.go new file mode 100644 index 0000000..8b881d1 --- /dev/null +++ b/cmd/audience_segments_get_test.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "net/http" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunAudienceSegmentsGet(t *testing.T) { + t.Run("returns segment with nontrivial filter", func(t *testing.T) { + body := `{ + "id": "seg_abc", + "name": "Active pro users", + "description": "Pro plan + opted in + active", + "createdAt": "2026-04-01T10:00:00Z", + "updatedAt": "2026-04-20T10:00:00Z", + "filter": { + "match": "all", + "conditions": [ + {"type": "property", "key": "plan", "operator": "equals", "value": "pro"}, + {"type": "optIn", "status": "accepted"}, + {"type": "activity", "action": "opened", "negate": false, "target": "campaign", "id": "cmp_1"} + ] + } + }` + serveJSON(t, http.StatusOK, body) + + s, err := runAudienceSegmentsGet(cfg(t), "seg_abc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.ID != "seg_abc" { + t.Errorf("ID = %q, want seg_abc", s.ID) + } + if deref(s.Description) != "Pro plan + opted in + active" { + t.Errorf("Description = %q", deref(s.Description)) + } + if s.Filter == nil { + t.Fatal("Filter nil") + } + if s.Filter.Match != "all" { + t.Errorf("Match = %q", s.Filter.Match) + } + if len(s.Filter.Conditions) != 3 { + t.Fatalf("Conditions len = %d, want 3", len(s.Filter.Conditions)) + } + if s.Filter.Conditions[0].Type != loops.AudienceConditionTypeProperty || s.Filter.Conditions[0].Property == nil { + t.Errorf("property condition not decoded: %+v", s.Filter.Conditions[0]) + } + if s.Filter.Conditions[1].Type != loops.AudienceConditionTypeOptIn || s.Filter.Conditions[1].OptIn == nil { + t.Errorf("optIn condition not decoded: %+v", s.Filter.Conditions[1]) + } + if s.Filter.Conditions[2].Type != loops.AudienceConditionTypeActivity || s.Filter.Conditions[2].Activity == nil { + t.Errorf("activity condition not decoded: %+v", s.Filter.Conditions[2]) + } + }) + + t.Run("nil filter (all-contacts reserved segment)", func(t *testing.T) { + body := `{ + "id": "seg_all", + "name": "All contacts", + "description": null, + "createdAt": "2026-04-01T10:00:00Z", + "updatedAt": "2026-04-01T10:00:00Z", + "filter": null + }` + serveJSON(t, http.StatusOK, body) + + s, err := runAudienceSegmentsGet(cfg(t), "seg_all") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.Filter != nil { + t.Errorf("Filter = %+v, want nil", s.Filter) + } + if got := formatSegmentFilter(s.Filter); got != "(all contacts)" { + t.Errorf("formatSegmentFilter = %q, want (all contacts)", got) + } + }) + + t.Run("returns error on non-200 response", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"success":false,"message":"Audience segment not found"}`) + _, err := runAudienceSegmentsGet(cfg(t), "seg_missing") + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/cmd/audience_segments_list_test.go b/cmd/audience_segments_list_test.go new file mode 100644 index 0000000..bbaa817 --- /dev/null +++ b/cmd/audience_segments_list_test.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "net/http" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunAudienceSegmentsList(t *testing.T) { + t.Run("returns segments", func(t *testing.T) { + body := `{ + "pagination": {"nextCursor": ""}, + "data": [ + { + "id": "seg_1", + "name": "All contacts", + "description": null, + "createdAt": "2026-04-01T10:00:00Z", + "updatedAt": "2026-04-01T10:00:00Z", + "filter": null + }, + { + "id": "seg_2", + "name": "Pro plan", + "description": "Paying customers", + "createdAt": "2026-04-02T10:00:00Z", + "updatedAt": "2026-04-20T10:00:00Z", + "filter": { + "match": "all", + "conditions": [ + {"type": "property", "key": "plan", "operator": "equals", "value": "pro"} + ] + } + } + ] + }` + serveJSON(t, http.StatusOK, body) + + segments, err := runAudienceSegmentsList(cfg(t), loops.PaginationParams{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(segments) != 2 { + t.Fatalf("len = %d, want 2", len(segments)) + } + if segments[0].ID != "seg_1" || segments[0].Filter != nil { + t.Errorf("segments[0] = %+v", segments[0]) + } + if segments[1].Filter == nil || len(segments[1].Filter.Conditions) != 1 { + t.Errorf("segments[1].Filter = %+v", segments[1].Filter) + } + }) + + t.Run("returns error on api failure", func(t *testing.T) { + serveJSON(t, http.StatusUnauthorized, `{"error":"unauthorized"}`) + _, err := runAudienceSegmentsList(cfg(t), loops.PaginationParams{}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} From 10a1c4934abf3dc9543eed464e5ed162ae562838 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:05:59 -0700 Subject: [PATCH 7/9] groups: add campaign-groups and transactional-groups commands --- cmd/groups.go | 246 +++++++++++++++++++++++++++++++++++++++++++++ cmd/groups_test.go | 157 +++++++++++++++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 cmd/groups.go create mode 100644 cmd/groups_test.go diff --git a/cmd/groups.go b/cmd/groups.go new file mode 100644 index 0000000..d63a2a6 --- /dev/null +++ b/cmd/groups.go @@ -0,0 +1,246 @@ +package cmd + +import ( + "fmt" + + "github.com/loops-so/cli/internal/config" + "github.com/loops-so/loops-go" + "github.com/spf13/cobra" +) + +// groupCmdSet binds the four SDK methods that back a *Group resource +// (campaign-groups or transactional-groups) to the labels used in CLI text. +// Both group types share the same Group/CreateGroupRequest/UpdateGroupRequest +// shapes, so the command tree is built once and dispatched twice. +type groupCmdSet struct { + use string // top-level command name, e.g. "campaign-groups" + idLabel string // table label for the id, e.g. "campaignGroupId" + singular string // for help text, e.g. "campaign group" + runList func(*config.Config, loops.PaginationParams) ([]loops.Group, error) + runGet func(*config.Config, string) (*loops.Group, error) + runCreate func(*config.Config, loops.CreateGroupRequest) (*loops.Group, error) + runUpdate func(*config.Config, string, loops.UpdateGroupRequest) (*loops.Group, error) +} + +// --- SDK method wrappers (one per resource × verb). +// Kept as standalone funcs so tests can call them directly. + +func runCampaignGroupsList(cfg *config.Config, params loops.PaginationParams) ([]loops.Group, error) { + return paginateGroups(params, newAPIClient(cfg).ListCampaignGroups) +} + +func runCampaignGroupsGet(cfg *config.Config, id string) (*loops.Group, error) { + return newAPIClient(cfg).GetCampaignGroup(id) +} + +func runCampaignGroupsCreate(cfg *config.Config, req loops.CreateGroupRequest) (*loops.Group, error) { + return newAPIClient(cfg).CreateCampaignGroup(req) +} + +func runCampaignGroupsUpdate(cfg *config.Config, id string, req loops.UpdateGroupRequest) (*loops.Group, error) { + return newAPIClient(cfg).UpdateCampaignGroup(id, req) +} + +func runTransactionalGroupsList(cfg *config.Config, params loops.PaginationParams) ([]loops.Group, error) { + return paginateGroups(params, newAPIClient(cfg).ListTransactionalGroups) +} + +func runTransactionalGroupsGet(cfg *config.Config, id string) (*loops.Group, error) { + return newAPIClient(cfg).GetTransactionalGroup(id) +} + +func runTransactionalGroupsCreate(cfg *config.Config, req loops.CreateGroupRequest) (*loops.Group, error) { + return newAPIClient(cfg).CreateTransactionalGroup(req) +} + +func runTransactionalGroupsUpdate(cfg *config.Config, id string, req loops.UpdateGroupRequest) (*loops.Group, error) { + return newAPIClient(cfg).UpdateTransactionalGroup(id, req) +} + +// paginateGroups runs single-page fetch when a cursor is set, otherwise walks +// every page via loops.Paginate. +func paginateGroups(params loops.PaginationParams, list func(loops.PaginationParams) ([]loops.Group, *loops.Pagination, error)) ([]loops.Group, error) { + if params.Cursor != "" { + groups, _, err := list(params) + return groups, err + } + return loops.Paginate(func(cursor string) ([]loops.Group, *loops.Pagination, error) { + return list(loops.PaginationParams{PerPage: params.PerPage, Cursor: cursor}) + }) +} + +func printGroup(cmd *cobra.Command, idLabel string, g *loops.Group) error { + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row(idLabel, g.ID) + t.Row("name", g.Name) + t.Row("description", g.Description) + t.Row("createdAt", g.CreatedAt) + t.Row("updatedAt", g.UpdatedAt) + return t.Render() +} + +func newGroupsCmd(set groupCmdSet) *cobra.Command { + parent := &cobra.Command{ + Use: set.use, + Short: "Manage " + set.use, + } + + listCmd := &cobra.Command{ + Use: "list", + Short: "List " + set.use, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validatePickFlags(cmd); err != nil { + return err + } + cfg, err := loadConfig() + if err != nil { + return err + } + groups, err := set.runList(cfg, paginationParams(cmd)) + if err != nil { + return err + } + + if isJSONOutput() { + if groups == nil { + groups = []loops.Group{} + } + return printJSON(cmd.OutOrStdout(), groups) + } + + if len(groups) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No %s found.\n", set.use) + return nil + } + + headers := []string{"ID", "NAME", "DESCRIPTION", "UPDATED"} + rows := make([][]string, 0, len(groups)) + for _, g := range groups { + rows = append(rows, []string{g.ID, g.Name, g.Description, g.UpdatedAt}) + } + + if isPicking(cmd) { + return runPicker(headers, rows, []pickBinding{ + copyColumnBinding("enter", "copy id", set.singular+" ID", rows, 0, cmd.OutOrStdout()), + }) + } + + t := newStyledTable(cmd.OutOrStdout(), headers...) + for _, r := range rows { + t.Row(r...) + } + return t.Render() + }, + } + addPaginationFlags(listCmd) + addPickFlag(listCmd) + + getCmd := &cobra.Command{ + Use: "get ", + Short: "Get a " + set.singular, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + g, err := set.runGet(cfg, args[0]) + if err != nil { + return err + } + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), g) + } + return printGroup(cmd, set.idLabel, g) + }, + } + + createCmd := &cobra.Command{ + Use: "create", + Short: "Create a " + set.singular, + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + + cfg, err := loadConfig() + if err != nil { + return err + } + g, err := set.runCreate(cfg, loops.CreateGroupRequest{ + Name: name, + Description: description, + }) + if err != nil { + return err + } + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), g) + } + fmt.Fprintf(cmd.OutOrStdout(), "Created. (id: %s)\n\n", g.ID) + return printGroup(cmd, set.idLabel, g) + }, + } + createCmd.Flags().StringP("name", "n", "", set.singular+" name (required)") + createCmd.Flags().StringP("description", "d", "", set.singular+" description") + createCmd.MarkFlagRequired("name") + + updateCmd := &cobra.Command{ + Use: "update ", + Short: "Update a " + set.singular, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + req := loops.UpdateGroupRequest{} + if cmd.Flags().Changed("name") { + req.Name, _ = cmd.Flags().GetString("name") + } + if cmd.Flags().Changed("description") { + req.Description, _ = cmd.Flags().GetString("description") + } + + cfg, err := loadConfig() + if err != nil { + return err + } + g, err := set.runUpdate(cfg, args[0], req) + if err != nil { + return err + } + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), g) + } + fmt.Fprintf(cmd.OutOrStdout(), "Updated. (id: %s)\n\n", g.ID) + return printGroup(cmd, set.idLabel, g) + }, + } + updateCmd.Flags().StringP("name", "n", "", "New name") + updateCmd.Flags().StringP("description", "d", "", "New description") + updateCmd.MarkFlagsOneRequired("name", "description") + + parent.AddCommand(listCmd) + parent.AddCommand(getCmd) + parent.AddCommand(createCmd) + parent.AddCommand(updateCmd) + return parent +} + +func init() { + rootCmd.AddCommand(newGroupsCmd(groupCmdSet{ + use: "campaign-groups", + idLabel: "campaignGroupId", + singular: "campaign group", + runList: runCampaignGroupsList, + runGet: runCampaignGroupsGet, + runCreate: runCampaignGroupsCreate, + runUpdate: runCampaignGroupsUpdate, + })) + + rootCmd.AddCommand(newGroupsCmd(groupCmdSet{ + use: "transactional-groups", + idLabel: "transactionalGroupId", + singular: "transactional group", + runList: runTransactionalGroupsList, + runGet: runTransactionalGroupsGet, + runCreate: runTransactionalGroupsCreate, + runUpdate: runTransactionalGroupsUpdate, + })) +} diff --git a/cmd/groups_test.go b/cmd/groups_test.go new file mode 100644 index 0000000..5f22f32 --- /dev/null +++ b/cmd/groups_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/loops-so/loops-go" +) + +const sampleGroupBody = `{ + "id": "grp_abc", + "name": "Onboarding", + "description": "Welcome flow", + "createdAt": "2026-04-01T10:00:00Z", + "updatedAt": "2026-04-20T10:00:00Z" +}` + +func TestRunCampaignGroupsList(t *testing.T) { + t.Run("returns groups", func(t *testing.T) { + body := `{"pagination":{"nextCursor":""},"data":[ + {"id":"grp_1","name":"Onboarding","description":"","createdAt":"2026-04-01","updatedAt":"2026-04-02"}, + {"id":"grp_2","name":"Promotions","description":"Sales","createdAt":"2026-03-01","updatedAt":"2026-03-05"} + ]}` + got := serveJSONCapture(t, http.StatusOK, body) + + groups, err := runCampaignGroupsList(cfg(t), loops.PaginationParams{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Path != "/campaign-groups" { + t.Errorf("Path = %q, want /campaign-groups", got.Path) + } + if len(groups) != 2 { + t.Fatalf("len = %d, want 2", len(groups)) + } + if groups[0].ID != "grp_1" || groups[1].ID != "grp_2" { + t.Errorf("ids = %q, %q", groups[0].ID, groups[1].ID) + } + }) + + t.Run("returns error on api failure", func(t *testing.T) { + serveJSON(t, http.StatusUnauthorized, `{"error":"unauthorized"}`) + _, err := runCampaignGroupsList(cfg(t), loops.PaginationParams{}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestRunCampaignGroupsGet(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, sampleGroupBody) + g, err := runCampaignGroupsGet(cfg(t), "grp_abc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Path != "/campaign-groups/grp_abc" { + t.Errorf("Path = %q", got.Path) + } + if g.ID != "grp_abc" || g.Name != "Onboarding" { + t.Errorf("group = %+v", g) + } +} + +func TestRunCampaignGroupsCreate(t *testing.T) { + got := serveJSONCapture(t, http.StatusCreated, sampleGroupBody) + _, err := runCampaignGroupsCreate(cfg(t), loops.CreateGroupRequest{ + Name: "Onboarding", + Description: "Welcome flow", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Path != "/campaign-groups" || got.Method != http.MethodPost { + t.Errorf("Path/Method = %q/%q", got.Path, got.Method) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["name"] != "Onboarding" { + t.Errorf("name = %v", sent["name"]) + } + if sent["description"] != "Welcome flow" { + t.Errorf("description = %v", sent["description"]) + } +} + +func TestRunCampaignGroupsUpdate(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, sampleGroupBody) + _, err := runCampaignGroupsUpdate(cfg(t), "grp_abc", loops.UpdateGroupRequest{ + Name: "Renamed", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Path != "/campaign-groups/grp_abc" || got.Method != http.MethodPost { + t.Errorf("Path/Method = %q/%q", got.Path, got.Method) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["name"] != "Renamed" { + t.Errorf("name = %v", sent["name"]) + } + if _, ok := sent["description"]; ok { + t.Errorf("description should be omitted when empty, got %v", sent) + } +} + +// The transactional-groups path uses the same helper machinery; spot-check +// each verb's endpoint to confirm the dispatch is correct. + +func TestRunTransactionalGroupsEndpoints(t *testing.T) { + t.Run("list hits /transactional-groups", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, `{"pagination":{"nextCursor":""},"data":[]}`) + if _, err := runTransactionalGroupsList(cfg(t), loops.PaginationParams{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Path != "/transactional-groups" { + t.Errorf("Path = %q", got.Path) + } + }) + + t.Run("get hits /transactional-groups/:id", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, sampleGroupBody) + if _, err := runTransactionalGroupsGet(cfg(t), "grp_abc"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Path != "/transactional-groups/grp_abc" { + t.Errorf("Path = %q", got.Path) + } + }) + + t.Run("create hits /transactional-groups", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusCreated, sampleGroupBody) + if _, err := runTransactionalGroupsCreate(cfg(t), loops.CreateGroupRequest{Name: "Onboarding"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Path != "/transactional-groups" || got.Method != http.MethodPost { + t.Errorf("Path/Method = %q/%q", got.Path, got.Method) + } + }) + + t.Run("update hits /transactional-groups/:id", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, sampleGroupBody) + if _, err := runTransactionalGroupsUpdate(cfg(t), "grp_abc", loops.UpdateGroupRequest{Name: "Renamed"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Path != "/transactional-groups/grp_abc" || got.Method != http.MethodPost { + t.Errorf("Path/Method = %q/%q", got.Path, got.Method) + } + }) +} From 63176d20ac7ec61c63307cf26ef63652989e3ee1 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:18:29 -0700 Subject: [PATCH 8/9] campaigns: support clearing nullable fields with "null" sentinel --- cmd/campaigns.go | 71 ++++++++++++++++++++++++++---------- cmd/campaigns_fields_test.go | 71 ++++++++++++++++++++++++++++++++++++ cmd/campaigns_update_test.go | 23 ++++++++++++ 3 files changed, 146 insertions(+), 19 deletions(-) diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 1c4e243..587b109 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -29,8 +29,10 @@ func formatAudienceFilter(f *loops.AudienceFilter) string { // user explicitly provided (keyed by JSON field name) so partial updates can // send only those fields. // -// Clearing nullable fields (mailing-list-id, audience-segment-id, -// audience-filter) is not yet supported on update; setting values is. +// For nullable fields (MailingListID, AudienceSegmentID, AudienceFilter), a +// pointer of nil with the corresponding Set key true encodes "send null" — +// which the API treats as a clear. CLI users opt in via the string sentinel +// "null". type campaignFieldParams struct { Name string CampaignGroupID string @@ -41,18 +43,39 @@ type campaignFieldParams struct { Set map[string]bool } +// nullSentinel is the value users pass to a nullable string flag to clear the +// server-side field (sent as JSON null). Empty string is rejected so users +// don't accidentally write empty values. +const nullSentinel = "null" + func addCampaignFieldFlags(cmd *cobra.Command) { cmd.Flags().StringP("name", "n", "", "Campaign name") cmd.Flags().String("campaign-group-id", "", "Campaign group ID") - cmd.Flags().String("mailing-list-id", "", "Mailing list ID to target") - cmd.Flags().String("audience-segment-id", "", "Audience segment ID to target") - cmd.Flags().String("audience-filter-file", "", "Path to a JSON file with an ad-hoc audience filter") + cmd.Flags().String("mailing-list-id", "", `Mailing list ID to target. Pass "null" to clear.`) + cmd.Flags().String("audience-segment-id", "", `Audience segment ID to target. Pass "null" to clear.`) + cmd.Flags().String("audience-filter-file", "", `Path to a JSON file with an ad-hoc audience filter. Pass "null" to clear.`) cmd.Flags().Bool("schedule-now", false, "Send immediately when published") cmd.Flags().String("schedule-at", "", "Send at the given RFC3339 timestamp (e.g. 2026-07-01T12:00:00Z)") cmd.MarkFlagsMutuallyExclusive("audience-segment-id", "audience-filter-file") cmd.MarkFlagsMutuallyExclusive("schedule-now", "schedule-at") } +// readNullableFlag returns (pointerOrNil, set, err) for a string flag that +// supports the "null" sentinel. Empty string is rejected. +func readNullableFlag(cmd *cobra.Command, flagName string) (*string, bool, error) { + if !cmd.Flags().Changed(flagName) { + return nil, false, nil + } + v, _ := cmd.Flags().GetString(flagName) + if v == "" { + return nil, false, fmt.Errorf(`--%s requires a value; pass "null" to clear`, flagName) + } + if v == nullSentinel { + return nil, true, nil + } + return &v, true, nil +} + func campaignFieldParamsFromCmd(cmd *cobra.Command) (campaignFieldParams, error) { p := campaignFieldParams{Set: map[string]bool{}} @@ -64,28 +87,38 @@ func campaignFieldParamsFromCmd(cmd *cobra.Command) (campaignFieldParams, error) p.CampaignGroupID, _ = cmd.Flags().GetString("campaign-group-id") p.Set["campaignGroupId"] = true } - if cmd.Flags().Changed("mailing-list-id") { - v, _ := cmd.Flags().GetString("mailing-list-id") - p.MailingListID = &v + if v, set, err := readNullableFlag(cmd, "mailing-list-id"); err != nil { + return p, err + } else if set { + p.MailingListID = v p.Set["mailingListId"] = true } - if cmd.Flags().Changed("audience-segment-id") { - v, _ := cmd.Flags().GetString("audience-segment-id") - p.AudienceSegmentID = &v + if v, set, err := readNullableFlag(cmd, "audience-segment-id"); err != nil { + return p, err + } else if set { + p.AudienceSegmentID = v p.Set["audienceSegmentId"] = true } if cmd.Flags().Changed("audience-filter-file") { path, _ := cmd.Flags().GetString("audience-filter-file") - data, err := os.ReadFile(path) - if err != nil { - return p, fmt.Errorf("read --audience-filter-file: %w", err) + if path == "" { + return p, fmt.Errorf(`--audience-filter-file requires a value; pass "null" to clear`) } - var f loops.AudienceFilter - if err := json.Unmarshal(data, &f); err != nil { - return p, fmt.Errorf("parse --audience-filter-file: %w", err) + if path == nullSentinel { + p.AudienceFilter = nil + p.Set["audienceFilter"] = true + } else { + data, err := os.ReadFile(path) + if err != nil { + return p, fmt.Errorf("read --audience-filter-file: %w", err) + } + var f loops.AudienceFilter + if err := json.Unmarshal(data, &f); err != nil { + return p, fmt.Errorf("parse --audience-filter-file: %w", err) + } + p.AudienceFilter = &f + p.Set["audienceFilter"] = true } - p.AudienceFilter = &f - p.Set["audienceFilter"] = true } if cmd.Flags().Changed("schedule-now") { p.Scheduling = &loops.CampaignSchedulingRequest{Method: loops.CampaignSchedulingMethodNow} diff --git a/cmd/campaigns_fields_test.go b/cmd/campaigns_fields_test.go index bdb2e12..d08abd8 100644 --- a/cmd/campaigns_fields_test.go +++ b/cmd/campaigns_fields_test.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" "github.com/loops-so/loops-go" @@ -151,4 +152,74 @@ func TestCampaignFieldParamsFromCmd(t *testing.T) { t.Fatal("expected error, got nil") } }) + + t.Run(`mailing-list-id "null" sentinel clears the field`, func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--mailing-list-id", "null"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + p, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.MailingListID != nil { + t.Errorf("MailingListID = %v, want nil", p.MailingListID) + } + if !p.Set["mailingListId"] { + t.Error(`Set["mailingListId"] = false, want true`) + } + }) + + t.Run(`audience-segment-id "null" sentinel clears the field`, func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--audience-segment-id", "null"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + p, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.AudienceSegmentID != nil { + t.Errorf("AudienceSegmentID = %v, want nil", p.AudienceSegmentID) + } + if !p.Set["audienceSegmentId"] { + t.Error(`Set["audienceSegmentId"] = false, want true`) + } + }) + + t.Run(`audience-filter-file "null" sentinel clears the filter`, func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--audience-filter-file", "null"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + p, err := campaignFieldParamsFromCmd(cmd) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.AudienceFilter != nil { + t.Errorf("AudienceFilter = %v, want nil", p.AudienceFilter) + } + if !p.Set["audienceFilter"] { + t.Error(`Set["audienceFilter"] = false, want true`) + } + }) + + t.Run("empty value on nullable flag is rejected", func(t *testing.T) { + cases := []string{"mailing-list-id", "audience-segment-id", "audience-filter-file"} + for _, flag := range cases { + t.Run(flag, func(t *testing.T) { + cmd := newCampaignFieldCmd() + if err := cmd.ParseFlags([]string{"--" + flag, ""}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + _, err := campaignFieldParamsFromCmd(cmd) + if err == nil { + t.Fatalf("--%s with empty value: expected error, got nil", flag) + } + if !strings.Contains(err.Error(), `"null"`) { + t.Errorf(`--%s error = %q, want it to mention "null"`, flag, err.Error()) + } + }) + } + }) } diff --git a/cmd/campaigns_update_test.go b/cmd/campaigns_update_test.go index 0bf20bf..27a70ff 100644 --- a/cmd/campaigns_update_test.go +++ b/cmd/campaigns_update_test.go @@ -44,6 +44,29 @@ func TestRunCampaignsUpdate(t *testing.T) { } }) + t.Run("nil pointer + Set sends JSON null on the wire", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, body) + _, err := runCampaignsUpdate(cfg(t), "cmp_abc123", loops.UpdateCampaignRequest{ + MailingListID: nil, + Set: map[string]bool{"mailingListId": true}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + v, ok := sent["mailingListId"] + if !ok { + t.Fatalf("mailingListId missing from request body: %v", sent) + } + if v != nil { + t.Errorf("mailingListId = %v, want nil (JSON null)", v) + } + }) + t.Run("Set map controls which fields are sent", func(t *testing.T) { got := serveJSONCapture(t, http.StatusOK, body) ts := "2026-07-01T12:00:00Z" From 75c5f79c7b486336b960a6937035fe6e5de1a41c Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:54:04 -0700 Subject: [PATCH 9/9] bump sdk to v.0.3.0 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c04ce14..24e6e47 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/colorprofile v0.4.2 github.com/charmbracelet/x/term v0.2.2 - github.com/loops-so/loops-go v0.2.0 + github.com/loops-so/loops-go v0.3.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/zalando/go-keyring v0.2.6 diff --git a/go.sum b/go.sum index 75b15f4..8e307d9 100644 --- a/go.sum +++ b/go.sum @@ -299,8 +299,8 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/loops-so/loops-go v0.2.0 h1:5Xbf62IlO2JbXShadFsWBt6Dy7JBBbvoMfygG4UU1zU= -github.com/loops-so/loops-go v0.2.0/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE= +github.com/loops-so/loops-go v0.3.0 h1:E4hMTtr1BH0JF0/B3QKUMfMes+Czjh90toS5XpPiMZg= +github.com/loops-so/loops-go v0.3.0/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=