From 0672557dad74d87475b9718c36bac1d313b3f67d Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Tue, 29 Sep 2026 16:44:20 +0200 Subject: [PATCH 1/9] Document the MAILTRAP_OUTPUT environment variable The output format already falls back to MAILTRAP_OUTPUT through Viper's AutomaticEnv, but nothing said so: --api-token and --account-id name their env vars in --help while --output did not, and neither the README nor the skill mentioned it. MAILTRAP_ACCOUNT_ID was missing from the README's env var example as well. --- README.md | 5 +++++ cmd/root.go | 2 +- skills/mailtrap-cli/SKILL.md | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b4f1c56..5e48e49 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,9 @@ mailtrap domains list --output json # Text mailtrap domains list --output text + +# Set the default format for every command +export MAILTRAP_OUTPUT=json ``` ## 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/skills/mailtrap-cli/SKILL.md b/skills/mailtrap-cli/SKILL.md index 9365946..30296d7 100644 --- a/skills/mailtrap-cli/SKILL.md +++ b/skills/mailtrap-cli/SKILL.md @@ -23,7 +23,7 @@ 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 From 4b535d92c463c6c18a3fd77b55a5fd87ec4cabb0 Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Tue, 29 Sep 2026 16:47:23 +0200 Subject: [PATCH 2/9] Add a paginated-list printer that keeps the response envelope List commands printed only the items array in JSON, so the next-page cursor and total count shown in the table footer were unreachable from scripts. PrintPage prints the response body unchanged in JSON; table and text render the items, then the total and the flag that takes the cursor. The cursor can be nested (email campaigns keep it under pagination.next_token) and a null cursor prints no footer. --- internal/output/page.go | 65 ++++++++++++++++++++++++++++ internal/output/page_test.go | 83 ++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 internal/output/page.go create mode 100644 internal/output/page_test.go diff --git a/internal/output/page.go b/internal/output/page.go new file mode 100644 index 0000000..db9f4f5 --- /dev/null +++ b/internal/output/page.go @@ -0,0 +1,65 @@ +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 page.Total != "" { + 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 +} + +func lookup(body json.RawMessage, path ...string) string { + 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..18da58b --- /dev/null +++ b/internal/output/page_test.go @@ -0,0 +1,83 @@ +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 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) +} From 08e5349335487123bcd4380079719014a26fca45 Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Tue, 29 Sep 2026 16:50:03 +0200 Subject: [PATCH 3/9] Keep the full API response in inbound messages and threads output Messages and threads were decoded into partial structs whose fields were all omitempty, so get dropped attachments, headers, references, bcc, reply_to, rfc_message_id, body sizes and raw_message_url, and an empty cc vanished instead of printing []. The key set changed from message to message, and a missing key could mean either empty or not modelled. get now prints the response as returned. list prints the whole envelope in JSON, so last_id and total_count are there for scripts, and shows the total next to the next-page hint in table and text. This changes list JSON from a bare array to an object. --- internal/commands/inbound/messages/get.go | 16 +++++- internal/commands/inbound/messages/list.go | 41 ++++----------- .../inbound/messages/messages_test.go | 51 +++++++++++++++++-- internal/commands/inbound/threads/get.go | 3 +- internal/commands/inbound/threads/list.go | 48 ++++------------- .../commands/inbound/threads/threads_test.go | 16 ++++-- skills/mailtrap-cli/references/inbound.md | 6 ++- 7 files changed, 102 insertions(+), 79 deletions(-) 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/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 From bf0ce20057aeb8aa208b09aa59fa82f1123c30ab Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Tue, 29 Sep 2026 16:56:39 +0200 Subject: [PATCH 4/9] Keep the full API response in email-logs output The EmailLog struct modelled ten fields, so get dropped client_ip, category, custom_variables, sending_stream, domain_id, the template fields and the open and click counts. list printed only the messages array in JSON, leaving next_page_cursor and total_count out of reach. get now prints the response as returned, and list prints the whole envelope in JSON and the total next to the next-page hint in table and text. This changes list JSON from a bare array to an object. --- .../commands/email_logs/email_logs_test.go | 50 +++++++++++++++++-- internal/commands/email_logs/get.go | 3 +- internal/commands/email_logs/list.go | 35 +++---------- skills/mailtrap-cli/references/email-logs.md | 4 +- 4 files changed, 59 insertions(+), 33 deletions(-) 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/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 | |------|------|----------|-------------| From 60dd2b76e1778bd7756d00507640a8a8a9f6be41 Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Tue, 29 Sep 2026 16:58:02 +0200 Subject: [PATCH 5/9] Keep pagination in tracking-opt-outs and email-campaigns list output Both printed only the data array in JSON. Tracking opt-outs showed last_id in the table footer alone; email campaigns dropped the pagination object in every format, so there was no way to find the next page token at all. JSON now prints the whole response, and table and text show the next-page flag (--last-id, --token). This changes list JSON from a bare array to an object. --- .../emailcampaigns/emailcampaigns_test.go | 46 +++++++++++++++---- internal/commands/emailcampaigns/list.go | 16 ++++--- internal/commands/trackingoptouts/list.go | 20 +++----- .../trackingoptouts/trackingoptouts_test.go | 16 +++++-- skills/mailtrap-cli/references/domains.md | 2 + 5 files changed, 67 insertions(+), 33 deletions(-) diff --git a/internal/commands/emailcampaigns/emailcampaigns_test.go b/internal/commands/emailcampaigns/emailcampaigns_test.go index c9864f7..bea1ddf 100644 --- a/internal/commands/emailcampaigns/emailcampaigns_test.go +++ b/internal/commands/emailcampaigns/emailcampaigns_test.go @@ -115,7 +115,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 +131,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()) } } diff --git a/internal/commands/emailcampaigns/list.go b/internal/commands/emailcampaigns/list.go index b9be273..7f812aa 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" @@ -63,10 +64,6 @@ type EmailCampaign struct { Template *Template `json:"template,omitempty"` } -type campaignListResponse struct { - Data []EmailCampaign `json:"data"` -} - type campaignResponse struct { Data EmailCampaign `json:"data"` } @@ -80,6 +77,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 +110,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/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/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. --- From 90636cdc69d46a6e263f271c3c6c60888bcaff08 Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Tue, 29 Sep 2026 16:59:29 +0200 Subject: [PATCH 6/9] Add pagination to sandbox messages list and keep full message output The endpoint returns 30 messages per page and accepts last_id and page, but list offered neither flag, so only the newest page was reachable. Messages were also decoded into a six-field struct, dropping sandbox_id, sender and recipient names, sizes, the template fields and body paths from list, get and update. The response is a bare array, so the next page's cursor is the id of the last message and needs no footer or envelope. --- internal/commands/messages/get.go | 3 +- internal/commands/messages/list.go | 35 ++++++++----- internal/commands/messages/messages_test.go | 57 +++++++++++++++++++++ internal/commands/messages/update.go | 3 +- skills/mailtrap-cli/references/sandbox.md | 6 ++- 5 files changed, 86 insertions(+), 18 deletions(-) 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/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 | |------|------|----------|-------------| From 3d26d3c8b74f6ff36bd50632d51ea41a6db2453a Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Tue, 29 Sep 2026 17:00:19 +0200 Subject: [PATCH 7/9] Document the JSON output shape of paginated lists Paginated lists now print the whole response object in JSON rather than the items array, and each API keeps its items and cursor under different keys. Document where to find them per command, and that JSON output keeps every field of the response, including empty [] and null values. --- README.md | 6 ++++++ skills/mailtrap-cli/SKILL.md | 14 +++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5e48e49..34572b0 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,12 @@ mailtrap domains list --output text 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 | Group | Commands | diff --git a/skills/mailtrap-cli/SKILL.md b/skills/mailtrap-cli/SKILL.md index 30296d7..dc8d68f 100644 --- a/skills/mailtrap-cli/SKILL.md +++ b/skills/mailtrap-cli/SKILL.md @@ -28,7 +28,19 @@ Use `-o` / `--output` (or the `MAILTRAP_OUTPUT` environment variable) to control - `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 From 346e8f6448a45519fd63c72b8b703bcb22c14a7a Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Wed, 30 Sep 2026 10:27:15 +0200 Subject: [PATCH 8/9] Print no pagination footer for a Page without a cursor lookup returned the whole response for an empty path, so a Page without Cursor would print "Next page: -- {...}". Return "" instead, which also covers an unset Total. --- internal/output/page.go | 11 +++++++---- internal/output/page_test.go | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/internal/output/page.go b/internal/output/page.go index db9f4f5..f93bb5e 100644 --- a/internal/output/page.go +++ b/internal/output/page.go @@ -32,10 +32,8 @@ func PrintPage(w io.Writer, format Format, body json.RawMessage, page Page, colu } var footer []string - if page.Total != "" { - if total := lookup(body, page.Total); total != "" { - footer = append(footer, "Total: "+total) - } + 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)) @@ -49,7 +47,12 @@ func PrintPage(w io.Writer, format Format, body json.RawMessage, page Page, colu 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 "" diff --git a/internal/output/page_test.go b/internal/output/page_test.go index 18da58b..8b3dfdb 100644 --- a/internal/output/page_test.go +++ b/internal/output/page_test.go @@ -73,6 +73,21 @@ func TestPrintPage_NoFooterOnLastPage(t *testing.T) { } } +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) From b92f3ec80cb0dd9e491c0e4fb0e4163c5ea786d0 Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Wed, 30 Sep 2026 10:27:23 +0200 Subject: [PATCH 9/9] Keep the full campaign in email-campaigns get, create and update output get, create, update and the start/cancel/terminate/reset/schedule actions decoded the campaign into an omitempty struct, so the same campaign lost contact_list_ids: [], reply_to: null and every field the struct did not model, depending on the command. Keep data as returned; the JSON shape is unchanged since these commands still unwrap the data envelope. --- .../emailcampaigns/emailcampaigns_test.go | 31 ++++++++++ internal/commands/emailcampaigns/list.go | 56 +------------------ 2 files changed, 34 insertions(+), 53 deletions(-) diff --git a/internal/commands/emailcampaigns/emailcampaigns_test.go b/internal/commands/emailcampaigns/emailcampaigns_test.go index bea1ddf..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" @@ -207,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 7f812aa..c1568e4 100644 --- a/internal/commands/emailcampaigns/list.go +++ b/internal/commands/emailcampaigns/list.go @@ -12,60 +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"` -} - +// 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{