Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]` |
Expand Down
5 changes: 5 additions & 0 deletions internal/commands/tokens/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
9 changes: 8 additions & 1 deletion internal/commands/tokens/reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

func NewCmdReset(f *cmdutil.Factory) *cobra.Command {
var tokenID string
var expiresAt string

cmd := &cobra.Command{
Use: "reset",
Expand All @@ -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
}

Expand All @@ -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
}
14 changes: 14 additions & 0 deletions internal/commands/tokens/tokens.go
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
158 changes: 158 additions & 0 deletions internal/commands/tokens/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}{
Expand All @@ -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()
Expand Down Expand Up @@ -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",
Expand All @@ -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)
}
}
2 changes: 2 additions & 0 deletions skills/mailtrap-cli/references/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down