diff --git a/README.md b/README.md index 3901f88..babcfbd 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,16 @@ mailtrap company-info create --domain-id 123 --name "Your Company" --address "12 --city "San Francisco" --country US --zip-code 94105 --website-url "https://yourdomain.com" mailtrap company-info update --domain-id 123 --city "New York" --zip-code 10001 +# Suppressions +mailtrap suppressions list --email "bounced@example.com" +mailtrap suppressions create --email "bounced@example.com" --domain-id 123 --sending-stream transactional +mailtrap suppressions delete --id "2fe148b8-b019-431f-ab3f-107663fdf868" + +# Tracking opt-outs +mailtrap tracking-opt-outs list +mailtrap tracking-opt-outs create --email "no-tracking@example.com" --domain-id 123 +mailtrap tracking-opt-outs delete --id "0198f1c4-0c0f-7a1c-8b0e-3f5d2a1b4c6d" + # Templates mailtrap templates list mailtrap templates create --name "Welcome" --subject "Hello {{name}}" --body-html '

Hi!

' @@ -171,7 +181,8 @@ mailtrap domains list --output text | **Domains** | `domains list`, `domains get`, `domains create`, `domains update`, `domains delete`, `domains send-setup-instructions` | | **Company Info** | `company-info get`, `company-info create`, `company-info update` | | **Templates** | `templates list`, `templates get`, `templates create`, `templates update`, `templates delete` | -| **Suppressions** | `suppressions list`, `suppressions delete` | +| **Suppressions** | `suppressions list`, `suppressions create`, `suppressions delete` | +| **Tracking Opt-outs** | `tracking-opt-outs list`, `tracking-opt-outs create`, `tracking-opt-outs delete` | | **Webhooks** | `webhooks list`, `webhooks get`, `webhooks create`, `webhooks update`, `webhooks delete` | | **Stats** | `stats get`, `stats by-domain`, `stats by-category`, `stats by-esp`, `stats by-date` | | **Email Logs** | `email-logs list`, `email-logs get` | diff --git a/cmd/root.go b/cmd/root.go index 6194d28..c4646e6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -31,6 +31,7 @@ import ( "github.com/mailtrap/mailtrap-cli/internal/commands/suppressions" "github.com/mailtrap/mailtrap-cli/internal/commands/templates" "github.com/mailtrap/mailtrap-cli/internal/commands/tokens" + "github.com/mailtrap/mailtrap-cli/internal/commands/trackingoptouts" "github.com/mailtrap/mailtrap-cli/internal/commands/webhooks" ) @@ -59,6 +60,7 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(domains.NewCmdDomains(f)) cmd.AddCommand(companyinfo.NewCmdCompanyInfo(f)) cmd.AddCommand(suppressions.NewCmdSuppressions(f)) + cmd.AddCommand(trackingoptouts.NewCmdTrackingOptOuts(f)) cmd.AddCommand(stats.NewCmdStats(f)) cmd.AddCommand(templates.NewCmdTemplates(f)) cmd.AddCommand(email_logs.NewCmdEmailLogs(f)) diff --git a/internal/commands/suppressions/create.go b/internal/commands/suppressions/create.go new file mode 100644 index 0000000..6d9b6e8 --- /dev/null +++ b/internal/commands/suppressions/create.go @@ -0,0 +1,78 @@ +package suppressions + +import ( + "context" + "fmt" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/config" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +type suppressionResponse struct { + Data Suppression `json:"data"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + email string + domainID int64 + sendingStream string + suppressType string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Add an email address to the suppression list", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("email", email); err != nil { + return err + } + if !cmd.Flags().Changed("domain-id") { + return fmt.Errorf("--domain-id is required") + } + if domainID <= 0 { + return fmt.Errorf("--domain-id must be greater than 0") + } + if err := cmdutil.RequireFlag("sending-stream", sendingStream); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + _, err = config.RequireAccountID() + if err != nil { + return err + } + + body := map[string]interface{}{ + "email": email, + "domain_id": domainID, + "sending_stream": sendingStream, + } + if cmd.Flags().Changed("type") { + body["type"] = suppressType + } + + var resp suppressionResponse + if err := c.Post(context.Background(), client.BaseGeneral, cmdutil.AccountPath("suppressions"), body, &resp); err != nil { + return err + } + + format := cmdutil.GetOutputFormat() + return output.Print(f.IOStreams.Out, format, resp.Data, suppressionColumns) + }, + } + + cmd.Flags().StringVar(&email, "email", "", "Email address to suppress (required)") + cmd.Flags().Int64Var(&domainID, "domain-id", 0, "ID of the sending domain the suppression applies to (required)") + cmd.Flags().StringVar(&sendingStream, "sending-stream", "", "Sending stream to suppress for: transactional, bulk (required)") + cmd.Flags().StringVar(&suppressType, "type", "", "Suppression reason: hard bounce, spam complaint, unsubscription, manual import") + + return cmd +} diff --git a/internal/commands/suppressions/list.go b/internal/commands/suppressions/list.go index 9bfd09e..aab5a90 100644 --- a/internal/commands/suppressions/list.go +++ b/internal/commands/suppressions/list.go @@ -12,16 +12,20 @@ import ( ) type Suppression struct { - ID string `json:"id"` - Email string `json:"email"` - Reason string `json:"reason"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Email string `json:"email"` + Type string `json:"type"` + SendingStream string `json:"sending_stream,omitempty"` + DomainName string `json:"domain_name,omitempty"` + CreatedAt string `json:"created_at"` } var suppressionColumns = []output.Column{ {Header: "ID", Field: "id"}, {Header: "EMAIL", Field: "email"}, - {Header: "REASON", Field: "reason"}, + {Header: "TYPE", Field: "type"}, + {Header: "SENDING_STREAM", Field: "sending_stream"}, + {Header: "DOMAIN_NAME", Field: "domain_name"}, {Header: "CREATED_AT", Field: "created_at"}, } @@ -29,6 +33,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { var email string var startTime string var endTime string + var lastID string cmd := &cobra.Command{ Use: "list", @@ -56,6 +61,9 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { if endTime != "" { query.Set("end_time", endTime) } + if lastID != "" { + query.Set("last_id", lastID) + } var suppressions []Suppression if err := c.Get(context.Background(), client.BaseGeneral, path, query, &suppressions); err != nil { @@ -70,6 +78,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&email, "email", "", "Filter by email address") cmd.Flags().StringVar(&startTime, "start-time", "", "Filter by start time") cmd.Flags().StringVar(&endTime, "end-time", "", "Filter by end time") + cmd.Flags().StringVar(&lastID, "last-id", "", "Pagination cursor (id of the last record from the previous response)") return cmd } diff --git a/internal/commands/suppressions/suppressions.go b/internal/commands/suppressions/suppressions.go index 9213570..e9886b3 100644 --- a/internal/commands/suppressions/suppressions.go +++ b/internal/commands/suppressions/suppressions.go @@ -12,6 +12,7 @@ func NewCmdSuppressions(f *cmdutil.Factory) *cobra.Command { } cmd.AddCommand(NewCmdList(f)) + cmd.AddCommand(NewCmdCreate(f)) cmd.AddCommand(NewCmdDelete(f)) return cmd diff --git a/internal/commands/suppressions/suppressions_test.go b/internal/commands/suppressions/suppressions_test.go index ee9e65f..bc1b76c 100644 --- a/internal/commands/suppressions/suppressions_test.go +++ b/internal/commands/suppressions/suppressions_test.go @@ -57,7 +57,7 @@ func TestSuppressionsList(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]map[string]interface{}{ - {"id": "uuid-1", "email": "test@example.com", "reason": "hard_bounce", "created_at": "2024-01-01"}, + {"id": "uuid-1", "email": "test@example.com", "type": "hard bounce", "sending_stream": "transactional", "domain_name": "example.com", "created_at": "2024-01-01"}, }) }) defer cleanup() @@ -75,8 +75,8 @@ func TestSuppressionsList(t *testing.T) { if !strings.Contains(output, "test@example.com") { t.Errorf("expected output to contain 'test@example.com', got:\n%s", output) } - if !strings.Contains(output, "hard_bounce") { - t.Errorf("expected output to contain 'hard_bounce', got:\n%s", output) + if !strings.Contains(output, "hard bounce") { + t.Errorf("expected output to contain 'hard bounce', got:\n%s", output) } } @@ -84,7 +84,7 @@ func TestSuppressionsListJSON(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{}{ - {"id": "uuid-1", "email": "test@example.com", "reason": "hard_bounce", "created_at": "2024-01-01"}, + {"id": "uuid-1", "email": "test@example.com", "type": "hard bounce", "sending_stream": "transactional", "domain_name": "example.com", "created_at": "2024-01-01"}, }) }) defer cleanup() @@ -126,7 +126,7 @@ func TestSuppressionsListWithFilters(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]map[string]interface{}{ - {"id": "uuid-1", "email": "test@example.com", "reason": "hard_bounce", "created_at": "2024-01-01"}, + {"id": "uuid-1", "email": "test@example.com", "type": "hard bounce", "sending_stream": "transactional", "domain_name": "example.com", "created_at": "2024-01-01"}, }) }) defer cleanup() @@ -189,3 +189,181 @@ func TestSuppressionsDeleteMissingID(t *testing.T) { t.Errorf("expected error to contain '--id is required', got: %v", err) } } + +func TestSuppressionsCreate(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/accounts/123/suppressions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("could not decode request body: %v", err) + } + if body["email"] != "test@example.com" { + t.Errorf("expected email 'test@example.com', got %v", body["email"]) + } + if body["domain_id"] != float64(4321) { + t.Errorf("expected domain_id 4321, got %v", body["domain_id"]) + } + if body["sending_stream"] != "transactional" { + t.Errorf("expected sending_stream 'transactional', got %v", body["sending_stream"]) + } + if _, ok := body["type"]; ok { + t.Errorf("expected type to be omitted, got %v", body["type"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "id": "uuid-1", + "email": "test@example.com", + "type": "manual import", + "sending_stream": "transactional", + "domain_name": "example.com", + "created_at": "2024-01-01", + }, + }) + }) + defer cleanup() + + cmd := suppressions.NewCmdSuppressions(f) + cmd.SetArgs([]string{ + "create", + "--email", "test@example.com", + "--domain-id", "4321", + "--sending-stream", "transactional", + }) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "manual import") { + t.Errorf("expected output to contain 'manual import', got:\n%s", output) + } +} + +func TestSuppressionsCreateSendsType(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("could not decode request body: %v", err) + } + if body["type"] != "manual import" { + t.Errorf("expected type 'manual import', got %v", body["type"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{"id": "uuid-1", "email": "test@example.com"}, + }) + }) + defer cleanup() + + cmd := suppressions.NewCmdSuppressions(f) + cmd.SetArgs([]string{ + "create", + "--email", "test@example.com", + "--domain-id", "4321", + "--sending-stream", "transactional", + "--type", "manual import", + }) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSuppressionsCreateMissingFlags(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + { + name: "missing email", + args: []string{"create", "--domain-id", "4321", "--sending-stream", "transactional"}, + want: "--email is required", + }, + { + name: "missing domain id", + args: []string{"create", "--email", "test@example.com", "--sending-stream", "transactional"}, + want: "--domain-id is required", + }, + { + name: "missing sending stream", + args: []string{"create", "--email", "test@example.com", "--domain-id", "4321"}, + want: "--sending-stream is required", + }, + { + name: "non-positive domain id", + args: []string{"create", "--email", "test@example.com", "--domain-id", "0", "--sending-stream", "transactional"}, + want: "--domain-id must be greater than 0", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + t.Error("expected no request to be sent") + }) + defer cleanup() + + cmd := suppressions.NewCmdSuppressions(f) + cmd.SetArgs(tc.args) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("expected error to contain %q, got: %v", tc.want, err) + } + }) + } +} + +func TestSuppressionsListPagination(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + if got := query.Get("start_time"); got != "2024-01-01" { + t.Errorf("expected start_time '2024-01-01', got %q", got) + } + if got := query.Get("end_time"); got != "2024-01-31" { + t.Errorf("expected end_time '2024-01-31', got %q", got) + } + if got := query.Get("last_id"); got != "uuid-1" { + t.Errorf("expected last_id 'uuid-1', got %q", got) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": "uuid-2", "email": "next@example.com"}, + }) + }) + defer cleanup() + + cmd := suppressions.NewCmdSuppressions(f) + cmd.SetArgs([]string{ + "list", + "--start-time", "2024-01-01", + "--end-time", "2024-01-31", + "--last-id", "uuid-1", + }) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "next@example.com") { + t.Errorf("expected output to contain 'next@example.com', got:\n%s", buf.String()) + } +} diff --git a/internal/commands/trackingoptouts/create.go b/internal/commands/trackingoptouts/create.go new file mode 100644 index 0000000..2a9c6ea --- /dev/null +++ b/internal/commands/trackingoptouts/create.go @@ -0,0 +1,61 @@ +package trackingoptouts + +import ( + "context" + "fmt" + + "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 trackingOptOutResponse struct { + Data TrackingOptOut `json:"data"` +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + email string + domainID int64 + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Opt an email address out of open and click tracking", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("email", email); err != nil { + return err + } + if !cmd.Flags().Changed("domain-id") { + return fmt.Errorf("--domain-id is required") + } + if domainID <= 0 { + return fmt.Errorf("--domain-id must be greater than 0") + } + + c, err := f.NewClient() + if err != nil { + return err + } + + body := map[string]interface{}{ + "email": email, + "domain_id": domainID, + } + + var resp trackingOptOutResponse + if err := c.Post(context.Background(), client.BaseGeneral, trackingOptOutsPath, body, &resp); err != nil { + return err + } + + format := cmdutil.GetOutputFormat() + return output.Print(f.IOStreams.Out, format, resp.Data, trackingOptOutColumns) + }, + } + + cmd.Flags().StringVar(&email, "email", "", "Email address to opt out of tracking (required)") + cmd.Flags().Int64Var(&domainID, "domain-id", 0, "ID of the sending domain the opt-out applies to (required)") + + return cmd +} diff --git a/internal/commands/trackingoptouts/delete.go b/internal/commands/trackingoptouts/delete.go new file mode 100644 index 0000000..45699f8 --- /dev/null +++ b/internal/commands/trackingoptouts/delete.go @@ -0,0 +1,42 @@ +package trackingoptouts + +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 trackingOptOutID string + + cmd := &cobra.Command{ + Use: "delete", + Short: "Remove an email address from the tracking opt-out list", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("id", trackingOptOutID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := trackingOptOutsPath + "/" + trackingOptOutID + + if err := c.Delete(context.Background(), client.BaseGeneral, path, nil); err != nil { + return err + } + + fmt.Fprintln(f.IOStreams.Out, "Tracking opt-out deleted successfully.") + return nil + }, + } + + cmd.Flags().StringVar(&trackingOptOutID, "id", "", "Tracking opt-out ID (required)") + + return cmd +} diff --git a/internal/commands/trackingoptouts/list.go b/internal/commands/trackingoptouts/list.go new file mode 100644 index 0000000..d8ab9cf --- /dev/null +++ b/internal/commands/trackingoptouts/list.go @@ -0,0 +1,72 @@ +package trackingoptouts + +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 trackingOptOutsListResponse struct { + Data []TrackingOptOut `json:"data"` + LastID string `json:"last_id"` +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + email string + startTime string + endTime string + lastID string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List tracking opt-outs", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := f.NewClient() + if err != nil { + return err + } + + query := url.Values{} + if email != "" { + query.Set("email", email) + } + if startTime != "" { + query.Set("start_time", startTime) + } + if endTime != "" { + query.Set("end_time", endTime) + } + if lastID != "" { + query.Set("last_id", lastID) + } + + var resp trackingOptOutsListResponse + if err := c.Get(context.Background(), client.BaseGeneral, trackingOptOutsPath, query, &resp); err != nil { + return err + } + + format := cmdutil.GetOutputFormat() + if err := output.Print(f.IOStreams.Out, format, resp.Data, trackingOptOutColumns); err != nil { + return err + } + if format != output.FormatJSON && resp.LastID != "" { + fmt.Fprintf(f.IOStreams.Out, "\nNext page: --last-id %s\n", resp.LastID) + } + return nil + }, + } + + cmd.Flags().StringVar(&email, "email", "", "Filter by email address") + cmd.Flags().StringVar(&startTime, "start-time", "", "Filter by start time") + cmd.Flags().StringVar(&endTime, "end-time", "", "Filter by end time") + cmd.Flags().StringVar(&lastID, "last-id", "", "Pagination cursor (last_id from previous response)") + + return cmd +} diff --git a/internal/commands/trackingoptouts/trackingoptouts.go b/internal/commands/trackingoptouts/trackingoptouts.go new file mode 100644 index 0000000..4a1cad0 --- /dev/null +++ b/internal/commands/trackingoptouts/trackingoptouts.go @@ -0,0 +1,36 @@ +package trackingoptouts + +import ( + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +const trackingOptOutsPath = "/api/tracking_opt_outs" + +type TrackingOptOut struct { + ID string `json:"id"` + Email string `json:"email"` + DomainName string `json:"domain_name,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +var trackingOptOutColumns = []output.Column{ + {Header: "ID", Field: "id"}, + {Header: "EMAIL", Field: "email"}, + {Header: "DOMAIN_NAME", Field: "domain_name"}, + {Header: "CREATED_AT", Field: "created_at"}, +} + +func NewCmdTrackingOptOuts(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "tracking-opt-outs", + Short: "Manage tracking opt-outs", + } + + cmd.AddCommand(NewCmdList(f)) + cmd.AddCommand(NewCmdCreate(f)) + cmd.AddCommand(NewCmdDelete(f)) + + return cmd +} diff --git a/internal/commands/trackingoptouts/trackingoptouts_test.go b/internal/commands/trackingoptouts/trackingoptouts_test.go new file mode 100644 index 0000000..b2ed445 --- /dev/null +++ b/internal/commands/trackingoptouts/trackingoptouts_test.go @@ -0,0 +1,299 @@ +package trackingoptouts_test + +import ( + "bytes" + "encoding/json" + "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/trackingoptouts" + "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"} + }, + IOStreams: &cmdutil.IOStreams{ + Out: buf, + ErrOut: &bytes.Buffer{}, + }, + ClientOverride: c, + } + + viper.Set("api-token", "test-token") + viper.Set("output", "table") + + return f, buf, func() { + server.Close() + viper.Reset() + } +} + +func TestTrackingOptOutsList(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/tracking_opt_outs" { + 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")) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{ + {"id": "uuid-1", "email": "test@example.com", "domain_name": "example.com", "created_at": "2024-01-01"}, + }, + "last_id": nil, + }) + }) + defer cleanup() + + cmd := trackingoptouts.NewCmdTrackingOptOuts(f) + cmd.SetArgs([]string{"list"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "test@example.com") { + t.Errorf("expected output to contain 'test@example.com', got:\n%s", output) + } + if strings.Contains(output, "Next page") { + t.Errorf("expected no pagination hint without a cursor, got:\n%s", output) + } +} + +func TestTrackingOptOutsListJSON(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{}{ + {"id": "uuid-1", "email": "test@example.com"}, + }, + "last_id": "uuid-1", + }) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := trackingoptouts.NewCmdTrackingOptOuts(f) + cmd.SetArgs([]string{"list"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + var result []map[string]interface{} + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput:\n%s", err, output) + } + if len(result) != 1 { + t.Fatalf("expected 1 tracking opt-out, got %d", len(result)) + } + if result[0]["id"] != "uuid-1" { + t.Errorf("expected id 'uuid-1', got %v", result[0]["id"]) + } +} + +func TestTrackingOptOutsListWithFilters(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + if got := query.Get("email"); got != "test@example.com" { + t.Errorf("expected email 'test@example.com', got %q", got) + } + if got := query.Get("start_time"); got != "2024-01-01" { + t.Errorf("expected start_time '2024-01-01', got %q", got) + } + if got := query.Get("end_time"); got != "2024-01-31" { + t.Errorf("expected end_time '2024-01-31', got %q", got) + } + if got := query.Get("last_id"); got != "uuid-1" { + t.Errorf("expected last_id 'uuid-1', got %q", got) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{ + {"id": "uuid-2", "email": "test@example.com"}, + }, + "last_id": "uuid-2", + }) + }) + defer cleanup() + + cmd := trackingoptouts.NewCmdTrackingOptOuts(f) + cmd.SetArgs([]string{ + "list", + "--email", "test@example.com", + "--start-time", "2024-01-01", + "--end-time", "2024-01-31", + "--last-id", "uuid-1", + }) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "--last-id uuid-2") { + t.Errorf("expected output to hint the next page cursor, got:\n%s", buf.String()) + } +} + +func TestTrackingOptOutsCreate(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/tracking_opt_outs" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("could not decode request body: %v", err) + } + if body["email"] != "test@example.com" { + t.Errorf("expected email 'test@example.com', got %v", body["email"]) + } + if body["domain_id"] != float64(4321) { + t.Errorf("expected domain_id 4321, got %v", body["domain_id"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "id": "uuid-1", + "email": "test@example.com", + "domain_name": "example.com", + "created_at": "2024-01-01", + }, + }) + }) + defer cleanup() + + cmd := trackingoptouts.NewCmdTrackingOptOuts(f) + cmd.SetArgs([]string{ + "create", + "--email", "test@example.com", + "--domain-id", "4321", + }) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "test@example.com") { + t.Errorf("expected output to contain 'test@example.com', got:\n%s", buf.String()) + } +} + +func TestTrackingOptOutsCreateMissingFlags(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + { + name: "missing email", + args: []string{"create", "--domain-id", "4321"}, + want: "--email is required", + }, + { + name: "missing domain id", + args: []string{"create", "--email", "test@example.com"}, + want: "--domain-id is required", + }, + { + name: "non-positive domain id", + args: []string{"create", "--email", "test@example.com", "--domain-id", "0"}, + want: "--domain-id must be greater than 0", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + t.Error("expected no request to be sent") + }) + defer cleanup() + + cmd := trackingoptouts.NewCmdTrackingOptOuts(f) + cmd.SetArgs(tc.args) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("expected error to contain %q, got: %v", tc.want, err) + } + }) + } +} + +func TestTrackingOptOutsDelete(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/tracking_opt_outs/uuid-1" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + w.WriteHeader(http.StatusOK) + }) + defer cleanup() + + cmd := trackingoptouts.NewCmdTrackingOptOuts(f) + cmd.SetArgs([]string{"delete", "--id", "uuid-1"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "deleted successfully") { + t.Errorf("expected output to contain 'deleted successfully', got:\n%s", buf.String()) + } +} + +func TestTrackingOptOutsDeleteMissingID(t *testing.T) { + f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + t.Error("expected no request to be sent") + }) + defer cleanup() + + cmd := trackingoptouts.NewCmdTrackingOptOuts(f) + cmd.SetArgs([]string{"delete"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "--id is required") { + t.Errorf("expected error to contain '--id is required', got: %v", err) + } +} diff --git a/skills/mailtrap-cli/SKILL.md b/skills/mailtrap-cli/SKILL.md index 64c7e58..9365946 100644 --- a/skills/mailtrap-cli/SKILL.md +++ b/skills/mailtrap-cli/SKILL.md @@ -37,7 +37,7 @@ For scripting and piping, always use `--output json`. - **Batch operations**: use `--file path/to/payload.json` with a JSON array of email objects - **Config priority**: CLI flags > environment variables > config file - **Exit codes**: 0 on success, 1 on error (with descriptive message) -- **API base**: most requests go to `https://mailtrap.io/api/accounts/{account-id}/...`. The `inbound` group is the exception — it goes to `https://mailtrap.io/api/inbound/...` and takes no `--account-id`. +- **API base**: most requests go to `https://mailtrap.io/api/accounts/{account-id}/...`. Several groups do not and take no `--account-id`: `inbound` (`/api/inbound/...`), `company-info` (`/api/domains/{domain-id}/company_info`), `tracking-opt-outs` (`/api/tracking_opt_outs`), `email-campaigns` (`/api/email_campaigns`) and `organizations` (`/api/organizations/...`). `send` and `sandbox-send` go to their own hosts entirely. ## Command Groups @@ -45,6 +45,7 @@ For scripting and piping, always use `--output json`. |-------|---------|-----------| | `send` | Transactional & bulk email sending | [sending.md](references/sending.md) | | `domains` | Sending domain management | [domains.md](references/domains.md) | +| `company-info` | Sending domain company info for compliance verification | [domains.md](references/domains.md) | | `templates` | Email template CRUD | [templates.md](references/templates.md) | | `stats` | Aggregated sending statistics | [email-logs.md](references/email-logs.md) | | `email-logs` | Individual email log lookup | [email-logs.md](references/email-logs.md) | @@ -64,6 +65,7 @@ For scripting and piping, always use `--output json`. | `billing` | Usage information | [accounts.md](references/accounts.md) | | `organizations` | Sub-account management | [accounts.md](references/accounts.md) | | `suppressions` | Suppression list management | [domains.md](references/domains.md) | +| `tracking-opt-outs` | Open and click tracking opt-outs | [domains.md](references/domains.md) | ## Gotchas diff --git a/skills/mailtrap-cli/references/domains.md b/skills/mailtrap-cli/references/domains.md index a9f9368..19d7d14 100644 --- a/skills/mailtrap-cli/references/domains.md +++ b/skills/mailtrap-cli/references/domains.md @@ -1,6 +1,6 @@ # domains -Detailed flag specifications for `mailtrap domains` and `mailtrap suppressions` commands. +Detailed flag specifications for `mailtrap domains`, `mailtrap company-info`, `mailtrap suppressions` and `mailtrap tracking-opt-outs` commands. --- @@ -36,6 +36,23 @@ Register a new sending domain. --- +## domains update + +Update the tracking and inbound settings of a sending domain. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--id` | string | Yes | Domain ID | +| `--open-tracking` | bool | No | Track opens on emails sent from this domain | +| `--click-tracking` | bool | No | Track clicks on links in emails sent from this domain | +| `--tracking-opt-out` | bool | No | Add the tracking opt-out link to tracked emails; requires open or click tracking | +| `--auto-unsubscribe-link` | bool | No | Automatically add an unsubscribe link to emails | +| `--inbound-enabled` | bool | No | Allow the domain to be attached to an inbound inbox as a catch-all | + +Only the flags actually passed are sent, so a partial update leaves the other settings alone. Pass `--flag=false` to turn a setting off. + +--- + ## domains delete Delete a sending domain. @@ -46,11 +63,71 @@ Delete a sending domain. --- +## company-info get + +Retrieve the company info of a sending domain, used for domain compliance verification. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--domain-id` | string | Yes | Sending domain ID | + +**Note:** Uses the API token's account; `--account-id` is not needed. + +--- + +## company-info create + +Set the company info of a sending domain. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--domain-id` | string | Yes | Sending domain ID | +| `--name` | string | Yes | Company or individual name | +| `--address` | string | Yes | Street address | +| `--city` | string | Yes | City | +| `--country` | string | Yes | Country | +| `--zip-code` | string | Yes | ZIP or postal code | +| `--website-url` | string | Yes | Company website URL | +| `--phone` | string | No | Phone number | +| `--privacy-policy-url` | string | No | URL to the privacy policy page | +| `--terms-of-service-url` | string | No | URL to the terms of service page | +| `--info-level` | string | No | Whether the sender is a `business` or an `individual` | + +--- + +## company-info update + +Change the company info of a sending domain. + +Takes the same flags as `company-info create`, all optional except `--domain-id`. Only the flags actually passed are sent, so a partial update leaves the other fields alone. An update with no attribute flags is rejected rather than sent as an empty payload. + +--- + ## suppressions list List all suppressions (bounced/unsubscribed addresses). -No additional flags. +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--email` | string | No | Filter by email address | +| `--start-time` | string | No | Filter by start time | +| `--end-time` | string | No | Filter by end time | +| `--last-id` | string | No | Pagination cursor: id of the last record from the previous response | + +**Note:** The endpoint returns up to 1000 suppressions per request. Page through larger result sets with `--last-id`. + +--- + +## suppressions create + +Add an address to the suppression list. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--email` | string | Yes | Email address to suppress | +| `--domain-id` | int | Yes | Sending domain the suppression applies to | +| `--sending-stream` | string | Yes | `transactional` or `bulk` | +| `--type` | string | No | Suppression reason: `hard bounce`, `spam complaint`, `unsubscription`, `manual import`. Defaults to `manual import` | --- @@ -61,3 +138,39 @@ Remove an address from the suppression list. | Flag | Type | Required | Description | |------|------|----------|-------------| | `--id` | string | Yes | Suppression ID | + +--- + +## tracking-opt-outs list + +List addresses excluded from open and click tracking. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--email` | string | No | Filter by email address | +| `--start-time` | string | No | Filter by start time | +| `--end-time` | string | No | Filter by end time | +| `--last-id` | string | No | Pagination cursor: `last_id` from the previous response | + +**Note:** Uses the API token's account; `--account-id` is not needed. + +--- + +## tracking-opt-outs create + +Exclude an address from open and click tracking. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--email` | string | Yes | Email address to opt out | +| `--domain-id` | int | Yes | Sending domain the opt-out applies to | + +--- + +## tracking-opt-outs delete + +Remove an address from the tracking opt-out list, so tracking applies again. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--id` | string | Yes | Tracking opt-out ID | diff --git a/skills/mailtrap-cli/references/email-logs.md b/skills/mailtrap-cli/references/email-logs.md index b10dfc6..10f5930 100644 --- a/skills/mailtrap-cli/references/email-logs.md +++ b/skills/mailtrap-cli/references/email-logs.md @@ -8,9 +8,30 @@ Detailed flag specifications for `mailtrap email-logs` and `mailtrap stats` comm List email logs (sent email history). -No additional flags. Returns recent email logs for the account. +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--cursor` | string | No | Pagination cursor: `next_page_cursor` from the previous response | +| `--sent-after` | string | No | Only logs sent after this ISO 8601 timestamp | +| `--sent-before` | string | No | Only logs sent before this ISO 8601 timestamp | +| `--to` | string | No | Filter by recipient email | +| `--to-operator` | string | No | Operator for `--to`: `ci_equal` (default), `ci_not_equal`, `ci_contain`, `ci_not_contain` | +| `--from` | string | No | Filter by sender email | +| `--from-operator` | string | No | Operator for `--from`, same values as `--to-operator` | +| `--subject` | string | No | Filter by subject | +| `--subject-operator` | string | No | Operator for `--subject`, same values as `--to-operator` | +| `--status` | string | No | Filter by status: `delivered`, `not_delivered`, `enqueued`, `opted_out` | +| `--event` | string | No | Filter by event: `delivery`, `open`, `click`, `bounce`, `spam`, `unsubscribe` | +| `--category` | string | No | Filter by category | + +**Output:** Table/JSON of email logs with ID, to, subject, status, and timestamp. In table and text output the next-page cursor is printed as `--cursor ` when more logs are available. -**Output:** Table/JSON of email logs with ID, to, subject, status, and timestamp. +**Example:** +```bash +mailtrap email-logs list \ + --sent-after 2024-01-01T00:00:00Z \ + --to "user@example.com" \ + --status delivered +``` ---