diff --git a/README.md b/README.md
index 34572b0..7568aaf 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, 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'
diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md
index 74e558e..849a892 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,14 +120,15 @@ 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` |
+| 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
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..039c20f 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,10 +27,21 @@ 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",
+ Short: "List email templates, one page at a time",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := f.NewClient()
if err != nil {
@@ -42,19 +52,28 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
return err
}
- path := cmdutil.AccountPath("email_templates")
+ 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))
+ }
- 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, page, 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.go b/internal/commands/templates/templates.go
index 4e05ec5..7bdb837 100644
--- a/internal/commands/templates/templates.go
+++ b/internal/commands/templates/templates.go
@@ -5,10 +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\n" + experimentalNote,
}
cmd.AddCommand(NewCmdList(f))
@@ -17,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 c9b2519..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"
@@ -40,12 +41,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 +63,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 +85,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 +101,115 @@ 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 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())
}
- if len(result) != 1 {
- t.Fatalf("expected 1 template, got %d", len(result))
+}
+
+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" {
+ 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 +218,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 +249,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 +259,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 +324,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 +334,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 +371,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 +392,91 @@ 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)
+ }
+}
+
+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/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/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 dc8d68f..5cfb485 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` (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 e67f409..b9e87fd 100644
--- a/skills/mailtrap-cli/references/templates.md
+++ b/skills/mailtrap-cli/references/templates.md
@@ -6,11 +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.
-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 (`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).
---
@@ -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 |