From 3c0f5732a09385a71bd64a3edd5834c1da02fcc4 Mon Sep 17 00:00:00 2001 From: IzikAJ Date: Mon, 5 Oct 2026 17:19:48 +0300 Subject: [PATCH 1/5] Move templates commands to /api/templates and add --token/--per-page `templates list|get|create|update|delete` now use the account-scoped `/api/accounts/{id}/templates` endpoints. - `templates list` takes `--per-page` and `--token`, prints a "Next page: --token N" footer, and `--output json` returns the full `{data, pagination}` object - get, create and update print the template from the `data` envelope, so JSON output keeps every field the API returns - Request bodies are flat; `create` sends `category: "General"` when `--category` is not set, and `update` requires at least one attribute flag --- README.md | 4 +- docs/TEST_PLAN.md | 6 +- internal/commands/templates/create.go | 25 +-- internal/commands/templates/delete.go | 2 +- internal/commands/templates/get.go | 10 +- internal/commands/templates/list.go | 44 ++-- internal/commands/templates/templates_test.go | 199 ++++++++++++++---- internal/commands/templates/update.go | 27 ++- skills/mailtrap-cli/SKILL.md | 1 + skills/mailtrap-cli/references/templates.md | 15 +- 10 files changed, 229 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 34572b0..a38bb76 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ mailtrap tracking-opt-outs create --email "no-tracking@example.com" --domain-id mailtrap tracking-opt-outs delete --id "0198f1c4-0c0f-7a1c-8b0e-3f5d2a1b4c6d" # Templates -mailtrap templates list +mailtrap templates list --per-page 20 mailtrap templates create --name "Welcome" --subject "Hello {{name}}" --body-html '

Hi!

' # Webhooks @@ -181,7 +181,7 @@ 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: +With `--output json`, commands print the API response as returned. Paginated lists (`inbound messages list`, `inbound threads list`, `email-logs list`, `email-campaigns list`, `templates 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' diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md index 74e558e..6acadc2 100644 --- a/docs/TEST_PLAN.md +++ b/docs/TEST_PLAN.md @@ -52,7 +52,7 @@ These were discovered during integration testing and are important for correct i | `GET /api_tokens` | `{"errors": "Access forbidden"}` | May require admin-level token | | `GET /contacts` | 404 (HTML page) | Endpoint may not exist or requires different path | | `GET /suppressions` | `[...]` | Flat array | -| `GET /email_templates` | `[...]` | Flat array | +| `GET /templates` | `{"data": [...], "pagination": {...}}` | Page-token pagination, no total | | `GET /contacts/lists` | `[...]` | Flat array | | `GET /contacts/fields` | `[...]` | Flat array | | `GET /account_accesses` | `[...]` | Flat array | @@ -120,9 +120,9 @@ Tests are organized by endpoint group. Each test specifies: | # | Test | Command | Expected | |---|------|---------|----------| | 4.1 | List templates | `mailtrap templates list` | Table with template entries | -| 4.2 | List templates (JSON) | `mailtrap templates list --output json` | Valid JSON array | +| 4.2 | List templates (JSON) | `mailtrap templates list --output json` | Valid JSON object with `data` and `pagination` | | 4.3 | Get template | `mailtrap templates get --id ` | Single template details | -| 4.4 | Create template | `mailtrap templates create --name "test-tpl" --subject "Test" --text "body"` | New template in output | +| 4.4 | Create template | `mailtrap templates create --name "test-tpl" --subject "Test" --body-text "body"` | New template in output (flat request body, category defaults to `General`) | | 4.5 | Update template | `mailtrap templates update --id --name "test-tpl-updated"` | Updated template | | 4.6 | Delete template | `mailtrap templates delete --id ` | Success message | | 4.7 | Get missing ID | `mailtrap templates get` | Error: `--id is required` | diff --git a/internal/commands/templates/create.go b/internal/commands/templates/create.go index 1020c03..4468d2c 100644 --- a/internal/commands/templates/create.go +++ b/internal/commands/templates/create.go @@ -34,27 +34,23 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { return err } - path := cmdutil.AccountPath("email_templates") + path := cmdutil.AccountPath("templates") body := map[string]interface{}{ - "email_template": map[string]interface{}{ - "name": opts.Name, - "subject": opts.Subject, - "body_html": opts.BodyHTML, - "body_text": opts.BodyText, - "category": opts.Category, - }, + "name": opts.Name, + "subject": opts.Subject, + "body_html": opts.BodyHTML, + "body_text": opts.BodyText, + "category": opts.Category, } - var result Template - if err := c.Post(context.Background(), client.BaseGeneral, path, body, &result); err != nil { + var resp templateResponse + if err := c.Post(context.Background(), client.BaseGeneral, path, body, &resp); err != nil { return err } format := cmdutil.GetOutputFormat() - output.Print(f.IOStreams.Out, format, result, templateColumns) - - return nil + return output.Print(f.IOStreams.Out, format, resp.Data, templateColumns) }, } @@ -62,7 +58,8 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&opts.Subject, "subject", "", "Template subject (required)") cmd.Flags().StringVar(&opts.BodyHTML, "body-html", "", "HTML body content") cmd.Flags().StringVar(&opts.BodyText, "body-text", "", "Plain text body content") - cmd.Flags().StringVar(&opts.Category, "category", "", "Template category") + // The API requires a category, but the CLI does not make the user pick one. + cmd.Flags().StringVar(&opts.Category, "category", "General", "Template category") _ = cmd.MarkFlagRequired("name") _ = cmd.MarkFlagRequired("subject") diff --git a/internal/commands/templates/delete.go b/internal/commands/templates/delete.go index 6be1bef..4fe8a2c 100644 --- a/internal/commands/templates/delete.go +++ b/internal/commands/templates/delete.go @@ -30,7 +30,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { return err } - path := cmdutil.AccountPath("email_templates", fmt.Sprintf("%d", opts.ID)) + path := cmdutil.AccountPath("templates", fmt.Sprintf("%d", opts.ID)) if err := c.Delete(context.Background(), client.BaseGeneral, path, nil); err != nil { return err diff --git a/internal/commands/templates/get.go b/internal/commands/templates/get.go index 3b8e1f9..b1fab2c 100644 --- a/internal/commands/templates/get.go +++ b/internal/commands/templates/get.go @@ -31,17 +31,15 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command { return err } - path := cmdutil.AccountPath("email_templates", fmt.Sprintf("%d", opts.ID)) + path := cmdutil.AccountPath("templates", fmt.Sprintf("%d", opts.ID)) - var result Template - if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &result); err != nil { + var resp templateResponse + if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &resp); err != nil { return err } format := cmdutil.GetOutputFormat() - output.Print(f.IOStreams.Out, format, result, templateColumns) - - return nil + return output.Print(f.IOStreams.Out, format, resp.Data, templateColumns) }, } diff --git a/internal/commands/templates/list.go b/internal/commands/templates/list.go index fcb26b6..900b7ae 100644 --- a/internal/commands/templates/list.go +++ b/internal/commands/templates/list.go @@ -2,6 +2,9 @@ package templates import ( "context" + "encoding/json" + "fmt" + "net/url" "github.com/mailtrap/mailtrap-cli/internal/client" "github.com/mailtrap/mailtrap-cli/internal/cmdutil" @@ -10,13 +13,9 @@ import ( "github.com/spf13/cobra" ) -type Template struct { - ID int `json:"id"` - UUID string `json:"uuid"` - Name string `json:"name"` - Subject string `json:"subject"` - Category string `json:"category"` - CreatedAt string `json:"created_at"` +// templateResponse keeps the template as raw JSON so --output json prints every field. +type templateResponse struct { + Data json.RawMessage `json:"data"` } var templateColumns = []output.Column{ @@ -28,7 +27,18 @@ var templateColumns = []output.Column{ {Header: "CREATED AT", Field: "created_at"}, } +var templatesPage = output.Page{ + Items: "data", + Cursor: []string{"pagination", "next_token"}, + CursorFlag: "token", +} + func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + perPage int + token int + ) + cmd := &cobra.Command{ Use: "list", Short: "List all email templates", @@ -42,19 +52,25 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return err } - path := cmdutil.AccountPath("email_templates") + query := url.Values{} + if cmd.Flags().Changed("per-page") { + query.Set("per_page", fmt.Sprintf("%d", perPage)) + } + if cmd.Flags().Changed("token") { + query.Set("token", fmt.Sprintf("%d", token)) + } - var result []Template - if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &result); err != nil { + var resp json.RawMessage + if err := c.Get(context.Background(), client.BaseGeneral, cmdutil.AccountPath("templates"), query, &resp); err != nil { return err } - format := cmdutil.GetOutputFormat() - output.Print(f.IOStreams.Out, format, result, templateColumns) - - return nil + return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, templatesPage, templateColumns) }, } + cmd.Flags().IntVar(&perPage, "per-page", 50, "Number of templates per page (max 100)") + cmd.Flags().IntVar(&token, "token", 0, "Page number to retrieve (page-token pagination)") + return cmd } diff --git a/internal/commands/templates/templates_test.go b/internal/commands/templates/templates_test.go index c9b2519..56dbe48 100644 --- a/internal/commands/templates/templates_test.go +++ b/internal/commands/templates/templates_test.go @@ -40,12 +40,21 @@ func setupTest(handler http.HandlerFunc) (*cmdutil.Factory, *bytes.Buffer, func( } } +func listBody() map[string]interface{} { + return map[string]interface{}{ + "data": []map[string]interface{}{ + {"id": 1, "uuid": "abc-123", "name": "Welcome", "subject": "Hello", "category": "transactional", "created_at": "2024-01-01"}, + }, + "pagination": map[string]interface{}{"token": 1, "prev_token": nil, "next_token": 2}, + } +} + func TestTemplatesList(t *testing.T) { f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } - if r.URL.Path != "/api/accounts/123/email_templates" { + if r.URL.Path != "/api/accounts/123/templates" { t.Errorf("unexpected path: %s", r.URL.Path) } if r.Header.Get("Api-Token") != "test-token" { @@ -53,9 +62,7 @@ func TestTemplatesList(t *testing.T) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]map[string]interface{}{ - {"id": 1, "uuid": "abc-123", "name": "Welcome", "subject": "Hello", "category": "transactional", "created_at": "2024-01-01"}, - }) + json.NewEncoder(w).Encode(listBody()) }) defer cleanup() @@ -77,9 +84,7 @@ func TestTemplatesList(t *testing.T) { func TestTemplatesListJSON(t *testing.T) { f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode([]map[string]interface{}{ - {"id": 1, "uuid": "abc-123", "name": "Welcome", "subject": "Hello", "category": "transactional", "created_at": "2024-01-01"}, - }) + json.NewEncoder(w).Encode(listBody()) }) defer cleanup() @@ -95,15 +100,82 @@ func TestTemplatesListJSON(t *testing.T) { } output := buf.String() - var result []map[string]interface{} + var result struct { + Data []map[string]interface{} `json:"data"` + Pagination map[string]interface{} `json:"pagination"` + } if err := json.Unmarshal([]byte(output), &result); err != nil { - t.Fatalf("output is not valid JSON: %v\noutput:\n%s", err, output) + t.Fatalf("output is not a JSON object: %v\noutput:\n%s", err, output) + } + if len(result.Data) != 1 { + t.Fatalf("expected 1 template, got %d", len(result.Data)) + } + if result.Data[0]["name"] != "Welcome" { + t.Errorf("expected name 'Welcome', got %v", result.Data[0]["name"]) } - if len(result) != 1 { - t.Fatalf("expected 1 template, got %d", len(result)) + if result.Pagination["next_token"] != float64(2) { + t.Errorf("expected pagination.next_token 2, got %v", result.Pagination["next_token"]) + } +} + +func TestTemplatesListNextPage(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(listBody()) + }) + defer cleanup() + + cmd := templates.NewCmdTemplates(f) + cmd.SetArgs([]string{"list"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Next page: --token 2") { + t.Errorf("expected next page footer, got:\n%s", buf.String()) + } +} + +func TestTemplatesListQuery(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("per_page"); got != "10" { + t.Errorf("expected per_page=10, got %q", got) + } + if got := r.URL.Query().Get("token"); got != "2" { + t.Errorf("expected token=2, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(listBody()) + }) + defer cleanup() + + cmd := templates.NewCmdTemplates(f) + cmd.SetArgs([]string{"list", "--per-page", "10", "--token", "2"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) } - if result[0]["name"] != "Welcome" { - t.Errorf("expected name 'Welcome', got %v", result[0]["name"]) +} + +func TestTemplatesListOmitsUnsetQuery(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "" { + t.Errorf("expected no query, got %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(listBody()) + }) + defer cleanup() + + cmd := templates.NewCmdTemplates(f) + cmd.SetArgs([]string{"list"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) } } @@ -112,14 +184,14 @@ func TestTemplatesGet(t *testing.T) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } - if !strings.HasSuffix(r.URL.Path, "/api/accounts/123/email_templates/1") { + if !strings.HasSuffix(r.URL.Path, "/api/accounts/123/templates/1") { t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]interface{}{"data": map[string]interface{}{ "id": 1, "uuid": "abc-123", "name": "Welcome", "subject": "Hello", "category": "transactional", "created_at": "2024-01-01", - }) + }}) }) defer cleanup() @@ -143,7 +215,7 @@ func TestTemplatesCreate(t *testing.T) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } - if r.URL.Path != "/api/accounts/123/email_templates" { + if r.URL.Path != "/api/accounts/123/templates" { t.Errorf("unexpected path: %s", r.URL.Path) } @@ -153,30 +225,30 @@ func TestTemplatesCreate(t *testing.T) { t.Fatalf("failed to unmarshal body: %v", err) } - tmpl, ok := payload["email_template"].(map[string]interface{}) - if !ok { - t.Fatal("expected 'email_template' key in body") + if _, ok := payload["email_template"]; ok { + t.Error("expected a flat body without 'email_template' key") } - if tmpl["name"] != "New" { - t.Errorf("expected name 'New', got %v", tmpl["name"]) + if payload["name"] != "New" { + t.Errorf("expected name 'New', got %v", payload["name"]) } - if tmpl["subject"] != "Hello {{name}}" { - t.Errorf("expected subject 'Hello {{name}}', got %v", tmpl["subject"]) + if payload["subject"] != "Hello {{name}}" { + t.Errorf("expected subject 'Hello {{name}}', got %v", payload["subject"]) } - if tmpl["body_html"] != "

Hi

" { - t.Errorf("expected body_html '

Hi

', got %v", tmpl["body_html"]) + if payload["body_html"] != "

Hi

" { + t.Errorf("expected body_html '

Hi

', got %v", payload["body_html"]) } - if tmpl["body_text"] != "" { - t.Errorf("expected body_text '', got %v", tmpl["body_text"]) + if payload["body_text"] != "" { + t.Errorf("expected body_text '', got %v", payload["body_text"]) } - if tmpl["category"] != "" { - t.Errorf("expected category '', got %v", tmpl["category"]) + if payload["category"] != "General" { + t.Errorf("expected category 'General', got %v", payload["category"]) } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "id": 2, "uuid": "def-456", "name": "New", "subject": "Hello {{name}}", "category": "", "created_at": "2024-01-01", - }) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{"data": map[string]interface{}{ + "id": 2, "uuid": "def-456", "name": "New", "subject": "Hello {{name}}", "category": "General", "created_at": "2024-01-01", + }}) }) defer cleanup() @@ -218,7 +290,7 @@ func TestTemplatesUpdate(t *testing.T) { if r.Method != http.MethodPatch { t.Errorf("expected PATCH, got %s", r.Method) } - if !strings.HasSuffix(r.URL.Path, "/api/accounts/123/email_templates/1") { + if !strings.HasSuffix(r.URL.Path, "/api/accounts/123/templates/1") { t.Errorf("unexpected path: %s", r.URL.Path) } @@ -228,18 +300,20 @@ func TestTemplatesUpdate(t *testing.T) { t.Fatalf("failed to unmarshal body: %v", err) } - tmpl, ok := payload["email_template"].(map[string]interface{}) - if !ok { - t.Fatal("expected 'email_template' key in body") + if _, ok := payload["email_template"]; ok { + t.Error("expected a flat body without 'email_template' key") } - if tmpl["name"] != "Updated" { - t.Errorf("expected name 'Updated', got %v", tmpl["name"]) + if payload["name"] != "Updated" { + t.Errorf("expected name 'Updated', got %v", payload["name"]) + } + if len(payload) != 1 { + t.Errorf("expected only changed flags in body, got %v", payload) } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ + json.NewEncoder(w).Encode(map[string]interface{}{"data": map[string]interface{}{ "id": 1, "uuid": "abc-123", "name": "Updated", "subject": "Hello", "category": "transactional", "created_at": "2024-01-01", - }) + }}) }) defer cleanup() @@ -263,10 +337,10 @@ func TestTemplatesDelete(t *testing.T) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) } - if !strings.HasSuffix(r.URL.Path, "/api/accounts/123/email_templates/1") { + if !strings.HasSuffix(r.URL.Path, "/api/accounts/123/templates/1") { t.Errorf("unexpected path: %s", r.URL.Path) } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) }) defer cleanup() @@ -284,3 +358,42 @@ func TestTemplatesDelete(t *testing.T) { t.Errorf("expected output to contain 'deleted successfully', got:\n%s", output) } } + +func TestTemplatesCreateKeepsExplicitCategory(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&payload) + if payload["category"] != "Promo" { + t.Errorf("expected category 'Promo', got %v", payload["category"]) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"data":{"id":2,"name":"New"}}`)) + }) + defer cleanup() + + cmd := templates.NewCmdTemplates(f) + cmd.SetArgs([]string{"create", "--name", "New", "--subject", "Hi", "--category", "Promo"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTemplatesUpdateRequiresAttribute(t *testing.T) { + f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + t.Error("unexpected request") + }) + defer cleanup() + + cmd := templates.NewCmdTemplates(f) + cmd.SetArgs([]string{"update", "--id", "1"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "at least one attribute flag is required") { + t.Fatalf("expected attribute flag error, got: %v", err) + } +} diff --git a/internal/commands/templates/update.go b/internal/commands/templates/update.go index 0484d12..876f4de 100644 --- a/internal/commands/templates/update.go +++ b/internal/commands/templates/update.go @@ -36,38 +36,35 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { return err } - path := cmdutil.AccountPath("email_templates", fmt.Sprintf("%d", opts.ID)) + path := cmdutil.AccountPath("templates", fmt.Sprintf("%d", opts.ID)) - templateFields := map[string]interface{}{} + body := map[string]interface{}{} if cmd.Flags().Changed("name") { - templateFields["name"] = opts.Name + body["name"] = opts.Name } if cmd.Flags().Changed("subject") { - templateFields["subject"] = opts.Subject + body["subject"] = opts.Subject } if cmd.Flags().Changed("body-html") { - templateFields["body_html"] = opts.BodyHTML + body["body_html"] = opts.BodyHTML } if cmd.Flags().Changed("body-text") { - templateFields["body_text"] = opts.BodyText + body["body_text"] = opts.BodyText } if cmd.Flags().Changed("category") { - templateFields["category"] = opts.Category + body["category"] = opts.Category } - - body := map[string]interface{}{ - "email_template": templateFields, + if len(body) == 0 { + return fmt.Errorf("at least one attribute flag is required") } - var result Template - if err := c.Patch(context.Background(), client.BaseGeneral, path, body, &result); err != nil { + var resp templateResponse + if err := c.Patch(context.Background(), client.BaseGeneral, path, body, &resp); err != nil { return err } format := cmdutil.GetOutputFormat() - output.Print(f.IOStreams.Out, format, result, templateColumns) - - return nil + return output.Print(f.IOStreams.Out, format, resp.Data, templateColumns) }, } diff --git a/skills/mailtrap-cli/SKILL.md b/skills/mailtrap-cli/SKILL.md index dc8d68f..c8dc7c9 100644 --- a/skills/mailtrap-cli/SKILL.md +++ b/skills/mailtrap-cli/SKILL.md @@ -38,6 +38,7 @@ Table and text output show a summary and, for paginated lists, a `Next page: --< | `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` | +| `templates 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. diff --git a/skills/mailtrap-cli/references/templates.md b/skills/mailtrap-cli/references/templates.md index e67f409..fe7a782 100644 --- a/skills/mailtrap-cli/references/templates.md +++ b/skills/mailtrap-cli/references/templates.md @@ -8,9 +8,12 @@ Detailed flag specifications for `mailtrap templates` commands. List all email templates for the account. -No additional flags. +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--per-page` | int | No | Number of templates per page (default 50, max 100) | +| `--token` | int | No | Page number to retrieve (page-token pagination) | -**Output:** Table/JSON of templates with ID, name, subject, and category. +**Output:** Table of templates with ID, UUID, name, subject, category and creation time, followed by `Next page: --token N` when more pages exist. With `--output json`, the full response object is printed: `.data` holds the templates and `.pagination.next_token` the next page (`null` on the last page). --- @@ -20,7 +23,7 @@ Get a specific email template. | Flag | Type | Required | Description | |------|------|----------|-------------| -| `--id` | string | Yes | Template ID | +| `--id` | int | Yes | Template ID | --- @@ -34,7 +37,7 @@ Create a new email template. | `--subject` | string | Yes | Template subject line | | `--body-html` | string | No | HTML body content | | `--body-text` | string | No | Plain text body content | -| `--category` | string | No | Template category | +| `--category` | string | No | Template category (default `General`) | **Example:** ```bash @@ -53,7 +56,7 @@ Update an existing email template. | Flag | Type | Required | Description | |------|------|----------|-------------| -| `--id` | string | Yes | Template ID | +| `--id` | int | Yes | Template ID | | `--name` | string | No | New template name | | `--subject` | string | No | New subject line | | `--body-html` | string | No | New HTML body | @@ -70,4 +73,4 @@ Delete an email template. | Flag | Type | Required | Description | |------|------|----------|-------------| -| `--id` | string | Yes | Template ID | +| `--id` | int | Yes | Template ID | From 778eba21d8a8a772520a23d8b83c06430213ae72 Mon Sep 17 00:00:00 2001 From: IzikAJ Date: Tue, 6 Oct 2026 10:08:40 +0300 Subject: [PATCH 2/5] Note that the /api/templates endpoints are experimental --- internal/commands/templates/templates.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/commands/templates/templates.go b/internal/commands/templates/templates.go index 4e05ec5..13da01f 100644 --- a/internal/commands/templates/templates.go +++ b/internal/commands/templates/templates.go @@ -9,6 +9,7 @@ func NewCmdTemplates(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "templates", Short: "Manage email templates", + Long: "Manage email templates.\n\nUses the experimental /api/templates endpoints; their request and response shapes may change before general availability.", } cmd.AddCommand(NewCmdList(f)) From 16744975affb02fa8101fc14df32cf88e51eb7f1 Mon Sep 17 00:00:00 2001 From: IzikAJ Date: Tue, 6 Oct 2026 11:48:57 +0300 Subject: [PATCH 3/5] docs: qualify the total-count claim for paginated lists --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a38bb76..7568aaf 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ 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`, `templates list`, `tracking-opt-outs list`) print the full response object, so the next-page cursor and total count are available to scripts: +With `--output json`, commands print the API response as returned. Paginated lists (`inbound messages list`, `inbound threads list`, `email-logs list`, `email-campaigns list`, `templates list`, `tracking-opt-outs list`) print the full response object, so the next-page cursor and, where the API provides one, the total count are available to scripts: ```bash mailtrap inbound messages list --inbox-id 735 -o json | jq -r '.last_id // empty' From 4e1f22d18dcc009ce94d32a8af4967fb4d543962 Mon Sep 17 00:00:00 2001 From: IzikAJ Date: Tue, 6 Oct 2026 16:15:16 +0300 Subject: [PATCH 4/5] Keep --per-page in the templates next-page hint - Repeat --per-page in the "Next page:" hint. Without per_page, the API reads the next token at its default page size of 50. - Port the output.Page NextArgs field unchanged from #17, so the two PRs merge in either order. - Cover the JSON output of get/create/update with body_html, body_text and updated_at in the response. - Show the experimental-endpoints note in every subcommand's help. - Say "one page at a time" in the list help and the skill reference. --- docs/TEST_PLAN.md | 1 + internal/commands/templates/list.go | 7 +- internal/commands/templates/templates.go | 8 +- internal/commands/templates/templates_test.go | 83 +++++++++++++++++++ internal/output/page.go | 7 +- internal/output/page_test.go | 14 ++++ skills/mailtrap-cli/SKILL.md | 2 +- skills/mailtrap-cli/references/templates.md | 4 +- 8 files changed, 119 insertions(+), 7 deletions(-) diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md index 6acadc2..e71d8c3 100644 --- a/docs/TEST_PLAN.md +++ b/docs/TEST_PLAN.md @@ -126,6 +126,7 @@ Tests are organized by endpoint group. Each test specifies: | 4.5 | Update template | `mailtrap templates update --id --name "test-tpl-updated"` | Updated template | | 4.6 | Delete template | `mailtrap templates delete --id ` | Success message | | 4.7 | Get missing ID | `mailtrap templates get` | Error: `--id is required` | +| 4.8 | Next page keeps page size | `mailtrap templates list --per-page 1`, then run the `Next page:` hint | Hint is `--token 2 --per-page 1`; it prints the second template | **Cleanup:** Delete created template. diff --git a/internal/commands/templates/list.go b/internal/commands/templates/list.go index 900b7ae..039c20f 100644 --- a/internal/commands/templates/list.go +++ b/internal/commands/templates/list.go @@ -41,7 +41,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List all email templates", + Short: "List email templates, one page at a time", RunE: func(cmd *cobra.Command, args []string) error { c, err := f.NewClient() if err != nil { @@ -53,8 +53,11 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { } query := url.Values{} + page := templatesPage if cmd.Flags().Changed("per-page") { query.Set("per_page", fmt.Sprintf("%d", perPage)) + // Without per_page the API reads the next token at its default page size. + page.NextArgs = fmt.Sprintf("--per-page %d", perPage) } if cmd.Flags().Changed("token") { query.Set("token", fmt.Sprintf("%d", token)) @@ -65,7 +68,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return err } - return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, templatesPage, templateColumns) + return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, page, templateColumns) }, } diff --git a/internal/commands/templates/templates.go b/internal/commands/templates/templates.go index 13da01f..7bdb837 100644 --- a/internal/commands/templates/templates.go +++ b/internal/commands/templates/templates.go @@ -5,11 +5,13 @@ import ( "github.com/spf13/cobra" ) +const experimentalNote = "Uses the experimental /api/templates endpoints; their request and response shapes may change before general availability." + func NewCmdTemplates(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "templates", Short: "Manage email templates", - Long: "Manage email templates.\n\nUses the experimental /api/templates endpoints; their request and response shapes may change before general availability.", + Long: "Manage email templates.\n\n" + experimentalNote, } cmd.AddCommand(NewCmdList(f)) @@ -18,5 +20,9 @@ func NewCmdTemplates(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdUpdate(f)) cmd.AddCommand(NewCmdDelete(f)) + for _, sub := range cmd.Commands() { + sub.Long = sub.Short + ".\n\n" + experimentalNote + } + return cmd } diff --git a/internal/commands/templates/templates_test.go b/internal/commands/templates/templates_test.go index 56dbe48..7c45e59 100644 --- a/internal/commands/templates/templates_test.go +++ b/internal/commands/templates/templates_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strings" "testing" @@ -138,6 +139,39 @@ func TestTemplatesListNextPage(t *testing.T) { } } +func TestTemplatesListNextPageRunsWithSamePerPage(t *testing.T) { + var requests []string + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(listBody()) + }) + defer cleanup() + + cmd := templates.NewCmdTemplates(f) + cmd.SetArgs([]string{"list", "--per-page", "1"}) + cmd.SetOut(buf) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, hint, found := strings.Cut(buf.String(), "Next page: ") + if !found { + t.Fatalf("expected next page footer, got:\n%s", buf.String()) + } + + cmd = templates.NewCmdTemplates(f) + cmd.SetArgs(append([]string{"list"}, strings.Fields(hint)...)) + cmd.SetOut(buf) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error running the footer's command: %v", err) + } + + if want := []string{"per_page=1", "per_page=1&token=2"}; !reflect.DeepEqual(requests, want) { + t.Errorf("expected queries %v, got %v", want, requests) + } +} + func TestTemplatesListQuery(t *testing.T) { f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { if got := r.URL.Query().Get("per_page"); got != "10" { @@ -397,3 +431,52 @@ func TestTemplatesUpdateRequiresAttribute(t *testing.T) { t.Fatalf("expected attribute flag error, got: %v", err) } } + +func TestTemplatesJSONKeepsTemplateAsIs(t *testing.T) { + template := `{"id":1,"uuid":"abc-123","name":"Welcome","subject":"Hello","category":"General",` + + `"body_html":null,"body_text":"Hi","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z"}` + + for _, args := range [][]string{ + {"get", "--id", "1"}, + {"create", "--name", "Welcome", "--subject", "Hello", "--body-text", "Hi"}, + {"update", "--id", "1", "--name", "Welcome"}, + } { + t.Run(args[0], func(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"data":`+template+`}`) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := templates.NewCmdTemplates(f) + cmd.SetArgs(args) + 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(template), &want) + if !reflect.DeepEqual(got, want) { + t.Errorf("expected the template unchanged\nwant: %v\ngot: %v", want, got) + } + }) + } +} + +func TestTemplatesSubcommandsHelpShowsExperimentalNote(t *testing.T) { + f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + for _, sub := range templates.NewCmdTemplates(f).Commands() { + if !strings.Contains(sub.Long, "experimental /api/templates endpoints") { + t.Errorf("expected %q help to note the experimental endpoints, got %q", sub.Name(), sub.Long) + } + } +} diff --git a/internal/output/page.go b/internal/output/page.go index f93bb5e..3aa25af 100644 --- a/internal/output/page.go +++ b/internal/output/page.go @@ -13,6 +13,7 @@ type Page struct { 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 + NextArgs string // extra flags the next request repeats } // PrintPage prints a paginated list response. JSON output is the body as the @@ -36,7 +37,11 @@ func PrintPage(w io.Writer, format Format, body json.RawMessage, page Page, colu footer = append(footer, "Total: "+total) } if cursor := lookup(body, page.Cursor...); cursor != "" { - footer = append(footer, fmt.Sprintf("Next page: --%s %s", page.CursorFlag, cursor)) + next := fmt.Sprintf("Next page: --%s %s", page.CursorFlag, cursor) + if page.NextArgs != "" { + next += " " + page.NextArgs + } + footer = append(footer, next) } if len(footer) > 0 { fmt.Fprintln(w) diff --git a/internal/output/page_test.go b/internal/output/page_test.go index 8b3dfdb..f97d36c 100644 --- a/internal/output/page_test.go +++ b/internal/output/page_test.go @@ -45,6 +45,20 @@ func TestPrintPage_TableShowsTotalAndCursor(t *testing.T) { } } +func TestPrintPage_NextArgsFollowCursor(t *testing.T) { + var buf bytes.Buffer + body := json.RawMessage(`{"data":[{"id":"a"}],"last_id":"a"}`) + page := Page{Items: "data", Cursor: []string{"last_id"}, CursorFlag: "last-id", NextArgs: `--search "acme"`} + + if err := PrintPage(&buf, FormatTable, body, page, pageCols); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), `Next page: --last-id a --search "acme"`) { + t.Errorf("expected next-page hint to repeat NextArgs, got:\n%s", buf.String()) + } +} + func TestPrintPage_NestedNumericCursor(t *testing.T) { var buf bytes.Buffer body := json.RawMessage(`{"data":[{"id":1}],"pagination":{"token":1,"next_token":2}}`) diff --git a/skills/mailtrap-cli/SKILL.md b/skills/mailtrap-cli/SKILL.md index c8dc7c9..5cfb485 100644 --- a/skills/mailtrap-cli/SKILL.md +++ b/skills/mailtrap-cli/SKILL.md @@ -38,7 +38,7 @@ Table and text output show a summary and, for paginated lists, a `Next page: --< | `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` | -| `templates list` | `.data` | — | `.pagination.next_token` → `--token` | +| `templates list` | `.data` | — | `.pagination.next_token` → `--token` (repeat `--per-page`) | | `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. diff --git a/skills/mailtrap-cli/references/templates.md b/skills/mailtrap-cli/references/templates.md index fe7a782..b9e87fd 100644 --- a/skills/mailtrap-cli/references/templates.md +++ b/skills/mailtrap-cli/references/templates.md @@ -6,14 +6,14 @@ Detailed flag specifications for `mailtrap templates` commands. ## templates list -List all email templates for the account. +List email templates for the account, one page at a time. | Flag | Type | Required | Description | |------|------|----------|-------------| | `--per-page` | int | No | Number of templates per page (default 50, max 100) | | `--token` | int | No | Page number to retrieve (page-token pagination) | -**Output:** Table of templates with ID, UUID, name, subject, category and creation time, followed by `Next page: --token N` when more pages exist. With `--output json`, the full response object is printed: `.data` holds the templates and `.pagination.next_token` the next page (`null` on the last page). +**Output:** Table of templates with ID, UUID, name, subject, category and creation time, followed by `Next page: --token N` when more pages exist (`Next page: --token N --per-page M` when `--per-page` was set, because the next page must use the same page size). With `--output json`, the full response object is printed: `.data` holds the templates and `.pagination.next_token` the next page (`null` on the last page). --- From 7e3499861defaf32a766d7b76a6b065bdb00f814 Mon Sep 17 00:00:00 2001 From: IzikAJ Date: Wed, 7 Oct 2026 09:20:24 +0300 Subject: [PATCH 5/5] docs: create two templates before the next-page test --- docs/TEST_PLAN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md index e71d8c3..849a892 100644 --- a/docs/TEST_PLAN.md +++ b/docs/TEST_PLAN.md @@ -126,9 +126,9 @@ Tests are organized by endpoint group. Each test specifies: | 4.5 | Update template | `mailtrap templates update --id --name "test-tpl-updated"` | Updated template | | 4.6 | Delete template | `mailtrap templates delete --id ` | Success message | | 4.7 | Get missing ID | `mailtrap templates get` | Error: `--id is required` | -| 4.8 | Next page keeps page size | `mailtrap templates list --per-page 1`, then run the `Next page:` hint | Hint is `--token 2 --per-page 1`; it prints the second template | +| 4.8 | Next page keeps page size | Create `test-tpl-a` and `test-tpl-b` as in 4.4, run `mailtrap templates list --per-page 1`, then run the `Next page:` hint | Hint is `--token 2 --per-page 1`; it prints one template that is not on the first page | -**Cleanup:** Delete created template. +**Cleanup:** Delete the templates created in 4.4 and 4.8. ## 5. Suppressions