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=