diff --git a/README.md b/README.md
index ad7ebd6..1852be9 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,14 @@ 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 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
+
# Sandboxes & projects
mailtrap projects list
mailtrap sandboxes list
@@ -155,6 +163,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..574a22e
--- /dev/null
+++ b/internal/commands/emailcampaigns/emailcampaigns_test.go
@@ -0,0 +1,604 @@
+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 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 {
+ 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..9b9beb9
--- /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", 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)")
+
+ 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..0bcaad3
--- /dev/null
+++ b/internal/commands/emailcampaigns/update.go
@@ -0,0 +1,59 @@
+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
+ var clearContactLists, clearContactSegments bool
+ 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)
+ // 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 {
+ 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)
+ 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
+}