Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ mailtrap templates create --name "Welcome" --subject "Hello {{name}}" --body-htm
# Contacts
mailtrap contacts create --email "user@example.com" --first-name "John"
mailtrap contact-lists list
mailtrap contact-fields create --name "Company" --data-type text
mailtrap contact-fields create --name "Company" --data-type text --merge-tag "{{company}}"

# Sandboxes & projects
mailtrap projects list
Expand Down
6 changes: 4 additions & 2 deletions docs/TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ Tests are organized by endpoint group. Each test specifies:
|---|------|---------|----------|
| 9.1 | List contact fields | `mailtrap contact-fields list` | Table with field entries |
| 9.2 | Get contact field | `mailtrap contact-fields get --id <FIELD_ID>` | Single field details |
| 9.3 | Create contact field | `mailtrap contact-fields create --name "test-field" --type "string"` | New field in output |
| 9.3 | Create contact field | `mailtrap contact-fields create --name "test-field" --data-type text --merge-tag "{{test_field}}"` | New field in output |
| 9.4 | Update contact field | `mailtrap contact-fields update --id <NEW_ID> --name "test-field-updated"` | Updated field |
| 9.5 | Delete contact field | `mailtrap contact-fields delete --id <NEW_ID>` | Success message |
| 9.6 | Get missing ID | `mailtrap contact-fields get` | Error: `--id is required` |
Expand Down Expand Up @@ -304,7 +304,7 @@ Prerequisite: Send an email with an attachment to the sandbox.
|---|------|---------|----------|
| 18.1 | List tokens | `mailtrap tokens list` | Table with tokens (may require admin token) |
| 18.2 | Get token | `mailtrap tokens get --id <TOKEN_ID>` | Token details |
| 18.3 | Create token | `mailtrap tokens create --name "test-token"` | New token |
| 18.3 | Create token | `mailtrap tokens create --name "test-token" --permissions '[{"resource_type":"account","resource_id":<ACCOUNT_ID>,"access_level":100}]'` | New token |
| 18.4 | Reset token | `mailtrap tokens reset --id <NEW_ID>` | New token value |
| 18.5 | Delete token | `mailtrap tokens delete --id <NEW_ID>` | Success message |
| 18.6 | Get missing ID | `mailtrap tokens get` | Error: `--id is required` |
Expand Down Expand Up @@ -346,6 +346,8 @@ Prerequisite: Send an email with an attachment to the sandbox.
| B3 | **Medium** | NOT A BUG | `contacts list` returns 404 — the Mailtrap API has no `GET /contacts` endpoint. Contacts can only be managed individually (get/create/update/delete). No `list` subcommand exists in the CLI. |
| B4 | **Low** | NOT A BUG | `tokens list` returns "Access forbidden" — this is an API permission issue requiring an admin-level token, not a CLI bug. The error message is surfaced correctly. |
| B5 | **Low** | NOT A BUG | `stats get` requires `--start-date` — already enforced via `cobra.MarkFlagRequired("start-date")`. The CLI shows a clear error when omitted. |
| B6 | **High** | FIXED | `tokens create` — request body was wrapped in an `api_token` key (`{"api_token": {"name": ..., "resources": [...]}}`), but the API expects a flat body (`{"name": ..., "resources": [...]}`). Removed the wrapper in `tokens/create.go`. |
| B7 | **High** | FIXED | `contact-fields create` — the `--merge-tag` flag was missing entirely. The Mailtrap API requires `merge_tag` when creating a contact field, but the CLI only accepted `--name` and `--data-type`. Added `--merge-tag` flag, validation, and included `merge_tag` in the POST body. |

---

Expand Down
21 changes: 20 additions & 1 deletion internal/commands/contact_fields/contact_fields_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ func TestContactFieldsCreate(t *testing.T) {
if payload["data_type"] != "text" {
t.Errorf("expected data_type 'text', got %v", payload["data_type"])
}
if payload["merge_tag"] != "{{company}}" {
t.Errorf("expected merge_tag '{{company}}', got %v", payload["merge_tag"])
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
Expand All @@ -190,7 +193,7 @@ func TestContactFieldsCreate(t *testing.T) {
defer cleanup()

cmd := contact_fields.NewCmdContactFields(f)
cmd.SetArgs([]string{"create", "--name", "Company", "--data-type", "text"})
cmd.SetArgs([]string{"create", "--name", "Company", "--data-type", "text", "--merge-tag", "{{company}}"})
cmd.SetOut(buf)

err := cmd.Execute()
Expand Down Expand Up @@ -236,6 +239,22 @@ func TestContactFieldsCreateMissingDataType(t *testing.T) {
}
}

func TestContactFieldsCreateMissingMergeTag(t *testing.T) {
f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()

cmd := contact_fields.NewCmdContactFields(f)
cmd.SetArgs([]string{"create", "--name", "Company", "--data-type", "text"})

err := cmd.Execute()
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "--merge-tag is required") {
t.Errorf("expected error to contain '--merge-tag is required', got: %v", err)
}
}

func TestContactFieldsUpdate(t *testing.T) {
f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch {
Expand Down
6 changes: 6 additions & 0 deletions internal/commands/contact_fields/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
var name string
var dataType string
var mergeTag string

cmd := &cobra.Command{
Use: "create",
Expand All @@ -24,6 +25,9 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
if err := cmdutil.RequireFlag("data-type", dataType); err != nil {
return err
}
if err := cmdutil.RequireFlag("merge-tag", mergeTag); err != nil {
return err
}

c, err := f.NewClient()
if err != nil {
Expand All @@ -40,6 +44,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
body := map[string]interface{}{
"name": name,
"data_type": dataType,
"merge_tag": mergeTag,
}

var field ContactField
Expand All @@ -54,6 +59,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {

cmd.Flags().StringVar(&name, "name", "", "Contact field name (required)")
cmd.Flags().StringVar(&dataType, "data-type", "", "Data type: text, integer, float, boolean, date (required)")
cmd.Flags().StringVar(&mergeTag, "merge-tag", "", "Merge tag for the field (required)")

return cmd
}
6 changes: 3 additions & 3 deletions internal/commands/stats/by_category.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,21 @@ func NewCmdByCategory(f *cmdutil.Factory) *cobra.Command {
params.Add("sending_domain_ids[]", d)
}
for _, s := range opts.Streams {
params.Add("streams[]", s)
params.Add("sending_streams[]", s)
}
for _, cat := range opts.Categories {
params.Add("categories[]", cat)
}

fullPath := fmt.Sprintf("%s?%s", path, params.Encode())

var result []Stats
var result []CategoryStats
if err := c.Get(context.Background(), client.BaseGeneral, fullPath, nil, &result); err != nil {
return err
}

format := cmdutil.GetOutputFormat()
output.Print(f.IOStreams.Out, format, result, statsColumns)
output.Print(f.IOStreams.Out, format, result, categoryStatsColumns)

return nil
},
Expand Down
6 changes: 3 additions & 3 deletions internal/commands/stats/by_date.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,21 @@ func NewCmdByDate(f *cmdutil.Factory) *cobra.Command {
params.Add("sending_domain_ids[]", d)
}
for _, s := range opts.Streams {
params.Add("streams[]", s)
params.Add("sending_streams[]", s)
}
for _, cat := range opts.Categories {
params.Add("categories[]", cat)
}

fullPath := fmt.Sprintf("%s?%s", path, params.Encode())

var result []Stats
var result []DateStats
if err := c.Get(context.Background(), client.BaseGeneral, fullPath, nil, &result); err != nil {
return err
}

format := cmdutil.GetOutputFormat()
output.Print(f.IOStreams.Out, format, result, statsColumns)
output.Print(f.IOStreams.Out, format, result, dateStatsColumns)

return nil
},
Expand Down
6 changes: 3 additions & 3 deletions internal/commands/stats/by_domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,21 @@ func NewCmdByDomain(f *cmdutil.Factory) *cobra.Command {
params.Add("sending_domain_ids[]", d)
}
for _, s := range opts.Streams {
params.Add("streams[]", s)
params.Add("sending_streams[]", s)
}
for _, cat := range opts.Categories {
params.Add("categories[]", cat)
}

fullPath := fmt.Sprintf("%s?%s", path, params.Encode())

var result []Stats
var result []DomainStats
if err := c.Get(context.Background(), client.BaseGeneral, fullPath, nil, &result); err != nil {
return err
}

format := cmdutil.GetOutputFormat()
output.Print(f.IOStreams.Out, format, result, statsColumns)
output.Print(f.IOStreams.Out, format, result, domainStatsColumns)

return nil
},
Expand Down
6 changes: 3 additions & 3 deletions internal/commands/stats/by_esp.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,21 +45,21 @@ func NewCmdByESP(f *cmdutil.Factory) *cobra.Command {
params.Add("sending_domain_ids[]", d)
}
for _, s := range opts.Streams {
params.Add("streams[]", s)
params.Add("sending_streams[]", s)
}
for _, cat := range opts.Categories {
params.Add("categories[]", cat)
}

fullPath := fmt.Sprintf("%s?%s", path, params.Encode())

var result []Stats
var result []ESPStats
if err := c.Get(context.Background(), client.BaseGeneral, fullPath, nil, &result); err != nil {
return err
}

format := cmdutil.GetOutputFormat()
output.Print(f.IOStreams.Out, format, result, statsColumns)
output.Print(f.IOStreams.Out, format, result, espStatsColumns)

return nil
},
Expand Down
148 changes: 148 additions & 0 deletions internal/commands/stats/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package stats

import (
"context"
"encoding/json"
"fmt"
"net/url"

Expand Down Expand Up @@ -38,6 +39,153 @@ var statsColumns = []output.Column{
{Header: "SPAM RATE", Field: "spam_rate"},
}

// Grouped response types — the API wraps stats under a "stats" key alongside the grouping field.
// MarshalJSON on each type flattens the nested stats for table/text output.

type DomainStats struct {
SendingDomainID int `json:"sending_domain_id"`
Stats Stats `json:"stats"`
}

func (d DomainStats) MarshalJSON() ([]byte, error) {
type flat struct {
SendingDomainID int `json:"sending_domain_id"`
DeliveryCount int `json:"delivery_count"`
DeliveryRate float64 `json:"delivery_rate"`
BounceCount int `json:"bounce_count"`
BounceRate float64 `json:"bounce_rate"`
OpenCount int `json:"open_count"`
OpenRate float64 `json:"open_rate"`
ClickCount int `json:"click_count"`
ClickRate float64 `json:"click_rate"`
SpamCount int `json:"spam_count"`
SpamRate float64 `json:"spam_rate"`
}
return json.Marshal(flat{d.SendingDomainID, d.Stats.DeliveryCount, d.Stats.DeliveryRate, d.Stats.BounceCount, d.Stats.BounceRate, d.Stats.OpenCount, d.Stats.OpenRate, d.Stats.ClickCount, d.Stats.ClickRate, d.Stats.SpamCount, d.Stats.SpamRate})
}

var domainStatsColumns = []output.Column{
{Header: "DOMAIN ID", Field: "sending_domain_id"},
{Header: "DELIVERY COUNT", Field: "delivery_count"},
{Header: "DELIVERY RATE", Field: "delivery_rate"},
{Header: "BOUNCE COUNT", Field: "bounce_count"},
{Header: "BOUNCE RATE", Field: "bounce_rate"},
{Header: "OPEN COUNT", Field: "open_count"},
{Header: "OPEN RATE", Field: "open_rate"},
{Header: "CLICK COUNT", Field: "click_count"},
{Header: "CLICK RATE", Field: "click_rate"},
{Header: "SPAM COUNT", Field: "spam_count"},
{Header: "SPAM RATE", Field: "spam_rate"},
}

type CategoryStats struct {
Category string `json:"category"`
Stats Stats `json:"stats"`
}

func (c CategoryStats) MarshalJSON() ([]byte, error) {
type flat struct {
Category string `json:"category"`
DeliveryCount int `json:"delivery_count"`
DeliveryRate float64 `json:"delivery_rate"`
BounceCount int `json:"bounce_count"`
BounceRate float64 `json:"bounce_rate"`
OpenCount int `json:"open_count"`
OpenRate float64 `json:"open_rate"`
ClickCount int `json:"click_count"`
ClickRate float64 `json:"click_rate"`
SpamCount int `json:"spam_count"`
SpamRate float64 `json:"spam_rate"`
}
return json.Marshal(flat{c.Category, c.Stats.DeliveryCount, c.Stats.DeliveryRate, c.Stats.BounceCount, c.Stats.BounceRate, c.Stats.OpenCount, c.Stats.OpenRate, c.Stats.ClickCount, c.Stats.ClickRate, c.Stats.SpamCount, c.Stats.SpamRate})
}

var categoryStatsColumns = []output.Column{
{Header: "CATEGORY", Field: "category"},
{Header: "DELIVERY COUNT", Field: "delivery_count"},
{Header: "DELIVERY RATE", Field: "delivery_rate"},
{Header: "BOUNCE COUNT", Field: "bounce_count"},
{Header: "BOUNCE RATE", Field: "bounce_rate"},
{Header: "OPEN COUNT", Field: "open_count"},
{Header: "OPEN RATE", Field: "open_rate"},
{Header: "CLICK COUNT", Field: "click_count"},
{Header: "CLICK RATE", Field: "click_rate"},
{Header: "SPAM COUNT", Field: "spam_count"},
{Header: "SPAM RATE", Field: "spam_rate"},
}

type ESPStats struct {
EmailServiceProvider string `json:"email_service_provider"`
Stats Stats `json:"stats"`
}

func (e ESPStats) MarshalJSON() ([]byte, error) {
type flat struct {
EmailServiceProvider string `json:"email_service_provider"`
DeliveryCount int `json:"delivery_count"`
DeliveryRate float64 `json:"delivery_rate"`
BounceCount int `json:"bounce_count"`
BounceRate float64 `json:"bounce_rate"`
OpenCount int `json:"open_count"`
OpenRate float64 `json:"open_rate"`
ClickCount int `json:"click_count"`
ClickRate float64 `json:"click_rate"`
SpamCount int `json:"spam_count"`
SpamRate float64 `json:"spam_rate"`
}
return json.Marshal(flat{e.EmailServiceProvider, e.Stats.DeliveryCount, e.Stats.DeliveryRate, e.Stats.BounceCount, e.Stats.BounceRate, e.Stats.OpenCount, e.Stats.OpenRate, e.Stats.ClickCount, e.Stats.ClickRate, e.Stats.SpamCount, e.Stats.SpamRate})
}

var espStatsColumns = []output.Column{
{Header: "ESP", Field: "email_service_provider"},
{Header: "DELIVERY COUNT", Field: "delivery_count"},
{Header: "DELIVERY RATE", Field: "delivery_rate"},
{Header: "BOUNCE COUNT", Field: "bounce_count"},
{Header: "BOUNCE RATE", Field: "bounce_rate"},
{Header: "OPEN COUNT", Field: "open_count"},
{Header: "OPEN RATE", Field: "open_rate"},
{Header: "CLICK COUNT", Field: "click_count"},
{Header: "CLICK RATE", Field: "click_rate"},
{Header: "SPAM COUNT", Field: "spam_count"},
{Header: "SPAM RATE", Field: "spam_rate"},
}

type DateStats struct {
Date string `json:"date"`
Stats Stats `json:"stats"`
}

func (d DateStats) MarshalJSON() ([]byte, error) {
type flat struct {
Date string `json:"date"`
DeliveryCount int `json:"delivery_count"`
DeliveryRate float64 `json:"delivery_rate"`
BounceCount int `json:"bounce_count"`
BounceRate float64 `json:"bounce_rate"`
OpenCount int `json:"open_count"`
OpenRate float64 `json:"open_rate"`
ClickCount int `json:"click_count"`
ClickRate float64 `json:"click_rate"`
SpamCount int `json:"spam_count"`
SpamRate float64 `json:"spam_rate"`
}
return json.Marshal(flat{d.Date, d.Stats.DeliveryCount, d.Stats.DeliveryRate, d.Stats.BounceCount, d.Stats.BounceRate, d.Stats.OpenCount, d.Stats.OpenRate, d.Stats.ClickCount, d.Stats.ClickRate, d.Stats.SpamCount, d.Stats.SpamRate})
}

var dateStatsColumns = []output.Column{
{Header: "DATE", Field: "date"},
{Header: "DELIVERY COUNT", Field: "delivery_count"},
{Header: "DELIVERY RATE", Field: "delivery_rate"},
{Header: "BOUNCE COUNT", Field: "bounce_count"},
{Header: "BOUNCE RATE", Field: "bounce_rate"},
{Header: "OPEN COUNT", Field: "open_count"},
{Header: "OPEN RATE", Field: "open_rate"},
{Header: "CLICK COUNT", Field: "click_count"},
{Header: "CLICK RATE", Field: "click_rate"},
{Header: "SPAM COUNT", Field: "spam_count"},
{Header: "SPAM RATE", Field: "spam_rate"},
}

type GetOptions struct {
StartDate string
EndDate string
Expand Down
Loading
Loading