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
+```
---