From de151d198adf79124a5541022731db1d3aac732b Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Thu, 30 Jul 2026 13:29:57 +0200 Subject: [PATCH 1/3] MT-22401: Add email campaigns commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decisions: - Request bodies are flat (no email_campaign wrapper); nested reply_to/template_attributes are assembled from flat flags - Single, list, and stats responses unwrap the data envelope; list pagination metadata is ignored for output - Endpoint is token-scoped (/api/email_campaigns) — no account-id requirement, unlike other resources - Lifecycle actions (start/schedule/cancel/terminate/reset) are subcommands sharing one body-less POST helper - Delete handles 204 No Content with a confirmation message per repo convention --- README.md | 8 + cmd/root.go | 2 + .../commands/emailcampaigns/attributes.go | 107 ++++ internal/commands/emailcampaigns/cancel.go | 10 + internal/commands/emailcampaigns/create.go | 56 ++ internal/commands/emailcampaigns/delete.go | 41 ++ .../commands/emailcampaigns/emailcampaigns.go | 39 ++ .../emailcampaigns/emailcampaigns_test.go | 549 ++++++++++++++++++ internal/commands/emailcampaigns/get.go | 41 ++ internal/commands/emailcampaigns/list.go | 126 ++++ internal/commands/emailcampaigns/reset.go | 10 + internal/commands/emailcampaigns/schedule.go | 50 ++ internal/commands/emailcampaigns/start.go | 47 ++ internal/commands/emailcampaigns/stats.go | 96 +++ internal/commands/emailcampaigns/terminate.go | 10 + internal/commands/emailcampaigns/update.go | 45 ++ 16 files changed, 1237 insertions(+) create mode 100644 internal/commands/emailcampaigns/attributes.go create mode 100644 internal/commands/emailcampaigns/cancel.go create mode 100644 internal/commands/emailcampaigns/create.go create mode 100644 internal/commands/emailcampaigns/delete.go create mode 100644 internal/commands/emailcampaigns/emailcampaigns.go create mode 100644 internal/commands/emailcampaigns/emailcampaigns_test.go create mode 100644 internal/commands/emailcampaigns/get.go create mode 100644 internal/commands/emailcampaigns/list.go create mode 100644 internal/commands/emailcampaigns/reset.go create mode 100644 internal/commands/emailcampaigns/schedule.go create mode 100644 internal/commands/emailcampaigns/start.go create mode 100644 internal/commands/emailcampaigns/stats.go create mode 100644 internal/commands/emailcampaigns/terminate.go create mode 100644 internal/commands/emailcampaigns/update.go diff --git a/README.md b/README.md index ad7ebd6..8a6e392 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,13 @@ mailtrap contact-lists list mailtrap contact-lists list --search news mailtrap contact-fields create --name "Company" --data-type text --merge-tag "{{company}}" +# Email campaigns +mailtrap email-campaigns list --search spring +mailtrap email-campaigns create --name "Spring Sale" --mailsend-domain-id "d2313359-acb4-4b87-bce6-f5774f6a1e37" --from-local-part news --subject "Spring is here" +mailtrap email-campaigns update --id 4567 --body-html '

Hi {{first_name}}!

Unsubscribe' --contact-list-ids 55,56 +mailtrap email-campaigns schedule --id 4567 --datetime "2026-06-01T09:00:00Z" +mailtrap email-campaigns stats --id 4567 --start-date 2026-05-01 --end-date 2026-05-31 + # Sandboxes & projects mailtrap projects list mailtrap sandboxes list @@ -155,6 +162,7 @@ mailtrap domains list --output text | **Contacts** | `contacts get`, `contacts create`, `contacts update`, `contacts delete`, `contacts import`, `contacts export`, `contacts import-status`, `contacts export-status`, `contacts create-event` | | **Contact Lists** | `contact-lists list`, `contact-lists get`, `contact-lists create`, `contact-lists update`, `contact-lists delete` | | **Contact Fields** | `contact-fields list`, `contact-fields get`, `contact-fields create`, `contact-fields update`, `contact-fields delete` | +| **Email Campaigns** | `email-campaigns list`, `email-campaigns get`, `email-campaigns create`, `email-campaigns update`, `email-campaigns delete`, `email-campaigns start`, `email-campaigns schedule`, `email-campaigns cancel`, `email-campaigns terminate`, `email-campaigns reset`, `email-campaigns stats` | | **Projects** | `projects list`, `projects get`, `projects create`, `projects update`, `projects delete` | | **Sandboxes** | `sandboxes list`, `sandboxes get`, `sandboxes create`, `sandboxes update`, `sandboxes delete`, `sandboxes clean`, `sandboxes mark-read`, `sandboxes reset-credentials`, `sandboxes toggle-email`, `sandboxes reset-email` | | **Messages** | `messages list`, `messages get`, `messages update`, `messages delete`, `messages forward`, `messages spam-score`, `messages html-analysis`, `messages headers`, `messages html`, `messages text`, `messages source`, `messages raw`, `messages eml` | diff --git a/cmd/root.go b/cmd/root.go index 3be8dcf..889d033 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( "github.com/mailtrap/mailtrap-cli/internal/commands/contacts" "github.com/mailtrap/mailtrap-cli/internal/commands/domains" email_logs "github.com/mailtrap/mailtrap-cli/internal/commands/email_logs" + "github.com/mailtrap/mailtrap-cli/internal/commands/emailcampaigns" "github.com/mailtrap/mailtrap-cli/internal/commands/sandboxes" "github.com/mailtrap/mailtrap-cli/internal/commands/messages" "github.com/mailtrap/mailtrap-cli/internal/commands/organizations" @@ -70,6 +71,7 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(contacts.NewCmdContacts(f)) cmd.AddCommand(contact_lists.NewCmdContactLists(f)) cmd.AddCommand(contact_fields.NewCmdContactFields(f)) + cmd.AddCommand(emailcampaigns.NewCmdEmailCampaigns(f)) // Account Management cmd.AddCommand(accounts.NewCmdAccounts(f)) diff --git a/internal/commands/emailcampaigns/attributes.go b/internal/commands/emailcampaigns/attributes.go new file mode 100644 index 0000000..db796b4 --- /dev/null +++ b/internal/commands/emailcampaigns/attributes.go @@ -0,0 +1,107 @@ +package emailcampaigns + +import ( + "github.com/spf13/cobra" +) + +// campaignAttrs holds the writable campaign attributes shared by create and update. +type campaignAttrs struct { + Name string + MailsendDomainID string + FromDisplayName string + FromLocalPart string + ReplyToDisplayName string + ReplyToLocalPart string + ReplyToDomain string + Subject string + BodyHTML string + BodyText string + MergeTags []string + DeliveryMode string + EmailsPerHour int64 + ContactListIDs []int64 + ContactSegmentIDs []int64 +} + +func addAttributeFlags(cmd *cobra.Command, attrs *campaignAttrs) { + cmd.Flags().StringVar(&attrs.Name, "name", "", "Campaign name") + cmd.Flags().StringVar(&attrs.MailsendDomainID, "mailsend-domain-id", "", "UUID of the verified sending domain") + cmd.Flags().StringVar(&attrs.FromDisplayName, "from-display-name", "", "Display name shown in the From header") + cmd.Flags().StringVar(&attrs.FromLocalPart, "from-local-part", "", "Local part (before the @) of the From address") + cmd.Flags().StringVar(&attrs.ReplyToDisplayName, "reply-to-display-name", "", "Reply-To display name") + cmd.Flags().StringVar(&attrs.ReplyToLocalPart, "reply-to-local-part", "", "Reply-To local part (before the @)") + cmd.Flags().StringVar(&attrs.ReplyToDomain, "reply-to-domain", "", "Reply-To domain") + cmd.Flags().StringVar(&attrs.Subject, "subject", "", "Email subject line") + cmd.Flags().StringVar(&attrs.BodyHTML, "body-html", "", "HTML body of the email (the design)") + cmd.Flags().StringVar(&attrs.BodyText, "body-text", "", "Plain-text alternative of the email body") + cmd.Flags().StringSliceVar(&attrs.MergeTags, "merge-tags", nil, "Merge tag names used in the content (comma-separated), without {{ }}") + cmd.Flags().StringVar(&attrs.DeliveryMode, "delivery-mode", "", "Delivery mode: rapid, gradual") + cmd.Flags().Int64Var(&attrs.EmailsPerHour, "emails-per-hour", 0, "Emails per hour when delivery mode is gradual") + cmd.Flags().Int64SliceVar(&attrs.ContactListIDs, "contact-list-ids", nil, "Contact list IDs to send to (comma-separated)") + cmd.Flags().Int64SliceVar(&attrs.ContactSegmentIDs, "contact-segment-ids", nil, "Contact segment IDs to send to (comma-separated)") +} + +// buildAttributesBody builds the flat request body (no wrapper key) from the flags +// that were explicitly set, assembling the nested reply_to and template_attributes objects. +func buildAttributesBody(cmd *cobra.Command, attrs *campaignAttrs) map[string]interface{} { + body := map[string]interface{}{} + + if cmd.Flags().Changed("name") { + body["name"] = attrs.Name + } + if cmd.Flags().Changed("mailsend-domain-id") { + body["mailsend_domain_id"] = attrs.MailsendDomainID + } + if cmd.Flags().Changed("from-display-name") { + body["from_display_name"] = attrs.FromDisplayName + } + if cmd.Flags().Changed("from-local-part") { + body["from_local_part"] = attrs.FromLocalPart + } + + replyTo := map[string]interface{}{} + if cmd.Flags().Changed("reply-to-display-name") { + replyTo["display_name"] = attrs.ReplyToDisplayName + } + if cmd.Flags().Changed("reply-to-local-part") { + replyTo["local_part"] = attrs.ReplyToLocalPart + } + if cmd.Flags().Changed("reply-to-domain") { + replyTo["domain"] = attrs.ReplyToDomain + } + if len(replyTo) > 0 { + body["reply_to"] = replyTo + } + + templateAttrs := map[string]interface{}{} + if cmd.Flags().Changed("subject") { + templateAttrs["subject"] = attrs.Subject + } + if cmd.Flags().Changed("body-html") { + templateAttrs["body_html"] = attrs.BodyHTML + } + if cmd.Flags().Changed("body-text") { + templateAttrs["body_text"] = attrs.BodyText + } + if cmd.Flags().Changed("merge-tags") { + templateAttrs["merge_tags"] = attrs.MergeTags + } + if len(templateAttrs) > 0 { + body["template_attributes"] = templateAttrs + } + + if cmd.Flags().Changed("delivery-mode") { + body["delivery_mode"] = attrs.DeliveryMode + } + if cmd.Flags().Changed("emails-per-hour") { + body["delivery_options"] = map[string]interface{}{"emails_per_hour": attrs.EmailsPerHour} + } + if cmd.Flags().Changed("contact-list-ids") { + body["contact_list_ids"] = attrs.ContactListIDs + } + if cmd.Flags().Changed("contact-segment-ids") { + body["contact_segment_ids"] = attrs.ContactSegmentIDs + } + + return body +} diff --git a/internal/commands/emailcampaigns/cancel.go b/internal/commands/emailcampaigns/cancel.go new file mode 100644 index 0000000..f1e9d72 --- /dev/null +++ b/internal/commands/emailcampaigns/cancel.go @@ -0,0 +1,10 @@ +package emailcampaigns + +import ( + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdCancel(f *cmdutil.Factory) *cobra.Command { + return newLifecycleCmd(f, "cancel", "Cancel a scheduled email campaign") +} diff --git a/internal/commands/emailcampaigns/create.go b/internal/commands/emailcampaigns/create.go new file mode 100644 index 0000000..e57c2b7 --- /dev/null +++ b/internal/commands/emailcampaigns/create.go @@ -0,0 +1,56 @@ +package emailcampaigns + +import ( + "context" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + attrs := &campaignAttrs{} + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an email campaign", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("name", attrs.Name); err != nil { + return err + } + if err := cmdutil.RequireFlag("mailsend-domain-id", attrs.MailsendDomainID); err != nil { + return err + } + if err := cmdutil.RequireFlag("from-local-part", attrs.FromLocalPart); err != nil { + return err + } + if err := cmdutil.RequireFlag("subject", attrs.Subject); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + body := buildAttributesBody(cmd, attrs) + + var resp campaignResponse + if err := c.Post(context.Background(), client.BaseGeneral, basePath, body, &resp); err != nil { + return err + } + + format := cmdutil.GetOutputFormat() + return output.Print(f.IOStreams.Out, format, resp.Data, campaignColumns) + }, + } + + addAttributeFlags(cmd, attrs) + // The attribute flags are shared with update, where all of them are optional. + for _, name := range []string{"name", "mailsend-domain-id", "from-local-part", "subject"} { + cmd.Flags().Lookup(name).Usage += " (required)" + } + + return cmd +} diff --git a/internal/commands/emailcampaigns/delete.go b/internal/commands/emailcampaigns/delete.go new file mode 100644 index 0000000..aae64ee --- /dev/null +++ b/internal/commands/emailcampaigns/delete.go @@ -0,0 +1,41 @@ +package emailcampaigns + +import ( + "context" + "fmt" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + var campaignID string + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete an email campaign", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("id", campaignID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + // The API returns 204 No Content with no body. + if err := c.Delete(context.Background(), client.BaseGeneral, campaignPath(campaignID), nil); err != nil { + return err + } + + fmt.Fprintln(f.IOStreams.Out, "Email campaign deleted successfully.") + return nil + }, + } + + cmd.Flags().StringVar(&campaignID, "id", "", "Email campaign ID (required)") + + return cmd +} diff --git a/internal/commands/emailcampaigns/emailcampaigns.go b/internal/commands/emailcampaigns/emailcampaigns.go new file mode 100644 index 0000000..6b58fc7 --- /dev/null +++ b/internal/commands/emailcampaigns/emailcampaigns.go @@ -0,0 +1,39 @@ +package emailcampaigns + +import ( + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// Email campaigns are token-scoped: the API path is /api/email_campaigns with no +// account_id segment, so commands in this package do not call config.RequireAccountID(). +const basePath = "/api/email_campaigns" + +func campaignPath(segments ...string) string { + path := basePath + for _, s := range segments { + path += "/" + s + } + return path +} + +func NewCmdEmailCampaigns(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "email-campaigns", + Short: "Manage email campaigns", + } + + cmd.AddCommand(NewCmdList(f)) + cmd.AddCommand(NewCmdGet(f)) + cmd.AddCommand(NewCmdCreate(f)) + cmd.AddCommand(NewCmdUpdate(f)) + cmd.AddCommand(NewCmdDelete(f)) + cmd.AddCommand(NewCmdStart(f)) + cmd.AddCommand(NewCmdSchedule(f)) + cmd.AddCommand(NewCmdCancel(f)) + cmd.AddCommand(NewCmdTerminate(f)) + cmd.AddCommand(NewCmdReset(f)) + cmd.AddCommand(NewCmdStats(f)) + + return cmd +} diff --git a/internal/commands/emailcampaigns/emailcampaigns_test.go b/internal/commands/emailcampaigns/emailcampaigns_test.go new file mode 100644 index 0000000..49774c5 --- /dev/null +++ b/internal/commands/emailcampaigns/emailcampaigns_test.go @@ -0,0 +1,549 @@ +package emailcampaigns_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/commands/emailcampaigns" + "github.com/mailtrap/mailtrap-cli/internal/config" + "github.com/spf13/viper" +) + +func setupTest(handler http.HandlerFunc) (*cmdutil.Factory, *bytes.Buffer, func()) { + server := httptest.NewServer(handler) + + c := client.New("test-token") + c.SetBaseURL(client.BaseGeneral, server.URL) + + buf := &bytes.Buffer{} + f := &cmdutil.Factory{ + Config: func() *config.Config { + return &config.Config{APIToken: "test-token", AccountID: "123"} + }, + IOStreams: &cmdutil.IOStreams{ + Out: buf, + ErrOut: &bytes.Buffer{}, + }, + ClientOverride: c, + } + + viper.Set("api-token", "test-token") + viper.Set("account-id", "123") + viper.Set("output", "table") + + return f, buf, func() { + server.Close() + viper.Reset() + } +} + +func sampleCampaign() map[string]interface{} { + return map[string]interface{}{ + "id": 4567, + "type": "ContactsEmailCampaign", + "mailsend_domain_id": "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "mailsend_domain_name": "acme.com", + "name": "Spring Sale", + "from_local_part": "news", + "from_display_name": "Acme Marketing", + "current_state": "draft", + "created_at": "2026-05-01T10:15:00.000Z", + "updated_at": "2026-05-02T09:00:00.000Z", + "recipient_total_count": 1500, + "contact_list_ids": []int{55, 56}, + "delivery_mode": "rapid", + "template": map[string]interface{}{ + "id": 789, + "subject": "Spring is here", + }, + } +} + +func TestEmailCampaignsList(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + // Token-scoped endpoint: no /api/accounts/{id} prefix. + if r.URL.Path != "/api/email_campaigns" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Api-Token") != "test-token" { + t.Errorf("expected Api-Token header 'test-token', got %q", r.Header.Get("Api-Token")) + } + if r.URL.Query().Get("per_page") != "10" { + t.Errorf("expected per_page=10, got %q", r.URL.Query().Get("per_page")) + } + if r.URL.Query().Get("search") != "spring" { + t.Errorf("expected search=spring, got %q", r.URL.Query().Get("search")) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{sampleCampaign()}, + "pagination": map[string]interface{}{"token": 1, "next_token": nil}, + }) + }) + defer cleanup() + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{"list", "--per-page", "10", "--search", "spring"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Spring Sale") { + t.Errorf("expected output to contain 'Spring Sale', got:\n%s", output) + } + if !strings.Contains(output, "draft") { + t.Errorf("expected output to contain 'draft', got:\n%s", output) + } + if !strings.Contains(output, "acme.com") { + t.Errorf("expected output to contain 'acme.com', got:\n%s", output) + } +} + +func TestEmailCampaignsListJSON(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{sampleCampaign()}, + }) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{"list"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result []map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput:\n%s", err, buf.String()) + } + + if len(result) != 1 { + t.Fatalf("expected 1 campaign, got %d", len(result)) + } + if result[0]["name"] != "Spring Sale" { + t.Errorf("expected name 'Spring Sale', got %v", result[0]["name"]) + } + if result[0]["mailsend_domain_id"] != "d2313359-acb4-4b87-bce6-f5774f6a1e37" { + t.Errorf("expected mailsend_domain_id UUID, got %v", result[0]["mailsend_domain_id"]) + } +} + +func TestEmailCampaignsGet(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/email_campaigns/4567" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"data": sampleCampaign()}) + }) + defer cleanup() + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{"get", "--id", "4567"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Spring Sale") { + t.Errorf("expected output to contain 'Spring Sale', got:\n%s", output) + } + if !strings.Contains(output, "1500") { + t.Errorf("expected output to contain recipient count, got:\n%s", output) + } +} + +func TestEmailCampaignsGetMissingID(t *testing.T) { + f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + buf := &bytes.Buffer{} + f.IOStreams.Out = buf + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{"get"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when --id is missing") + } + if !strings.Contains(err.Error(), "--id is required") { + t.Errorf("expected '--id is required' error, got: %v", err) + } +} + +func TestEmailCampaignsCreate(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/email_campaigns" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + body, _ := io.ReadAll(r.Body) + var reqBody map[string]interface{} + json.Unmarshal(body, &reqBody) + + // The body is flat — no {"email_campaign": ...} wrapper. + if _, ok := reqBody["email_campaign"]; ok { + t.Errorf("did not expect an 'email_campaign' wrapper, got %v", reqBody) + } + if reqBody["name"] != "Spring Sale" { + t.Errorf("unexpected name: %v", reqBody["name"]) + } + if reqBody["mailsend_domain_id"] != "d2313359-acb4-4b87-bce6-f5774f6a1e37" { + t.Errorf("unexpected mailsend_domain_id: %v", reqBody["mailsend_domain_id"]) + } + if reqBody["from_local_part"] != "news" { + t.Errorf("unexpected from_local_part: %v", reqBody["from_local_part"]) + } + if reqBody["delivery_mode"] != "gradual" { + t.Errorf("unexpected delivery_mode: %v", reqBody["delivery_mode"]) + } + templateAttrs, ok := reqBody["template_attributes"].(map[string]interface{}) + if !ok || templateAttrs["subject"] != "Spring is here" { + t.Errorf("unexpected template_attributes: %v", reqBody["template_attributes"]) + } + if templateAttrs["body_html"] != "

Hi

" { + t.Errorf("unexpected body_html: %v", templateAttrs["body_html"]) + } + replyTo, ok := reqBody["reply_to"].(map[string]interface{}) + if !ok || replyTo["local_part"] != "support" || replyTo["domain"] != "acme.com" { + t.Errorf("unexpected reply_to: %v", reqBody["reply_to"]) + } + deliveryOptions, ok := reqBody["delivery_options"].(map[string]interface{}) + if !ok || deliveryOptions["emails_per_hour"] != float64(1000) { + t.Errorf("unexpected delivery_options: %v", reqBody["delivery_options"]) + } + if lists, ok := reqBody["contact_list_ids"].([]interface{}); !ok || len(lists) != 2 { + t.Errorf("expected 2 contact_list_ids, got %v", reqBody["contact_list_ids"]) + } + if segments, ok := reqBody["contact_segment_ids"].([]interface{}); !ok || len(segments) != 1 { + t.Errorf("expected 1 contact_segment_id, got %v", reqBody["contact_segment_ids"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{"data": sampleCampaign()}) + }) + defer cleanup() + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{ + "create", + "--name", "Spring Sale", + "--mailsend-domain-id", "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "--from-local-part", "news", + "--subject", "Spring is here", + "--body-html", "

Hi

", + "--reply-to-local-part", "support", + "--reply-to-domain", "acme.com", + "--delivery-mode", "gradual", + "--emails-per-hour", "1000", + "--contact-list-ids", "55,56", + "--contact-segment-ids", "12", + }) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "Spring Sale") { + t.Errorf("expected output to contain 'Spring Sale', got:\n%s", output) + } + if !strings.Contains(output, "draft") { + t.Errorf("expected output to contain 'draft', got:\n%s", output) + } +} + +func TestEmailCampaignsCreateMissingName(t *testing.T) { + f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + buf := &bytes.Buffer{} + f.IOStreams.Out = buf + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{ + "create", + "--mailsend-domain-id", "d2313359-acb4-4b87-bce6-f5774f6a1e37", + "--from-local-part", "news", + "--subject", "Spring is here", + }) + cmd.SetOut(buf) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when --name is missing") + } + if !strings.Contains(err.Error(), "--name is required") { + t.Errorf("expected '--name is required' error, got: %v", err) + } +} + +func TestEmailCampaignsUpdate(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.URL.Path != "/api/email_campaigns/4567" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + body, _ := io.ReadAll(r.Body) + var reqBody map[string]interface{} + json.Unmarshal(body, &reqBody) + + if reqBody["name"] != "Spring Sale (updated)" { + t.Errorf("unexpected name: %v", reqBody["name"]) + } + templateAttrs, ok := reqBody["template_attributes"].(map[string]interface{}) + if !ok || templateAttrs["subject"] != "New subject" { + t.Errorf("unexpected template_attributes: %v", reqBody["template_attributes"]) + } + // PATCH semantics: unchanged flags stay out of the body. + if _, ok := templateAttrs["body_html"]; ok { + t.Errorf("did not expect body_html to be set, got %v", templateAttrs["body_html"]) + } + if _, ok := reqBody["mailsend_domain_id"]; ok { + t.Errorf("did not expect mailsend_domain_id to be set, got %v", reqBody["mailsend_domain_id"]) + } + if _, ok := reqBody["reply_to"]; ok { + t.Errorf("did not expect reply_to to be set, got %v", reqBody["reply_to"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"data": sampleCampaign()}) + }) + defer cleanup() + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{ + "update", + "--id", "4567", + "--name", "Spring Sale (updated)", + "--subject", "New subject", + }) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Spring Sale") { + t.Errorf("expected output to contain 'Spring Sale', got:\n%s", buf.String()) + } +} + +func TestEmailCampaignsDelete(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/email_campaigns/4567" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + }) + defer cleanup() + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{"delete", "--id", "4567"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Email campaign deleted successfully") { + t.Errorf("expected success message, got:\n%s", buf.String()) + } +} + +func testLifecycleAction(t *testing.T, action, resultState string) { + t.Helper() + + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/email_campaigns/4567/"+action { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + campaign := sampleCampaign() + campaign["current_state"] = resultState + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"data": campaign}) + }) + defer cleanup() + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{action, "--id", "4567"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), resultState) { + t.Errorf("expected output to contain %q, got:\n%s", resultState, buf.String()) + } +} + +func TestEmailCampaignsStart(t *testing.T) { + testLifecycleAction(t, "start", "started") +} + +func TestEmailCampaignsCancel(t *testing.T) { + testLifecycleAction(t, "cancel", "draft") +} + +func TestEmailCampaignsTerminate(t *testing.T) { + testLifecycleAction(t, "terminate", "terminating") +} + +func TestEmailCampaignsReset(t *testing.T) { + testLifecycleAction(t, "reset", "draft") +} + +func TestEmailCampaignsSchedule(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/email_campaigns/4567/schedule" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + body, _ := io.ReadAll(r.Body) + var reqBody map[string]interface{} + json.Unmarshal(body, &reqBody) + if reqBody["datetime"] != "2026-06-01T09:00:00.000Z" { + t.Errorf("unexpected datetime: %v", reqBody["datetime"]) + } + + campaign := sampleCampaign() + campaign["current_state"] = "scheduled" + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"data": campaign}) + }) + defer cleanup() + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{"schedule", "--id", "4567", "--datetime", "2026-06-01T09:00:00.000Z"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "scheduled") { + t.Errorf("expected output to contain 'scheduled', got:\n%s", buf.String()) + } +} + +func TestEmailCampaignsScheduleMissingDatetime(t *testing.T) { + f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + buf := &bytes.Buffer{} + f.IOStreams.Out = buf + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{"schedule", "--id", "4567"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when --datetime is missing") + } + if !strings.Contains(err.Error(), "--datetime is required") { + t.Errorf("expected '--datetime is required' error, got: %v", err) + } +} + +func TestEmailCampaignsStats(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/email_campaigns/4567/stats" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.URL.Query().Get("start_date") != "2026-05-01" { + t.Errorf("expected start_date=2026-05-01, got %q", r.URL.Query().Get("start_date")) + } + if r.URL.Query().Get("end_date") != "2026-05-31" { + t.Errorf("expected end_date=2026-05-31, got %q", r.URL.Query().Get("end_date")) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "delivery_count": 1450, + "open_count": 820, + "click_count": 310, + "bounce_count": 30, + "unsubscription_count": 12, + "sent_count": 1500, + "spam_count": 5, + "message_count": 1500, + "reject_count": 20, + "delivery_rate": 0.9667, + "open_rate": 0.5655, + "click_rate": 0.2138, + "bounce_rate": 0.02, + "spam_rate": 0.0033, + "unsubscription_rate": 0.0083, + }, + }) + }) + defer cleanup() + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{"stats", "--id", "4567", "--start-date", "2026-05-01", "--end-date", "2026-05-31"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "1450") { + t.Errorf("expected output to contain delivery count, got:\n%s", output) + } + if !strings.Contains(output, "0.9667") { + t.Errorf("expected output to contain delivery rate, got:\n%s", output) + } +} diff --git a/internal/commands/emailcampaigns/get.go b/internal/commands/emailcampaigns/get.go new file mode 100644 index 0000000..01abda7 --- /dev/null +++ b/internal/commands/emailcampaigns/get.go @@ -0,0 +1,41 @@ +package emailcampaigns + +import ( + "context" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +func NewCmdGet(f *cmdutil.Factory) *cobra.Command { + var campaignID string + + cmd := &cobra.Command{ + Use: "get", + Short: "Get an email campaign", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("id", campaignID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + var resp campaignResponse + if err := c.Get(context.Background(), client.BaseGeneral, campaignPath(campaignID), nil, &resp); err != nil { + return err + } + + format := cmdutil.GetOutputFormat() + return output.Print(f.IOStreams.Out, format, resp.Data, campaignColumns) + }, + } + + cmd.Flags().StringVar(&campaignID, "id", "", "Email campaign ID (required)") + + return cmd +} diff --git a/internal/commands/emailcampaigns/list.go b/internal/commands/emailcampaigns/list.go new file mode 100644 index 0000000..08c4e0f --- /dev/null +++ b/internal/commands/emailcampaigns/list.go @@ -0,0 +1,126 @@ +package emailcampaigns + +import ( + "context" + "fmt" + "net/url" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +type ReplyTo struct { + DisplayName string `json:"display_name,omitempty"` + LocalPart string `json:"local_part,omitempty"` + Domain string `json:"domain,omitempty"` +} + +type RecipientError struct { + Message string `json:"message"` + RcptIndex int `json:"rcpt_index"` +} + +type StateMetadata struct { + Reason *string `json:"reason,omitempty"` + Error *string `json:"error,omitempty"` + Errors []RecipientError `json:"errors,omitempty"` + ScheduledAt *string `json:"scheduled_at,omitempty"` +} + +type DeliveryOptions struct { + EmailsPerHour *int64 `json:"emails_per_hour,omitempty"` +} + +type Template struct { + ID int64 `json:"id"` + Subject string `json:"subject"` + MergeTags []string `json:"merge_tags,omitempty"` + BodyHTML *string `json:"body_html,omitempty"` + BodyText *string `json:"body_text,omitempty"` +} + +type EmailCampaign struct { + ID int64 `json:"id"` + Type string `json:"type"` + MailsendDomainID string `json:"mailsend_domain_id"` + MailsendDomainName string `json:"mailsend_domain_name"` + Name string `json:"name"` + FromLocalPart string `json:"from_local_part"` + FromDisplayName string `json:"from_display_name"` + ReplyTo *ReplyTo `json:"reply_to,omitempty"` + CurrentState string `json:"current_state"` + CurrentStateMetadata *StateMetadata `json:"current_state_metadata,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + LastStartedAt *string `json:"last_started_at,omitempty"` + LastStartedAtDate *string `json:"last_started_at_date,omitempty"` + RecipientTotalCount *int64 `json:"recipient_total_count,omitempty"` + ContactListIDs []int64 `json:"contact_list_ids,omitempty"` + ContactSegmentIDs []int64 `json:"contact_segment_ids,omitempty"` + DeliveryMode string `json:"delivery_mode"` + DeliveryOptions *DeliveryOptions `json:"delivery_options,omitempty"` + Template *Template `json:"template,omitempty"` +} + +type campaignListResponse struct { + Data []EmailCampaign `json:"data"` +} + +type campaignResponse struct { + Data EmailCampaign `json:"data"` +} + +var campaignColumns = []output.Column{ + {Header: "ID", Field: "id"}, + {Header: "NAME", Field: "name"}, + {Header: "STATE", Field: "current_state"}, + {Header: "DOMAIN", Field: "mailsend_domain_name"}, + {Header: "RECIPIENTS", Field: "recipient_total_count"}, + {Header: "CREATED", Field: "created_at"}, +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + perPage int + search string + token int + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List all email campaigns", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := f.NewClient() + if err != nil { + return err + } + + query := url.Values{} + if cmd.Flags().Changed("per-page") { + query.Set("per_page", fmt.Sprintf("%d", perPage)) + } + if search != "" { + query.Set("search", search) + } + if cmd.Flags().Changed("token") { + query.Set("token", fmt.Sprintf("%d", token)) + } + + var resp campaignListResponse + if err := c.Get(context.Background(), client.BaseGeneral, basePath, query, &resp); err != nil { + return err + } + + format := cmdutil.GetOutputFormat() + return output.Print(f.IOStreams.Out, format, resp.Data, campaignColumns) + }, + } + + cmd.Flags().IntVar(&perPage, "per-page", 0, "Number of campaigns per page (default 50, max 100)") + cmd.Flags().StringVar(&search, "search", "", "Filter campaigns by name") + cmd.Flags().IntVar(&token, "token", 0, "Page number to retrieve (page-token pagination)") + + return cmd +} diff --git a/internal/commands/emailcampaigns/reset.go b/internal/commands/emailcampaigns/reset.go new file mode 100644 index 0000000..933b5b5 --- /dev/null +++ b/internal/commands/emailcampaigns/reset.go @@ -0,0 +1,10 @@ +package emailcampaigns + +import ( + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdReset(f *cmdutil.Factory) *cobra.Command { + return newLifecycleCmd(f, "reset", "Reset a scheduled email campaign to draft") +} diff --git a/internal/commands/emailcampaigns/schedule.go b/internal/commands/emailcampaigns/schedule.go new file mode 100644 index 0000000..94ab237 --- /dev/null +++ b/internal/commands/emailcampaigns/schedule.go @@ -0,0 +1,50 @@ +package emailcampaigns + +import ( + "context" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +func NewCmdSchedule(f *cmdutil.Factory) *cobra.Command { + var ( + campaignID string + datetime string + ) + + cmd := &cobra.Command{ + Use: "schedule", + Short: "Schedule a draft email campaign", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("id", campaignID); err != nil { + return err + } + if err := cmdutil.RequireFlag("datetime", datetime); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + body := map[string]interface{}{"datetime": datetime} + + var resp campaignResponse + if err := c.Post(context.Background(), client.BaseGeneral, campaignPath(campaignID, "schedule"), body, &resp); err != nil { + return err + } + + format := cmdutil.GetOutputFormat() + return output.Print(f.IOStreams.Out, format, resp.Data, campaignColumns) + }, + } + + cmd.Flags().StringVar(&campaignID, "id", "", "Email campaign ID (required)") + cmd.Flags().StringVar(&datetime, "datetime", "", "When to send the campaign, ISO 8601 (required)") + + return cmd +} diff --git a/internal/commands/emailcampaigns/start.go b/internal/commands/emailcampaigns/start.go new file mode 100644 index 0000000..a6873ac --- /dev/null +++ b/internal/commands/emailcampaigns/start.go @@ -0,0 +1,47 @@ +package emailcampaigns + +import ( + "context" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +// newLifecycleCmd builds a body-less lifecycle action subcommand +// (POST /api/email_campaigns/{id}/{action}) shared by start, cancel, terminate, and reset. +func newLifecycleCmd(f *cmdutil.Factory, action, short string) *cobra.Command { + var campaignID string + + cmd := &cobra.Command{ + Use: action, + Short: short, + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("id", campaignID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + var resp campaignResponse + if err := c.Post(context.Background(), client.BaseGeneral, campaignPath(campaignID, action), nil, &resp); err != nil { + return err + } + + format := cmdutil.GetOutputFormat() + return output.Print(f.IOStreams.Out, format, resp.Data, campaignColumns) + }, + } + + cmd.Flags().StringVar(&campaignID, "id", "", "Email campaign ID (required)") + + return cmd +} + +func NewCmdStart(f *cmdutil.Factory) *cobra.Command { + return newLifecycleCmd(f, "start", "Start a draft email campaign") +} diff --git a/internal/commands/emailcampaigns/stats.go b/internal/commands/emailcampaigns/stats.go new file mode 100644 index 0000000..a107698 --- /dev/null +++ b/internal/commands/emailcampaigns/stats.go @@ -0,0 +1,96 @@ +package emailcampaigns + +import ( + "context" + "net/url" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +type CampaignStats struct { + DeliveryCount int `json:"delivery_count"` + OpenCount int `json:"open_count"` + ClickCount int `json:"click_count"` + BounceCount int `json:"bounce_count"` + UnsubscriptionCount int `json:"unsubscription_count"` + SentCount int `json:"sent_count"` + SpamCount int `json:"spam_count"` + MessageCount int `json:"message_count"` + RejectCount int `json:"reject_count"` + DeliveryRate float64 `json:"delivery_rate"` + OpenRate float64 `json:"open_rate"` + ClickRate float64 `json:"click_rate"` + BounceRate float64 `json:"bounce_rate"` + SpamRate float64 `json:"spam_rate"` + UnsubscriptionRate float64 `json:"unsubscription_rate"` +} + +type campaignStatsResponse struct { + Data CampaignStats `json:"data"` +} + +var campaignStatsColumns = []output.Column{ + {Header: "SENT", Field: "sent_count"}, + {Header: "DELIVERED", Field: "delivery_count"}, + {Header: "OPENS", Field: "open_count"}, + {Header: "CLICKS", Field: "click_count"}, + {Header: "BOUNCES", Field: "bounce_count"}, + {Header: "SPAM", Field: "spam_count"}, + {Header: "UNSUBSCRIBES", Field: "unsubscription_count"}, + {Header: "MESSAGES", Field: "message_count"}, + {Header: "REJECTS", Field: "reject_count"}, + {Header: "DELIVERY RATE", Field: "delivery_rate"}, + {Header: "OPEN RATE", Field: "open_rate"}, + {Header: "CLICK RATE", Field: "click_rate"}, + {Header: "BOUNCE RATE", Field: "bounce_rate"}, + {Header: "SPAM RATE", Field: "spam_rate"}, + {Header: "UNSUBSCRIPTION RATE", Field: "unsubscription_rate"}, +} + +func NewCmdStats(f *cmdutil.Factory) *cobra.Command { + var ( + campaignID string + startDate string + endDate string + ) + + cmd := &cobra.Command{ + Use: "stats", + Short: "Get email campaign statistics", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("id", campaignID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + query := url.Values{} + if startDate != "" { + query.Set("start_date", startDate) + } + if endDate != "" { + query.Set("end_date", endDate) + } + + var resp campaignStatsResponse + if err := c.Get(context.Background(), client.BaseGeneral, campaignPath(campaignID, "stats"), query, &resp); err != nil { + return err + } + + format := cmdutil.GetOutputFormat() + return output.Print(f.IOStreams.Out, format, resp.Data, campaignStatsColumns) + }, + } + + cmd.Flags().StringVar(&campaignID, "id", "", "Email campaign ID (required)") + cmd.Flags().StringVar(&startDate, "start-date", "", "Start of the aggregation window (YYYY-MM-DD)") + cmd.Flags().StringVar(&endDate, "end-date", "", "End of the aggregation window (YYYY-MM-DD)") + + return cmd +} diff --git a/internal/commands/emailcampaigns/terminate.go b/internal/commands/emailcampaigns/terminate.go new file mode 100644 index 0000000..76ce46b --- /dev/null +++ b/internal/commands/emailcampaigns/terminate.go @@ -0,0 +1,10 @@ +package emailcampaigns + +import ( + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdTerminate(f *cmdutil.Factory) *cobra.Command { + return newLifecycleCmd(f, "terminate", "Terminate a sending email campaign") +} diff --git a/internal/commands/emailcampaigns/update.go b/internal/commands/emailcampaigns/update.go new file mode 100644 index 0000000..fd41619 --- /dev/null +++ b/internal/commands/emailcampaigns/update.go @@ -0,0 +1,45 @@ +package emailcampaigns + +import ( + "context" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + var campaignID string + attrs := &campaignAttrs{} + + cmd := &cobra.Command{ + Use: "update", + Short: "Update a draft email campaign", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("id", campaignID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + body := buildAttributesBody(cmd, attrs) + + var resp campaignResponse + if err := c.Patch(context.Background(), client.BaseGeneral, campaignPath(campaignID), body, &resp); err != nil { + return err + } + + format := cmdutil.GetOutputFormat() + return output.Print(f.IOStreams.Out, format, resp.Data, campaignColumns) + }, + } + + cmd.Flags().StringVar(&campaignID, "id", "", "Email campaign ID (required)") + addAttributeFlags(cmd, attrs) + + return cmd +} From 06ed4f54da2c3b68f16c9155fa20389c36f050a3 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Fri, 31 Jul 2026 12:53:11 +0200 Subject: [PATCH 2/3] MT-22401: Address code review feedback Decisions: - Bind --per-page default to 50 (matches API default per OpenAPI spec); Changed check kept so the param is only sent when set explicitly - README schedule example now uses an obviously-future timestamp (2030-01-01) - No timeout added for context.Background() calls: matches repo convention (no command uses timeouts); a CLI-wide timeout belongs in a separate change - Empty updates not rejected: templates/webhooks update commands also send empty partial PATCH; consistency kept --- README.md | 2 +- internal/commands/emailcampaigns/list.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a6e392..fdc5333 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ mailtrap contact-fields create --name "Company" --data-type text --merge-tag "{{ mailtrap email-campaigns list --search spring mailtrap email-campaigns create --name "Spring Sale" --mailsend-domain-id "d2313359-acb4-4b87-bce6-f5774f6a1e37" --from-local-part news --subject "Spring is here" mailtrap email-campaigns update --id 4567 --body-html '

Hi {{first_name}}!

Unsubscribe' --contact-list-ids 55,56 -mailtrap email-campaigns schedule --id 4567 --datetime "2026-06-01T09:00:00Z" +mailtrap email-campaigns schedule --id 4567 --datetime "2030-01-01T09:00:00Z" mailtrap email-campaigns stats --id 4567 --start-date 2026-05-01 --end-date 2026-05-31 # Sandboxes & projects diff --git a/internal/commands/emailcampaigns/list.go b/internal/commands/emailcampaigns/list.go index 08c4e0f..9b9beb9 100644 --- a/internal/commands/emailcampaigns/list.go +++ b/internal/commands/emailcampaigns/list.go @@ -118,7 +118,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().IntVar(&perPage, "per-page", 0, "Number of campaigns per page (default 50, max 100)") + cmd.Flags().IntVar(&perPage, "per-page", 50, "Number of campaigns per page (max 100)") cmd.Flags().StringVar(&search, "search", "", "Filter campaigns by name") cmd.Flags().IntVar(&token, "token", 0, "Page number to retrieve (page-token pagination)") From 507c382101c90ab93c88893e365e09e4ed1d2eff Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Fri, 31 Jul 2026 13:40:54 +0200 Subject: [PATCH 3/3] MT-22401: Add flags to clear the campaign audience on update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decisions: - The API treats contact_list_ids/contact_segment_ids as the full set, so an explicit [] clears them — pflag cannot parse an empty slice value, so dedicated --clear-contact-lists/--clear-contact-segments booleans express it; mutually exclusive with the ids flags --- README.md | 1 + .../emailcampaigns/emailcampaigns_test.go | 55 +++++++++++++++++++ internal/commands/emailcampaigns/update.go | 14 +++++ 3 files changed, 70 insertions(+) diff --git a/README.md b/README.md index fdc5333..1852be9 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ mailtrap contact-fields create --name "Company" --data-type text --merge-tag "{{ mailtrap email-campaigns list --search spring mailtrap email-campaigns create --name "Spring Sale" --mailsend-domain-id "d2313359-acb4-4b87-bce6-f5774f6a1e37" --from-local-part news --subject "Spring is here" mailtrap email-campaigns update --id 4567 --body-html '

Hi {{first_name}}!

Unsubscribe' --contact-list-ids 55,56 +mailtrap email-campaigns update --id 4567 --clear-contact-lists mailtrap email-campaigns schedule --id 4567 --datetime "2030-01-01T09:00:00Z" mailtrap email-campaigns stats --id 4567 --start-date 2026-05-01 --end-date 2026-05-31 diff --git a/internal/commands/emailcampaigns/emailcampaigns_test.go b/internal/commands/emailcampaigns/emailcampaigns_test.go index 49774c5..574a22e 100644 --- a/internal/commands/emailcampaigns/emailcampaigns_test.go +++ b/internal/commands/emailcampaigns/emailcampaigns_test.go @@ -365,6 +365,61 @@ func TestEmailCampaignsUpdate(t *testing.T) { } } +func TestEmailCampaignsUpdateClearAudience(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var reqBody map[string]interface{} + json.Unmarshal(body, &reqBody) + + // An explicit empty array clears the audience; the API treats the ids + // as the full set of included lists. + lists, ok := reqBody["contact_list_ids"].([]interface{}) + if !ok || len(lists) != 0 { + t.Errorf("expected contact_list_ids to be [], got %v", reqBody["contact_list_ids"]) + } + if _, ok := reqBody["contact_segment_ids"]; ok { + t.Errorf("did not expect contact_segment_ids to be set, got %v", reqBody["contact_segment_ids"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"data": sampleCampaign()}) + }) + defer cleanup() + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{ + "update", + "--id", "4567", + "--clear-contact-lists", + }) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestEmailCampaignsUpdateClearConflictsWithIDs(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + t.Error("request should not be sent when flags conflict") + }) + defer cleanup() + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{ + "update", + "--id", "4567", + "--contact-list-ids", "55,56", + "--clear-contact-lists", + }) + cmd.SetOut(buf) + cmd.SetErr(buf) + + if err := cmd.Execute(); err == nil { + t.Fatal("expected an error for mutually exclusive flags") + } +} + func TestEmailCampaignsDelete(t *testing.T) { f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { diff --git a/internal/commands/emailcampaigns/update.go b/internal/commands/emailcampaigns/update.go index fd41619..0bcaad3 100644 --- a/internal/commands/emailcampaigns/update.go +++ b/internal/commands/emailcampaigns/update.go @@ -11,6 +11,7 @@ import ( func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { var campaignID string + var clearContactLists, clearContactSegments bool attrs := &campaignAttrs{} cmd := &cobra.Command{ @@ -27,6 +28,15 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { } body := buildAttributesBody(cmd, attrs) + // The API treats audience ids as the full set, so an explicit `[]` + // clears them — inexpressible via the ids flags (pflag rejects an + // empty slice value), hence the dedicated flags. + if clearContactLists { + body["contact_list_ids"] = []int64{} + } + if clearContactSegments { + body["contact_segment_ids"] = []int64{} + } var resp campaignResponse if err := c.Patch(context.Background(), client.BaseGeneral, campaignPath(campaignID), body, &resp); err != nil { @@ -40,6 +50,10 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&campaignID, "id", "", "Email campaign ID (required)") addAttributeFlags(cmd, attrs) + cmd.Flags().BoolVar(&clearContactLists, "clear-contact-lists", false, "Remove all contact lists from the audience") + cmd.Flags().BoolVar(&clearContactSegments, "clear-contact-segments", false, "Remove all contact segments from the audience") + cmd.MarkFlagsMutuallyExclusive("contact-list-ids", "clear-contact-lists") + cmd.MarkFlagsMutuallyExclusive("contact-segment-ids", "clear-contact-segments") return cmd }