Skip to content
Merged
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ You can also use environment variables:

```bash
export MAILTRAP_API_TOKEN=your-token
export MAILTRAP_ACCOUNT_ID=your-account-id
export MAILTRAP_OUTPUT=json # table (default), json or text
```

### 2. Send an email
Expand Down Expand Up @@ -174,6 +176,15 @@ mailtrap domains list --output json

# Text
mailtrap domains list --output text

# Set the default format for every command
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:

```bash
mailtrap inbound messages list --inbox-id 735 -o json | jq -r '.last_id // empty'
```

## Commands
Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command {

cmd.PersistentFlags().String("api-token", "", "Mailtrap API token (env: MAILTRAP_API_TOKEN)")
cmd.PersistentFlags().String("account-id", "", "Mailtrap account ID (env: MAILTRAP_ACCOUNT_ID)")
cmd.PersistentFlags().StringP("output", "o", "table", "Output format: json, table, text")
cmd.PersistentFlags().StringP("output", "o", "table", "Output format: json, table, text (env: MAILTRAP_OUTPUT)")

viper.BindPFlag("api-token", cmd.PersistentFlags().Lookup("api-token"))
viper.BindPFlag("account-id", cmd.PersistentFlags().Lookup("account-id"))
Expand Down
50 changes: 47 additions & 3 deletions internal/commands/email_logs/email_logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package emaillogs_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -109,12 +111,22 @@ func TestEmailLogsListJSON(t *testing.T) {
}

output := buf.String()
var result []map[string]interface{}
var result struct {
Messages []map[string]interface{} `json:"messages"`
TotalCount int `json:"total_count"`
NextPageCursor string `json:"next_page_cursor"`
}
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Fatalf("output is not valid JSON: %v\noutput:\n%s", err, output)
}
if len(result) != 1 {
t.Fatalf("expected 1 email log, got %d", len(result))
if len(result.Messages) != 1 {
t.Fatalf("expected 1 email log, got %d", len(result.Messages))
}
if result.TotalCount != 1 {
t.Errorf("expected total_count 1, got %d", result.TotalCount)
}
if result.NextPageCursor != "cursor-abc" {
t.Errorf("expected next_page_cursor 'cursor-abc', got %q", result.NextPageCursor)
}
}

Expand Down Expand Up @@ -196,3 +208,35 @@ func TestEmailLogsGet(t *testing.T) {
t.Errorf("expected output to contain 'delivered', got:\n%s", output)
}
}

func TestEmailLogsGetJSONKeepsResponseAsIs(t *testing.T) {
body := `{"message_id":"msg-1","status":"delivered","client_ip":null,"category":"Welcome",` +
`"custom_variables":{"user_id":"42"},"sending_stream":"transactional","domain_id":3,` +
`"template_id":null,"references":[],"opens_count":2,"clicks_count":0,` +
`"events":[{"event_type":"delivery","created_at":"2024-01-01T00:00:01Z"}]}`

f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, body)
})
defer cleanup()

viper.Set("output", "json")

cmd := emaillogs.NewCmdEmailLogs(f)
cmd.SetArgs([]string{"get", "--id", "msg-1"})
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(body), &want)
if !reflect.DeepEqual(got, want) {
t.Errorf("expected the API response unchanged\nwant: %v\ngot: %v", want, got)
}
}
3 changes: 2 additions & 1 deletion internal/commands/email_logs/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package emaillogs

import (
"context"
"encoding/json"

"github.com/mailtrap/mailtrap-cli/internal/client"
"github.com/mailtrap/mailtrap-cli/internal/cmdutil"
Expand Down Expand Up @@ -32,7 +33,7 @@ func NewCmdGet(f *cmdutil.Factory) *cobra.Command {

path := cmdutil.AccountPath("email_logs", opts.ID)

var result EmailLog
var result json.RawMessage
if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &result); err != nil {
return err
}
Expand Down
35 changes: 8 additions & 27 deletions internal/commands/email_logs/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package emaillogs

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

"github.com/mailtrap/mailtrap-cli/internal/client"
Expand All @@ -12,23 +12,11 @@ import (
"github.com/spf13/cobra"
)

type EmailLog struct {
MessageID string `json:"message_id"`
Subject string `json:"subject"`
From string `json:"from"`
To string `json:"to"`
Status string `json:"status"`
SentAt string `json:"sent_at"`
RFCMessageID string `json:"rfc_message_id,omitempty"`
InReplyTo string `json:"in_reply_to,omitempty"`
References []string `json:"references,omitempty"`
ThreadID string `json:"thread_id,omitempty"`
}

type emailLogListResponse struct {
Messages []EmailLog `json:"messages"`
TotalCount int `json:"total_count"`
NextPageCursor string `json:"next_page_cursor"`
var emailLogsPage = output.Page{

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one of the breaking changes mentioned in PR description. I think it's acceptable to release as minor version given we're still pre v1.

Items: "messages",
Total: "total_count",
Cursor: []string{"next_page_cursor"},
CursorFlag: "cursor",
}

var emailLogColumns = []output.Column{
Expand Down Expand Up @@ -110,19 +98,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
params.Set("filters[category]", category)
}

var resp emailLogListResponse
var resp json.RawMessage
if err := c.Get(context.Background(), client.BaseGeneral, path, params, &resp); err != nil {
return err
}

format := cmdutil.GetOutputFormat()
if err := output.Print(f.IOStreams.Out, format, resp.Messages, emailLogColumns); err != nil {
return err
}
if format != output.FormatJSON && resp.NextPageCursor != "" {
fmt.Fprintf(f.IOStreams.Out, "\nNext page: --cursor %s\n", resp.NextPageCursor)
}
return nil
return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, emailLogsPage, emailLogColumns)
},
}

Expand Down
77 changes: 69 additions & 8 deletions internal/commands/emailcampaigns/emailcampaigns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -115,7 +116,8 @@ func TestEmailCampaignsListJSON(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{}{
"data": []map[string]interface{}{sampleCampaign()},
"data": []map[string]interface{}{sampleCampaign()},
"pagination": map[string]interface{}{"token": 1, "next_token": 2},
})
})
defer cleanup()
Expand All @@ -130,19 +132,48 @@ func TestEmailCampaignsListJSON(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}

var result []map[string]interface{}
var result struct {
Data []map[string]interface{} `json:"data"`
Pagination map[string]interface{} `json:"pagination"`
}
if err := json.Unmarshal(buf.Bytes(), &result); err != nil {
t.Fatalf("output is not valid JSON: %v\noutput:\n%s", err, buf.String())
}

if len(result) != 1 {
t.Fatalf("expected 1 campaign, got %d", len(result))
if len(result.Data) != 1 {
t.Fatalf("expected 1 campaign, got %d", len(result.Data))
}
if result.Data[0]["name"] != "Spring Sale" {
t.Errorf("expected name 'Spring Sale', got %v", result.Data[0]["name"])
}
if result[0]["name"] != "Spring Sale" {
t.Errorf("expected name 'Spring Sale', got %v", result[0]["name"])
if result.Data[0]["domain_id"] != float64(4321) {
t.Errorf("expected domain_id 4321, got %v", result.Data[0]["domain_id"])
}
if result.Pagination["next_token"] != float64(2) {
t.Errorf("expected pagination.next_token 2, got %v", result.Pagination["next_token"])
}
}

func TestEmailCampaignsListNextPage(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{}{
"data": []map[string]interface{}{sampleCampaign()},
"pagination": map[string]interface{}{"token": 1, "next_token": 2},
})
})
defer cleanup()

cmd := emailcampaigns.NewCmdEmailCampaigns(f)
cmd.SetArgs([]string{"list"})
cmd.SetOut(buf)

if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result[0]["domain_id"] != float64(4321) {
t.Errorf("expected domain_id 4321, got %v", result[0]["domain_id"])

if !strings.Contains(buf.String(), "Next page: --token 2") {
t.Errorf("expected output to surface the next-page token, got:\n%s", buf.String())
}
}

Expand Down Expand Up @@ -177,6 +208,36 @@ func TestEmailCampaignsGet(t *testing.T) {
}
}

func TestEmailCampaignsGetJSONKeepsCampaignAsIs(t *testing.T) {
campaign := `{"id":4567,"name":"Spring Sale","reply_to":null,"contact_list_ids":[],` +
`"contact_segment_ids":[],"current_state_metadata":{"reason":null},"archived":false}`

f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"data":`+campaign+`}`)
})
defer cleanup()

viper.Set("output", "json")

cmd := emailcampaigns.NewCmdEmailCampaigns(f)
cmd.SetArgs([]string{"get", "--id", "4567"})
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(campaign), &want)
if !reflect.DeepEqual(got, want) {
t.Errorf("expected the campaign unchanged\nwant: %v\ngot: %v", want, got)
}
}

func TestEmailCampaignsGetMissingID(t *testing.T) {
f, _, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
Expand Down
72 changes: 12 additions & 60 deletions internal/commands/emailcampaigns/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package emailcampaigns

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

Expand All @@ -11,64 +12,10 @@ import (
"github.com/spf13/cobra"
)

type ReplyTo struct {
DisplayName string `json:"display_name,omitempty"`
LocalPart string `json:"local_part,omitempty"`
Domain string `json:"domain,omitempty"`
}

type RecipientError struct {
Message string `json:"message"`
RcptIndex int `json:"rcpt_index"`
}

type StateMetadata struct {
Reason *string `json:"reason,omitempty"`
Error *string `json:"error,omitempty"`
Errors []RecipientError `json:"errors,omitempty"`
ScheduledAt *string `json:"scheduled_at,omitempty"`
}

type DeliveryOptions struct {
EmailsPerHour *int64 `json:"emails_per_hour,omitempty"`
}

type Template struct {
ID int64 `json:"id"`
Subject string `json:"subject"`
MergeTags []string `json:"merge_tags,omitempty"`
BodyHTML *string `json:"body_html,omitempty"`
BodyText *string `json:"body_text,omitempty"`
}

type EmailCampaign struct {
ID int64 `json:"id"`
DomainID int64 `json:"domain_id"`
DomainName string `json:"domain_name"`
Name string `json:"name"`
FromLocalPart string `json:"from_local_part"`
FromDisplayName string `json:"from_display_name"`
ReplyTo *ReplyTo `json:"reply_to,omitempty"`
CurrentState string `json:"current_state"`
CurrentStateMetadata *StateMetadata `json:"current_state_metadata,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
LastStartedAt *string `json:"last_started_at,omitempty"`
LastStartedAtDate *string `json:"last_started_at_date,omitempty"`
RecipientTotalCount *int64 `json:"recipient_total_count,omitempty"`
ContactListIDs []int64 `json:"contact_list_ids,omitempty"`
ContactSegmentIDs []int64 `json:"contact_segment_ids,omitempty"`
DeliveryMode string `json:"delivery_mode"`
DeliveryOptions *DeliveryOptions `json:"delivery_options,omitempty"`
Template *Template `json:"template,omitempty"`
}

type campaignListResponse struct {
Data []EmailCampaign `json:"data"`
}

// campaignResponse unwraps the data envelope of a single-campaign response and
// keeps the campaign as the API returned it.
type campaignResponse struct {
Comment thread
mklocek marked this conversation as resolved.
Data EmailCampaign `json:"data"`
Data json.RawMessage `json:"data"`
}

var campaignColumns = []output.Column{
Expand All @@ -80,6 +27,12 @@ var campaignColumns = []output.Column{
{Header: "CREATED", Field: "created_at"},
}

var campaignsPage = output.Page{
Items: "data",
Cursor: []string{"pagination", "next_token"},
CursorFlag: "token",
}

func NewCmdList(f *cmdutil.Factory) *cobra.Command {
var (
perPage int
Expand Down Expand Up @@ -107,13 +60,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
query.Set("token", fmt.Sprintf("%d", token))
}

var resp campaignListResponse
var resp json.RawMessage
if err := c.Get(context.Background(), client.BaseGeneral, basePath, query, &resp); err != nil {
return err
}

format := cmdutil.GetOutputFormat()
return output.Print(f.IOStreams.Out, format, resp.Data, campaignColumns)
return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, campaignsPage, campaignColumns)
},
}

Expand Down
Loading
Loading