From 508f252ef082387842f1b14c72523dbf118f2d92 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:26:42 +0200 Subject: [PATCH 1/4] MT-23076: add --expires-at flag to tokens create --- internal/commands/tokens/create.go | 5 +++++ internal/commands/tokens/tokens.go | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/internal/commands/tokens/create.go b/internal/commands/tokens/create.go index d306088..9634d38 100644 --- a/internal/commands/tokens/create.go +++ b/internal/commands/tokens/create.go @@ -15,6 +15,7 @@ import ( func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { var name string var permissions string + var expiresAt string cmd := &cobra.Command{ Use: "create", @@ -57,6 +58,9 @@ Example: "name": name, "resources": resources, } + if cmd.Flags().Changed("expires-at") { + body["expires_at"] = expiresAtValue(expiresAt) + } var token APIToken if err := c.Post(context.Background(), client.BaseGeneral, path, body, &token); err != nil { @@ -70,6 +74,7 @@ Example: cmd.Flags().StringVar(&name, "name", "", "API token name (required)") cmd.Flags().StringVar(&permissions, "permissions", "", `Permissions JSON array (required), e.g. '[{"resource_type":"account","resource_id":123,"access_level":100}]'`) + cmd.Flags().StringVar(&expiresAt, "expires-at", "", expiresAtUsage) return cmd } diff --git a/internal/commands/tokens/tokens.go b/internal/commands/tokens/tokens.go index e2a97aa..56f32cf 100644 --- a/internal/commands/tokens/tokens.go +++ b/internal/commands/tokens/tokens.go @@ -1,10 +1,24 @@ package tokens import ( + "strings" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" "github.com/spf13/cobra" ) +const expiresAtUsage = "Token expiration as an ISO 8601 date-time; pass 'never' for a token that never expires; omit for the server default (a 1-year default is being rolled out)" + +// expiresAtValue maps the --expires-at flag value to the request body value: +// "never" (case-insensitive) becomes an explicit JSON null, anything else is +// passed through verbatim for the server to validate. +func expiresAtValue(expiresAt string) interface{} { + if strings.EqualFold(expiresAt, "never") { + return nil + } + return expiresAt +} + func NewCmdTokens(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "tokens", From 67ade2e4d70cf28f302cc059c1fa297d20351e23 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:27:07 +0200 Subject: [PATCH 2/4] MT-23076: add --expires-at flag to tokens reset --- internal/commands/tokens/reset.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/commands/tokens/reset.go b/internal/commands/tokens/reset.go index 9e87f53..73f1192 100644 --- a/internal/commands/tokens/reset.go +++ b/internal/commands/tokens/reset.go @@ -13,6 +13,7 @@ import ( func NewCmdReset(f *cmdutil.Factory) *cobra.Command { var tokenID string + var expiresAt string cmd := &cobra.Command{ Use: "reset", @@ -34,8 +35,13 @@ func NewCmdReset(f *cmdutil.Factory) *cobra.Command { path := cmdutil.AccountPath("api_tokens", fmt.Sprintf("%s", tokenID), "reset") + var body interface{} + if cmd.Flags().Changed("expires-at") { + body = map[string]interface{}{"expires_at": expiresAtValue(expiresAt)} + } + var resp interface{} - if err := c.Post(context.Background(), client.BaseGeneral, path, nil, &resp); err != nil { + if err := c.Post(context.Background(), client.BaseGeneral, path, body, &resp); err != nil { return err } @@ -50,6 +56,7 @@ func NewCmdReset(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().StringVar(&tokenID, "id", "", "API token ID") + cmd.Flags().StringVar(&expiresAt, "expires-at", "", expiresAtUsage) return cmd } From d4da4e7394fcfb24891e9c2c24b2cea68972d098 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:28:35 +0200 Subject: [PATCH 3/4] MT-23076: cover token expiration flag with tests --- internal/commands/tokens/tokens_test.go | 158 ++++++++++++++++++++++++ 1 file changed, 158 insertions(+) diff --git a/internal/commands/tokens/tokens_test.go b/internal/commands/tokens/tokens_test.go index 6654887..f50cadf 100644 --- a/internal/commands/tokens/tokens_test.go +++ b/internal/commands/tokens/tokens_test.go @@ -186,6 +186,9 @@ func TestTokensCreate(t *testing.T) { if len(resources) != 1 { t.Errorf("expected 1 resource, got %d", len(resources)) } + if _, ok := payload["expires_at"]; ok { + t.Errorf("expected no expires_at key, got %v", payload["expires_at"]) + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ @@ -209,6 +212,94 @@ func TestTokensCreate(t *testing.T) { } } +func TestTokensCreateWithExpiresAt(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse request body: %v", err) + } + + if payload["expires_at"] != "2027-06-01T00:00:00Z" { + t.Errorf("expected expires_at '2027-06-01T00:00:00Z', got %v", payload["expires_at"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 2, "name": "new-token", "last_4_digits": "efgh", "created_by": "", "expires_at": "2027-06-01T00:00:00Z", "token": "full-token-value", + }) + }) + defer cleanup() + + cmd := tokens.NewCmdTokens(f) + cmd.SetArgs([]string{"create", "--name", "new-token", "--permissions", `[{"resource_type":"account","resource_id":123,"access_level":100}]`, "--expires-at", "2027-06-01T00:00:00Z"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTokensCreateExpiresAtNever(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse request body: %v", err) + } + + value, ok := payload["expires_at"] + if !ok { + t.Error("expected expires_at key to be present") + } + if value != nil { + t.Errorf("expected expires_at to be null, got %v", value) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 2, "name": "new-token", "last_4_digits": "efgh", "created_by": "", "expires_at": nil, "token": "full-token-value", + }) + }) + defer cleanup() + + cmd := tokens.NewCmdTokens(f) + cmd.SetArgs([]string{"create", "--name", "new-token", "--permissions", `[{"resource_type":"account","resource_id":123,"access_level":100}]`, "--expires-at", "never"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTokensCreateExpiresAtRejected(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "expires_at must be in the future", + }) + }) + defer cleanup() + + cmd := tokens.NewCmdTokens(f) + cmd.SetArgs([]string{"create", "--name", "new-token", "--permissions", `[{"resource_type":"account","resource_id":123,"access_level":100}]`, "--expires-at", "2020-01-01T00:00:00Z"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "API error 422") { + t.Errorf("expected error to contain 'API error 422', got: %v", err) + } + if !strings.Contains(err.Error(), "expires_at must be in the future") { + t.Errorf("expected error to contain the server message, got: %v", err) + } +} + func TestTokensCreateMissingName(t *testing.T) { f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) defer cleanup() @@ -294,6 +385,11 @@ func TestTokensReset(t *testing.T) { t.Errorf("unexpected path: %s", r.URL.Path) } + body, _ := io.ReadAll(r.Body) + if len(body) != 0 { + t.Errorf("expected empty request body, got %s", body) + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "token": "new-token-value", @@ -315,3 +411,65 @@ func TestTokensReset(t *testing.T) { t.Errorf("expected output to contain 'new-token-value', got:\n%s", output) } } + +func TestTokensResetWithExpiresAt(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse request body: %v", err) + } + + if payload["expires_at"] != "2027-06-01T00:00:00Z" { + t.Errorf("expected expires_at '2027-06-01T00:00:00Z', got %v", payload["expires_at"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "token": "new-token-value", + }) + }) + defer cleanup() + + cmd := tokens.NewCmdTokens(f) + cmd.SetArgs([]string{"reset", "--id", "1", "--expires-at", "2027-06-01T00:00:00Z"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTokensResetExpiresAtNeverCaseInsensitive(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse request body: %v", err) + } + + value, ok := payload["expires_at"] + if !ok { + t.Error("expected expires_at key to be present") + } + if value != nil { + t.Errorf("expected expires_at to be null, got %v", value) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "token": "new-token-value", + }) + }) + defer cleanup() + + cmd := tokens.NewCmdTokens(f) + cmd.SetArgs([]string{"reset", "--id", "1", "--expires-at", "NEVER"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} From dc2dc2658b5eb8716cd66c248328b7df8744b875 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:29:06 +0200 Subject: [PATCH 4/4] MT-23076: document --expires-at in README and accounts reference --- README.md | 6 +++++- skills/mailtrap-cli/references/accounts.md | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ad7ebd6..26f6ac1 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ mailtrap webhooks create --url "https://example.com/hooks" --type email_sending mailtrap webhooks update --id 1 --active=false --event-types delivery,bounce,unsubscribe mailtrap webhooks delete --id 1 +# API tokens (--expires-at takes an ISO 8601 date-time or 'never'; omit it for the server default) +mailtrap tokens create --name "ci-token" --permissions '[{"resource_type":"account","resource_id":123,"access_level":100}]' --expires-at 2027-06-01T00:00:00Z +mailtrap tokens reset --id 42 --expires-at never + # Contacts mailtrap contacts create --email "user@example.com" --first-name "John" mailtrap contact-lists list @@ -162,7 +166,7 @@ mailtrap domains list --output text | **Accounts** | `accounts list` | | **Account Access** | `account-access list`, `account-access remove` | | **Permissions** | `permissions resources`, `permissions bulk-update` | -| **Tokens** | `tokens list`, `tokens get`, `tokens create`, `tokens delete`, `tokens reset` | +| **Tokens** | `tokens list`, `tokens get`, `tokens create [--expires-at]`, `tokens delete`, `tokens reset [--expires-at]` | | **Billing** | `billing usage` | | **Organizations** | `organizations list-sub-accounts`, `organizations create-sub-account` | | **Config** | `configure`, `completion [bash\|zsh\|fish\|powershell]` | diff --git a/skills/mailtrap-cli/references/accounts.md b/skills/mailtrap-cli/references/accounts.md index 87fbe46..7bc2730 100644 --- a/skills/mailtrap-cli/references/accounts.md +++ b/skills/mailtrap-cli/references/accounts.md @@ -58,6 +58,7 @@ Create a new API token. |------|------|----------|-------------| | `--name` | string | Yes | Token name | | `--permissions` | string | Yes | Permissions JSON array, e.g. `'[{"resource_type":"account","resource_id":123,"access_level":100}]'` | +| `--expires-at` | string | No | Token expiration as an ISO 8601 date-time, e.g. `2027-06-01T00:00:00Z`. Pass `never` for a token that never expires. Omit for the server default (a 1-year default is being rolled out) | **Note:** The token value is shown only once in the response. Store it securely. @@ -80,6 +81,7 @@ Reset (regenerate) an API token. | Flag | Type | Required | Description | |------|------|----------|-------------| | `--id` | string | Yes | Token ID | +| `--expires-at` | string | No | New token expiration as an ISO 8601 date-time, e.g. `2027-06-01T00:00:00Z`. Pass `never` for a token that never expires. Omit for the server default (a 1-year default is being rolled out) | **Note:** The new token value is shown only once. The old token stops working immediately.