diff --git a/README.md b/README.md index b4f1c56..34572b0 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ You can also use environment variables: ```bash export MAILTRAP_API_TOKEN=your-token +export MAILTRAP_ACCOUNT_ID=your-account-id +export MAILTRAP_OUTPUT=json # table (default), json or text ``` ### 2. Send an email @@ -174,6 +176,15 @@ mailtrap domains list --output json # Text mailtrap domains list --output text + +# Set the default format for every command +export MAILTRAP_OUTPUT=json +``` + +With `--output json`, commands print the API response as returned. Paginated lists (`inbound messages list`, `inbound threads list`, `email-logs list`, `email-campaigns list`, `tracking-opt-outs list`) print the full response object, so the next-page cursor and total count are available to scripts: + +```bash +mailtrap inbound messages list --inbox-id 735 -o json | jq -r '.last_id // empty' ``` ## Commands diff --git a/cmd/root.go b/cmd/root.go index c4646e6..38ad199 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -46,7 +46,7 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { cmd.PersistentFlags().String("api-token", "", "Mailtrap API token (env: MAILTRAP_API_TOKEN)") cmd.PersistentFlags().String("account-id", "", "Mailtrap account ID (env: MAILTRAP_ACCOUNT_ID)") - cmd.PersistentFlags().StringP("output", "o", "table", "Output format: json, table, text") + cmd.PersistentFlags().StringP("output", "o", "table", "Output format: json, table, text (env: MAILTRAP_OUTPUT)") viper.BindPFlag("api-token", cmd.PersistentFlags().Lookup("api-token")) viper.BindPFlag("account-id", cmd.PersistentFlags().Lookup("account-id")) diff --git a/internal/commands/email_logs/email_logs_test.go b/internal/commands/email_logs/email_logs_test.go index bbc2489..c201f1c 100644 --- a/internal/commands/email_logs/email_logs_test.go +++ b/internal/commands/email_logs/email_logs_test.go @@ -3,8 +3,10 @@ package emaillogs_test import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" @@ -109,12 +111,22 @@ func TestEmailLogsListJSON(t *testing.T) { } output := buf.String() - var result []map[string]interface{} + var result struct { + Messages []map[string]interface{} `json:"messages"` + TotalCount int `json:"total_count"` + NextPageCursor string `json:"next_page_cursor"` + } 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 email log, got %d", len(result)) + if len(result.Messages) != 1 { + t.Fatalf("expected 1 email log, got %d", len(result.Messages)) + } + if result.TotalCount != 1 { + t.Errorf("expected total_count 1, got %d", result.TotalCount) + } + if result.NextPageCursor != "cursor-abc" { + t.Errorf("expected next_page_cursor 'cursor-abc', got %q", result.NextPageCursor) } } @@ -196,3 +208,35 @@ func TestEmailLogsGet(t *testing.T) { t.Errorf("expected output to contain 'delivered', got:\n%s", output) } } + +func TestEmailLogsGetJSONKeepsResponseAsIs(t *testing.T) { + body := `{"message_id":"msg-1","status":"delivered","client_ip":null,"category":"Welcome",` + + `"custom_variables":{"user_id":"42"},"sending_stream":"transactional","domain_id":3,` + + `"template_id":null,"references":[],"opens_count":2,"clicks_count":0,` + + `"events":[{"event_type":"delivery","created_at":"2024-01-01T00:00:01Z"}]}` + + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, body) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := emaillogs.NewCmdEmailLogs(f) + cmd.SetArgs([]string{"get", "--id", "msg-1"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got, want map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput:\n%s", err, buf.String()) + } + json.Unmarshal([]byte(body), &want) + if !reflect.DeepEqual(got, want) { + t.Errorf("expected the API response unchanged\nwant: %v\ngot: %v", want, got) + } +} diff --git a/internal/commands/email_logs/get.go b/internal/commands/email_logs/get.go index 2a7a311..b406a9b 100644 --- a/internal/commands/email_logs/get.go +++ b/internal/commands/email_logs/get.go @@ -2,6 +2,7 @@ package emaillogs import ( "context" + "encoding/json" "github.com/mailtrap/mailtrap-cli/internal/client" "github.com/mailtrap/mailtrap-cli/internal/cmdutil" @@ -32,7 +33,7 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command { path := cmdutil.AccountPath("email_logs", opts.ID) - var result EmailLog + var result json.RawMessage if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &result); err != nil { return err } diff --git a/internal/commands/email_logs/list.go b/internal/commands/email_logs/list.go index 23913c8..8c18b2f 100644 --- a/internal/commands/email_logs/list.go +++ b/internal/commands/email_logs/list.go @@ -2,7 +2,7 @@ package emaillogs import ( "context" - "fmt" + "encoding/json" "net/url" "github.com/mailtrap/mailtrap-cli/internal/client" @@ -12,23 +12,11 @@ import ( "github.com/spf13/cobra" ) -type EmailLog struct { - MessageID string `json:"message_id"` - Subject string `json:"subject"` - From string `json:"from"` - To string `json:"to"` - Status string `json:"status"` - SentAt string `json:"sent_at"` - RFCMessageID string `json:"rfc_message_id,omitempty"` - InReplyTo string `json:"in_reply_to,omitempty"` - References []string `json:"references,omitempty"` - ThreadID string `json:"thread_id,omitempty"` -} - -type emailLogListResponse struct { - Messages []EmailLog `json:"messages"` - TotalCount int `json:"total_count"` - NextPageCursor string `json:"next_page_cursor"` +var emailLogsPage = output.Page{ + Items: "messages", + Total: "total_count", + Cursor: []string{"next_page_cursor"}, + CursorFlag: "cursor", } var emailLogColumns = []output.Column{ @@ -110,19 +98,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { params.Set("filters[category]", category) } - var resp emailLogListResponse + var resp json.RawMessage if err := c.Get(context.Background(), client.BaseGeneral, path, params, &resp); err != nil { return err } - format := cmdutil.GetOutputFormat() - if err := output.Print(f.IOStreams.Out, format, resp.Messages, emailLogColumns); err != nil { - return err - } - if format != output.FormatJSON && resp.NextPageCursor != "" { - fmt.Fprintf(f.IOStreams.Out, "\nNext page: --cursor %s\n", resp.NextPageCursor) - } - return nil + return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, emailLogsPage, emailLogColumns) }, } diff --git a/internal/commands/emailcampaigns/emailcampaigns_test.go b/internal/commands/emailcampaigns/emailcampaigns_test.go index c9864f7..bf9f2bb 100644 --- a/internal/commands/emailcampaigns/emailcampaigns_test.go +++ b/internal/commands/emailcampaigns/emailcampaigns_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" @@ -115,7 +116,8 @@ 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()}, + "data": []map[string]interface{}{sampleCampaign()}, + "pagination": map[string]interface{}{"token": 1, "next_token": 2}, }) }) defer cleanup() @@ -130,19 +132,48 @@ func TestEmailCampaignsListJSON(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - var result []map[string]interface{} + var result struct { + Data []map[string]interface{} `json:"data"` + Pagination map[string]interface{} `json:"pagination"` + } 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 len(result.Data) != 1 { + t.Fatalf("expected 1 campaign, got %d", len(result.Data)) + } + if result.Data[0]["name"] != "Spring Sale" { + t.Errorf("expected name 'Spring Sale', got %v", result.Data[0]["name"]) } - if result[0]["name"] != "Spring Sale" { - t.Errorf("expected name 'Spring Sale', got %v", result[0]["name"]) + if result.Data[0]["domain_id"] != float64(4321) { + t.Errorf("expected domain_id 4321, got %v", result.Data[0]["domain_id"]) + } + if result.Pagination["next_token"] != float64(2) { + t.Errorf("expected pagination.next_token 2, got %v", result.Pagination["next_token"]) + } +} + +func TestEmailCampaignsListNextPage(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()}, + "pagination": map[string]interface{}{"token": 1, "next_token": 2}, + }) + }) + defer cleanup() + + cmd := emailcampaigns.NewCmdEmailCampaigns(f) + cmd.SetArgs([]string{"list"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) } - if result[0]["domain_id"] != float64(4321) { - t.Errorf("expected domain_id 4321, got %v", result[0]["domain_id"]) + + if !strings.Contains(buf.String(), "Next page: --token 2") { + t.Errorf("expected output to surface the next-page token, got:\n%s", buf.String()) } } @@ -177,6 +208,36 @@ func TestEmailCampaignsGet(t *testing.T) { } } +func TestEmailCampaignsGetJSONKeepsCampaignAsIs(t *testing.T) { + campaign := `{"id":4567,"name":"Spring Sale","reply_to":null,"contact_list_ids":[],` + + `"contact_segment_ids":[],"current_state_metadata":{"reason":null},"archived":false}` + + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"data":`+campaign+`}`) + }) + defer cleanup() + + viper.Set("output", "json") + + 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) + } + + var got, want map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput:\n%s", err, buf.String()) + } + json.Unmarshal([]byte(campaign), &want) + if !reflect.DeepEqual(got, want) { + t.Errorf("expected the campaign unchanged\nwant: %v\ngot: %v", want, got) + } +} + func TestEmailCampaignsGetMissingID(t *testing.T) { f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) defer cleanup() diff --git a/internal/commands/emailcampaigns/list.go b/internal/commands/emailcampaigns/list.go index b9be273..c1568e4 100644 --- a/internal/commands/emailcampaigns/list.go +++ b/internal/commands/emailcampaigns/list.go @@ -2,6 +2,7 @@ package emailcampaigns import ( "context" + "encoding/json" "fmt" "net/url" @@ -11,64 +12,10 @@ import ( "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"` - DomainID int64 `json:"domain_id"` - DomainName string `json:"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"` -} - +// campaignResponse unwraps the data envelope of a single-campaign response and +// keeps the campaign as the API returned it. type campaignResponse struct { - Data EmailCampaign `json:"data"` + Data json.RawMessage `json:"data"` } var campaignColumns = []output.Column{ @@ -80,6 +27,12 @@ var campaignColumns = []output.Column{ {Header: "CREATED", Field: "created_at"}, } +var campaignsPage = output.Page{ + Items: "data", + Cursor: []string{"pagination", "next_token"}, + CursorFlag: "token", +} + func NewCmdList(f *cmdutil.Factory) *cobra.Command { var ( perPage int @@ -107,13 +60,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { query.Set("token", fmt.Sprintf("%d", token)) } - var resp campaignListResponse + var resp json.RawMessage 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) + return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, campaignsPage, campaignColumns) }, } diff --git a/internal/commands/inbound/messages/get.go b/internal/commands/inbound/messages/get.go index a9d868c..994eb50 100644 --- a/internal/commands/inbound/messages/get.go +++ b/internal/commands/inbound/messages/get.go @@ -2,6 +2,7 @@ package messages import ( "context" + "encoding/json" "fmt" "github.com/mailtrap/mailtrap-cli/internal/client" @@ -10,6 +11,17 @@ import ( "github.com/spf13/cobra" ) +var messageDetailColumns = []output.Column{ + {Header: "ID", Field: "id"}, + {Header: "FROM", Field: "from"}, + {Header: "TO", Field: "to"}, + {Header: "CC", Field: "cc"}, + {Header: "SUBJECT", Field: "subject"}, + {Header: "SIZE", Field: "size"}, + {Header: "RECEIVED AT", Field: "received_at"}, + {Header: "THREAD ID", Field: "thread_id"}, +} + func NewCmdGet(f *cmdutil.Factory) *cobra.Command { var ( inboxID string @@ -34,12 +46,12 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command { path := fmt.Sprintf("/api/inbound/inboxes/%s/messages/%s", inboxID, messageID) - var resp InboundMessage + var resp json.RawMessage if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &resp); err != nil { return err } - return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, messageColumns) + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, messageDetailColumns) }, } diff --git a/internal/commands/inbound/messages/list.go b/internal/commands/inbound/messages/list.go index a7f39e3..89e5366 100644 --- a/internal/commands/inbound/messages/list.go +++ b/internal/commands/inbound/messages/list.go @@ -2,6 +2,7 @@ package messages import ( "context" + "encoding/json" "fmt" "net/url" @@ -11,28 +12,6 @@ import ( "github.com/spf13/cobra" ) -// InboundMessage represents a received inbound message. Body fields -// (html_body, text_body) are populated only on get-by-id. -type InboundMessage struct { - ID string `json:"id"` - InboxID *int `json:"inbox_id,omitempty"` - From string `json:"from,omitempty"` - To []string `json:"to,omitempty"` - Cc []string `json:"cc,omitempty"` - Subject string `json:"subject,omitempty"` - Size *int `json:"size,omitempty"` - ReceivedAt string `json:"received_at,omitempty"` - ThreadID string `json:"thread_id,omitempty"` - HTMLBody string `json:"html_body,omitempty"` - TextBody string `json:"text_body,omitempty"` -} - -type messagesListResponse struct { - Data []InboundMessage `json:"data"` - TotalCount int `json:"total_count"` - LastID string `json:"last_id"` -} - var messageColumns = []output.Column{ {Header: "ID", Field: "id"}, {Header: "FROM", Field: "from"}, @@ -41,6 +20,13 @@ var messageColumns = []output.Column{ {Header: "THREAD ID", Field: "thread_id"}, } +var messagesPage = output.Page{ + Items: "data", + Total: "total_count", + Cursor: []string{"last_id"}, + CursorFlag: "last-id", +} + func NewCmdList(f *cmdutil.Factory) *cobra.Command { var ( inboxID string @@ -68,19 +54,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { params.Set("last_id", lastID) } - var resp messagesListResponse + var resp json.RawMessage if err := c.Get(context.Background(), client.BaseGeneral, path, params, &resp); err != nil { return err } - format := cmdutil.GetOutputFormat() - if err := output.Print(f.IOStreams.Out, format, resp.Data, messageColumns); 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 + return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, messagesPage, messageColumns) }, } diff --git a/internal/commands/inbound/messages/messages_test.go b/internal/commands/inbound/messages/messages_test.go index 2b198e6..b8595a5 100644 --- a/internal/commands/inbound/messages/messages_test.go +++ b/internal/commands/inbound/messages/messages_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" @@ -74,6 +75,9 @@ func TestMessagesList(t *testing.T) { if !strings.Contains(buf.String(), "--last-id msg_1") { t.Errorf("expected output to surface the next-page cursor, got:\n%s", buf.String()) } + if !strings.Contains(buf.String(), "Total: 1") { + t.Errorf("expected output to surface the total count, got:\n%s", buf.String()) + } } func TestMessagesListWithCursor(t *testing.T) { @@ -258,11 +262,52 @@ func TestMessagesListJSON(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - var result []map[string]interface{} + var result struct { + Data []map[string]interface{} `json:"data"` + TotalCount int `json:"total_count"` + LastID string `json:"last_id"` + } 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 || result[0]["id"] != "msg_1" { - t.Errorf("unexpected JSON result: %v", result) + if len(result.Data) != 1 || result.Data[0]["id"] != "msg_1" { + t.Errorf("unexpected JSON data: %v", result.Data) + } + if result.TotalCount != 1 { + t.Errorf("expected total_count 1, got %d", result.TotalCount) + } + if result.LastID != "msg_1" { + t.Errorf("expected last_id 'msg_1', got %q", result.LastID) + } +} + +func TestMessagesGetJSONKeepsResponseAsIs(t *testing.T) { + body := `{"id":"msg_1","cc":[],"reply_to":null,"headers":{"mime-version":"1.0"},` + + `"attachments":[{"attachment_id":"att-1","download_url":"https://example.com/att-1"}],` + + `"raw_message_url":"https://example.com/raw.eml"}` + + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, body) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := messages.NewCmdMessages(f) + cmd.SetArgs([]string{"get", "--inbox-id", "201", "--id", "msg_1"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got, want map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput:\n%s", err, buf.String()) + } + json.Unmarshal([]byte(body), &want) + if !reflect.DeepEqual(got, want) { + t.Errorf("expected the API response unchanged\nwant: %v\ngot: %v", want, got) } } diff --git a/internal/commands/inbound/threads/get.go b/internal/commands/inbound/threads/get.go index fc69f56..1de90e6 100644 --- a/internal/commands/inbound/threads/get.go +++ b/internal/commands/inbound/threads/get.go @@ -2,6 +2,7 @@ package threads import ( "context" + "encoding/json" "fmt" "github.com/mailtrap/mailtrap-cli/internal/client" @@ -34,7 +35,7 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command { path := fmt.Sprintf("/api/inbound/inboxes/%s/threads/%s", inboxID, threadID) - var resp InboundThread + var resp json.RawMessage if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &resp); err != nil { return err } diff --git a/internal/commands/inbound/threads/list.go b/internal/commands/inbound/threads/list.go index 5e37edb..1848565 100644 --- a/internal/commands/inbound/threads/list.go +++ b/internal/commands/inbound/threads/list.go @@ -2,6 +2,7 @@ package threads import ( "context" + "encoding/json" "fmt" "net/url" @@ -11,35 +12,6 @@ import ( "github.com/spf13/cobra" ) -// InboundThreadMessage represents a message inside a thread. Populated on get-by-id. -type InboundThreadMessage struct { - ID string `json:"id,omitempty"` - VisibilityStatus string `json:"visibility_status,omitempty"` - Direction string `json:"direction,omitempty"` - Subject string `json:"subject,omitempty"` - From string `json:"from,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - DeliveryStatus string `json:"delivery_status,omitempty"` -} - -// InboundThread represents a conversation thread. Messages are populated on get-by-id. -type InboundThread struct { - ID string `json:"id"` - Subject string `json:"subject,omitempty"` - MessageCount *int `json:"message_count,omitempty"` - Size *int `json:"size,omitempty"` - LastActivityAt string `json:"last_activity_at,omitempty"` - Senders []string `json:"senders,omitempty"` - Recipients []string `json:"recipients,omitempty"` - Messages []InboundThreadMessage `json:"messages,omitempty"` -} - -type threadsListResponse struct { - Data []InboundThread `json:"data"` - TotalCount int `json:"total_count"` - LastID string `json:"last_id"` -} - var threadColumns = []output.Column{ {Header: "ID", Field: "id"}, {Header: "SUBJECT", Field: "subject"}, @@ -47,6 +19,13 @@ var threadColumns = []output.Column{ {Header: "LAST ACTIVITY", Field: "last_activity_at"}, } +var threadsPage = output.Page{ + Items: "data", + Total: "total_count", + Cursor: []string{"last_id"}, + CursorFlag: "last-id", +} + func NewCmdList(f *cmdutil.Factory) *cobra.Command { var ( inboxID string @@ -74,19 +53,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { params.Set("last_id", lastID) } - var resp threadsListResponse + var resp json.RawMessage if err := c.Get(context.Background(), client.BaseGeneral, path, params, &resp); err != nil { return err } - format := cmdutil.GetOutputFormat() - if err := output.Print(f.IOStreams.Out, format, resp.Data, threadColumns); 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 + return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, threadsPage, threadColumns) }, } diff --git a/internal/commands/inbound/threads/threads_test.go b/internal/commands/inbound/threads/threads_test.go index 6fcd32f..66d9eb0 100644 --- a/internal/commands/inbound/threads/threads_test.go +++ b/internal/commands/inbound/threads/threads_test.go @@ -150,11 +150,21 @@ func TestThreadsListJSON(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - var result []map[string]interface{} + var result struct { + Data []map[string]interface{} `json:"data"` + TotalCount int `json:"total_count"` + LastID string `json:"last_id"` + } 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 || result[0]["id"] != "thr_1" { - t.Errorf("unexpected JSON result: %v", result) + if len(result.Data) != 1 || result.Data[0]["id"] != "thr_1" { + t.Errorf("unexpected JSON data: %v", result.Data) + } + if result.TotalCount != 1 { + t.Errorf("expected total_count 1, got %d", result.TotalCount) + } + if result.LastID != "thr_1" { + t.Errorf("expected last_id 'thr_1', got %q", result.LastID) } } diff --git a/internal/commands/messages/get.go b/internal/commands/messages/get.go index 71b76e4..2c81c08 100644 --- a/internal/commands/messages/get.go +++ b/internal/commands/messages/get.go @@ -2,6 +2,7 @@ package messages import ( "context" + "encoding/json" "fmt" "github.com/mailtrap/mailtrap-cli/internal/client" @@ -38,7 +39,7 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command { path := cmdutil.AccountPath("inboxes", fmt.Sprintf("%s", sandboxID), "messages", fmt.Sprintf("%s", messageID)) - var message Message + var message json.RawMessage if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &message); err != nil { return err } diff --git a/internal/commands/messages/list.go b/internal/commands/messages/list.go index 7c52459..353583d 100644 --- a/internal/commands/messages/list.go +++ b/internal/commands/messages/list.go @@ -2,7 +2,10 @@ package messages import ( "context" + "encoding/json" "fmt" + "net/url" + "strconv" "github.com/mailtrap/mailtrap-cli/internal/client" "github.com/mailtrap/mailtrap-cli/internal/cmdutil" @@ -11,15 +14,6 @@ import ( "github.com/spf13/cobra" ) -type Message struct { - ID int `json:"id"` - Subject string `json:"subject"` - FromEmail string `json:"from_email"` - ToEmail string `json:"to_email"` - IsRead bool `json:"is_read"` - CreatedAt string `json:"created_at"` -} - var messageColumns = []output.Column{ {Header: "ID", Field: "id"}, {Header: "SUBJECT", Field: "subject"}, @@ -30,7 +24,11 @@ var messageColumns = []output.Column{ } func NewCmdList(f *cmdutil.Factory) *cobra.Command { - var sandboxID string + var ( + sandboxID string + lastID string + page int + ) cmd := &cobra.Command{ Use: "list", @@ -52,17 +50,26 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { path := cmdutil.AccountPath("inboxes", fmt.Sprintf("%s", sandboxID), "messages") - var messages []Message - if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &messages); err != nil { + query := url.Values{} + if lastID != "" { + query.Set("last_id", lastID) + } + if cmd.Flags().Changed("page") { + query.Set("page", strconv.Itoa(page)) + } + + var messages json.RawMessage + if err := c.Get(context.Background(), client.BaseGeneral, path, query, &messages); err != nil { return err } - format := cmdutil.GetOutputFormat() - return output.Print(f.IOStreams.Out, format, messages, messageColumns) + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), messages, messageColumns) }, } cmd.Flags().StringVar(&sandboxID, "sandbox-id", "", "Sandbox ID") + cmd.Flags().StringVar(&lastID, "last-id", "", "Pagination cursor (id of the last message from the previous response)") + cmd.Flags().IntVar(&page, "page", 1, "Page number to retrieve (ignored when --last-id is set)") return cmd } diff --git a/internal/commands/messages/messages_test.go b/internal/commands/messages/messages_test.go index 27d7b00..a254fc2 100644 --- a/internal/commands/messages/messages_test.go +++ b/internal/commands/messages/messages_test.go @@ -3,8 +3,10 @@ package messages_test import ( "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" @@ -101,6 +103,29 @@ func TestMessagesList(t *testing.T) { } } +func TestMessagesListWithPagination(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("last_id"); got != "92" { + t.Errorf("expected last_id=92, got %q", got) + } + if got := r.URL.Query().Get("page"); got != "3" { + t.Errorf("expected page=3, got %q", got) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]map[string]interface{}{{"id": 91, "subject": "Older"}}) + }) + defer cleanup() + + cmd := messages.NewCmdMessages(f) + cmd.SetArgs([]string{"list", "--sandbox-id", "1", "--last-id", "92", "--page", "3"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + func TestMessagesListMissingSandboxID(t *testing.T) { f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) defer cleanup() @@ -230,3 +255,35 @@ func TestMessagesListJSON(t *testing.T) { t.Errorf("expected subject 'Test', got %v", result[0]["subject"]) } } + +func TestMessagesGetJSONKeepsResponseAsIs(t *testing.T) { + body := `{"id":92,"sandbox_id":342,"subject":"Test email","from_name":"Ela Marks",` + + `"to_name":"Edmund Maggio","email_size":300,"is_read":false,"template_id":0,` + + `"template_variables":null,"html_body_size":150,"text_body_size":100,` + + `"html_path":"/api/accounts/336/sandboxes/342/messages/92/body.html"}` + + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, body) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := messages.NewCmdMessages(f) + cmd.SetArgs([]string{"get", "--sandbox-id", "342", "--id", "92"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got, want map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput:\n%s", err, buf.String()) + } + json.Unmarshal([]byte(body), &want) + if !reflect.DeepEqual(got, want) { + t.Errorf("expected the API response unchanged\nwant: %v\ngot: %v", want, got) + } +} diff --git a/internal/commands/messages/update.go b/internal/commands/messages/update.go index d790bb4..b18566f 100644 --- a/internal/commands/messages/update.go +++ b/internal/commands/messages/update.go @@ -2,6 +2,7 @@ package messages import ( "context" + "encoding/json" "fmt" "github.com/mailtrap/mailtrap-cli/internal/client" @@ -42,7 +43,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { "message": map[string]interface{}{"is_read": isRead}, } - var message Message + var message json.RawMessage if err := c.Patch(context.Background(), client.BaseGeneral, path, body, &message); err != nil { return err } diff --git a/internal/commands/trackingoptouts/list.go b/internal/commands/trackingoptouts/list.go index d8ab9cf..addc809 100644 --- a/internal/commands/trackingoptouts/list.go +++ b/internal/commands/trackingoptouts/list.go @@ -2,7 +2,7 @@ package trackingoptouts import ( "context" - "fmt" + "encoding/json" "net/url" "github.com/mailtrap/mailtrap-cli/internal/client" @@ -11,9 +11,10 @@ import ( "github.com/spf13/cobra" ) -type trackingOptOutsListResponse struct { - Data []TrackingOptOut `json:"data"` - LastID string `json:"last_id"` +var trackingOptOutsPage = output.Page{ + Items: "data", + Cursor: []string{"last_id"}, + CursorFlag: "last-id", } func NewCmdList(f *cmdutil.Factory) *cobra.Command { @@ -47,19 +48,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { query.Set("last_id", lastID) } - var resp trackingOptOutsListResponse + var resp json.RawMessage 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 + return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, trackingOptOutsPage, trackingOptOutColumns) }, } diff --git a/internal/commands/trackingoptouts/trackingoptouts_test.go b/internal/commands/trackingoptouts/trackingoptouts_test.go index b2ed445..23a9998 100644 --- a/internal/commands/trackingoptouts/trackingoptouts_test.go +++ b/internal/commands/trackingoptouts/trackingoptouts_test.go @@ -104,15 +104,21 @@ func TestTrackingOptOutsListJSON(t *testing.T) { } output := buf.String() - var result []map[string]interface{} + var result struct { + Data []map[string]interface{} `json:"data"` + LastID string `json:"last_id"` + } 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 len(result.Data) != 1 { + t.Fatalf("expected 1 tracking opt-out, got %d", len(result.Data)) + } + if result.Data[0]["id"] != "uuid-1" { + t.Errorf("expected id 'uuid-1', got %v", result.Data[0]["id"]) } - if result[0]["id"] != "uuid-1" { - t.Errorf("expected id 'uuid-1', got %v", result[0]["id"]) + if result.LastID != "uuid-1" { + t.Errorf("expected last_id 'uuid-1', got %q", result.LastID) } } diff --git a/internal/output/page.go b/internal/output/page.go new file mode 100644 index 0000000..f93bb5e --- /dev/null +++ b/internal/output/page.go @@ -0,0 +1,68 @@ +package output + +import ( + "encoding/json" + "fmt" + "io" +) + +// Page describes where a paginated list response keeps its items, total +// count and next-page cursor. +type Page struct { + Items string // key of the items array + Total string // key of the total count; empty when the API returns none + Cursor []string // path to the next-page cursor + CursorFlag string // flag that takes the cursor on the next request +} + +// PrintPage prints a paginated list response. JSON output is the body as the +// API returned it, so scripts keep every field, the total and the cursor. +// Table and text render the items followed by the total and next-page flag. +func PrintPage(w io.Writer, format Format, body json.RawMessage, page Page, columns []Column) error { + if format == FormatJSON { + return Print(w, format, body, columns) + } + + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("data must be a JSON object") + } + if err := Print(w, format, envelope[page.Items], columns); err != nil { + return err + } + + var footer []string + if total := lookup(body, page.Total); total != "" { + footer = append(footer, "Total: "+total) + } + if cursor := lookup(body, page.Cursor...); cursor != "" { + footer = append(footer, fmt.Sprintf("Next page: --%s %s", page.CursorFlag, cursor)) + } + if len(footer) > 0 { + fmt.Fprintln(w) + for _, line := range footer { + fmt.Fprintln(w, line) + } + } + return nil +} + +// lookup returns the scalar at path in body, or "" when the path is empty, +// unset or missing from the response. +func lookup(body json.RawMessage, path ...string) string { + if len(path) == 0 || path[0] == "" { + return "" + } + var v interface{} + if json.Unmarshal(body, &v) != nil { + return "" + } + for _, key := range path { + m, ok := v.(map[string]interface{}) + if !ok { + return "" + } + v = m[key] + } + return formatValue(v) +} diff --git a/internal/output/page_test.go b/internal/output/page_test.go new file mode 100644 index 0000000..8b3dfdb --- /dev/null +++ b/internal/output/page_test.go @@ -0,0 +1,98 @@ +package output + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +var pageCols = []Column{{Header: "ID", Field: "id"}} + +func TestPrintPage_JSONIsResponseBody(t *testing.T) { + var buf bytes.Buffer + body := json.RawMessage(`{"data":[{"id":"a","cc":[],"reply_to":null}],"total_count":7,"last_id":"a"}`) + page := Page{Items: "data", Total: "total_count", Cursor: []string{"last_id"}, CursorFlag: "last-id"} + + if err := PrintPage(&buf, FormatJSON, body, page, pageCols); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got, want interface{} + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput:\n%s", err, buf.String()) + } + json.Unmarshal(body, &want) + if got, want := mustMarshal(t, got), mustMarshal(t, want); got != want { + t.Errorf("JSON output = %s, want %s", got, want) + } +} + +func TestPrintPage_TableShowsTotalAndCursor(t *testing.T) { + var buf bytes.Buffer + body := json.RawMessage(`{"data":[{"id":"a"}],"total_count":7,"last_id":"a"}`) + page := Page{Items: "data", Total: "total_count", Cursor: []string{"last_id"}, CursorFlag: "last-id"} + + if err := PrintPage(&buf, FormatTable, body, page, pageCols); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + for _, want := range []string{"ID", "a", "Total: 7", "Next page: --last-id a"} { + if !strings.Contains(out, want) { + t.Errorf("expected output to contain %q, got:\n%s", want, out) + } + } +} + +func TestPrintPage_NestedNumericCursor(t *testing.T) { + var buf bytes.Buffer + body := json.RawMessage(`{"data":[{"id":1}],"pagination":{"token":1,"next_token":2}}`) + page := Page{Items: "data", Cursor: []string{"pagination", "next_token"}, CursorFlag: "token"} + + if err := PrintPage(&buf, FormatText, body, page, pageCols); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Next page: --token 2") { + t.Errorf("expected next-page token, got:\n%s", buf.String()) + } +} + +func TestPrintPage_NoFooterOnLastPage(t *testing.T) { + var buf bytes.Buffer + body := json.RawMessage(`{"data":[],"last_id":null}`) + page := Page{Items: "data", Cursor: []string{"last_id"}, CursorFlag: "last-id"} + + if err := PrintPage(&buf, FormatTable, body, page, pageCols); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.Contains(buf.String(), "Next page") { + t.Errorf("expected no next-page hint without a cursor, got:\n%s", buf.String()) + } +} + +func TestPrintPage_NoFooterWithoutCursorOrTotal(t *testing.T) { + var buf bytes.Buffer + body := json.RawMessage(`{"data":[{"id":"a"}],"total_count":7,"last_id":"a"}`) + page := Page{Items: "data"} + + if err := PrintPage(&buf, FormatTable, body, page, pageCols); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "Next page") || strings.Contains(out, "Total") { + t.Errorf("expected no footer for a page without Cursor or Total, got:\n%s", out) + } +} + +func mustMarshal(t *testing.T, v interface{}) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} diff --git a/skills/mailtrap-cli/SKILL.md b/skills/mailtrap-cli/SKILL.md index 9365946..dc8d68f 100644 --- a/skills/mailtrap-cli/SKILL.md +++ b/skills/mailtrap-cli/SKILL.md @@ -23,12 +23,24 @@ If a required flag is missing, the command exits with an error listing the missi ## Output Formats -Use `-o` / `--output` to control output format: +Use `-o` / `--output` (or the `MAILTRAP_OUTPUT` environment variable) to control output format: - `table` (default) — human-readable table - `json` — machine-readable JSON - `text` — plain text -For scripting and piping, always use `--output json`. +For scripting and piping, always use `--output json`. JSON output is the API response as returned: every field is kept, and empty values stay as `[]` / `null` rather than being dropped. + +Table and text output show a summary and, for paginated lists, a `Next page: -- ` footer. In JSON, paginated lists print the full response object instead of a bare array: + +| Command | Items | Total | Next-page cursor → flag | +|---------|-------|-------|-------------------------| +| `inbound messages list` | `.data` | `.total_count` | `.last_id` → `--last-id` | +| `inbound threads list` | `.data` | `.total_count` | `.last_id` → `--last-id` | +| `email-logs list` | `.messages` | `.total_count` | `.next_page_cursor` → `--cursor` | +| `email-campaigns list` | `.data` | — | `.pagination.next_token` → `--token` | +| `tracking-opt-outs list` | `.data` | — | `.last_id` → `--last-id` | + +A `null` cursor means there are no more pages. `suppressions list` and `messages list` return a bare array; pass the last item's `id` as `--last-id` for the next page. ## Key Conventions diff --git a/skills/mailtrap-cli/references/domains.md b/skills/mailtrap-cli/references/domains.md index 19d7d14..2d1729f 100644 --- a/skills/mailtrap-cli/references/domains.md +++ b/skills/mailtrap-cli/references/domains.md @@ -152,6 +152,8 @@ List addresses excluded from open and click tracking. | `--end-time` | string | No | Filter by end time | | `--last-id` | string | No | Pagination cursor: `last_id` from the previous response | +**Output:** In JSON, the full response: `{"data": [...], "last_id": "..."}`. `last_id` is `null` when there are no more pages. + **Note:** Uses the API token's account; `--account-id` is not needed. --- diff --git a/skills/mailtrap-cli/references/email-logs.md b/skills/mailtrap-cli/references/email-logs.md index 10f5930..8556d9a 100644 --- a/skills/mailtrap-cli/references/email-logs.md +++ b/skills/mailtrap-cli/references/email-logs.md @@ -23,7 +23,7 @@ List email logs (sent email history). | `--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 of email logs with ID, to, subject, status, and timestamp. Table and text print the total and a `Next page: --cursor ` footer when more logs are available. JSON prints the full response: `{"messages": [...], "total_count": N, "next_page_cursor": "..."}`; pass `next_page_cursor` as `--cursor` for the next page. **Example:** ```bash @@ -37,7 +37,7 @@ mailtrap email-logs list \ ## email-logs get -Get a specific email log entry. +Get a specific email log entry. JSON output is the API response as returned, including `category`, `custom_variables`, `sending_stream`, template fields and open/click counts. | Flag | Type | Required | Description | |------|------|----------|-------------| diff --git a/skills/mailtrap-cli/references/inbound.md b/skills/mailtrap-cli/references/inbound.md index 5c454ad..193725a 100644 --- a/skills/mailtrap-cli/references/inbound.md +++ b/skills/mailtrap-cli/references/inbound.md @@ -104,11 +104,13 @@ Messages and threads are accessed via the top-level inbox route (`/api/inbound/i | `--inbox-id` | string | Yes | Inbox ID | | `--last-id` | string | No | Pagination cursor (`last_id` from the previous response) | +**Output:** In JSON, the full response: `{"data": [...], "total_count": N, "last_id": "..."}`. Pass `last_id` as `--last-id` for the next page; it is `null` on the last page. Table and text print the total and a `Next page: --last-id ` footer. + --- ## inbound messages get -Returns the message with its body and attachment download URLs. +Returns the message with its body and attachment download URLs. JSON output is the API response as returned, including `attachments` (with `download_url`), `headers`, `references`, `bcc`, `reply_to`, `raw_message_url` and body sizes. | Flag | Type | Required | Description | |------|------|----------|-------------| @@ -162,6 +164,8 @@ Each sends a **real email** and returns the sent message IDs. | `--inbox-id` | string | Yes | Inbox ID | | `--last-id` | string | No | Pagination cursor (`last_id` from the previous response) | +**Output:** Same shape as `inbound messages list`: `{"data": [...], "total_count": N, "last_id": "..."}` in JSON. + --- ## inbound threads get diff --git a/skills/mailtrap-cli/references/sandbox.md b/skills/mailtrap-cli/references/sandbox.md index aabfeb2..5572c5c 100644 --- a/skills/mailtrap-cli/references/sandbox.md +++ b/skills/mailtrap-cli/references/sandbox.md @@ -190,17 +190,19 @@ Reset the email username of a sandbox. ## messages list -List all messages in a sandbox. +List messages in a sandbox, up to 30 per page. | Flag | Type | Required | Description | |------|------|----------|-------------| | `--sandbox-id` | string | Yes | Sandbox ID | +| `--last-id` | string | No | Pagination cursor: `id` of the last message from the previous response; returns older messages | +| `--page` | int | No | Page number to retrieve (ignored when `--last-id` is set) | --- ## messages get -Get a specific sandbox message. +Get a specific sandbox message. JSON output is the API response as returned, including sender/recipient names, sizes, template fields and body paths. | Flag | Type | Required | Description | |------|------|----------|-------------|