From e69bd398316315b59b80af7f34bc82826ad9c566 Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Fri, 2 Oct 2026 17:19:49 +0200 Subject: [PATCH] Add inbound forward-rules commands and threads list --search --- README.md | 11 +- docs/TEST_PLAN.md | 23 +- .../commands/inbound/forwardrules/create.go | 123 +++++ .../commands/inbound/forwardrules/delete.go | 49 ++ .../inbound/forwardrules/forwardrules.go | 22 + .../inbound/forwardrules/forwardrules_test.go | 452 ++++++++++++++++++ internal/commands/inbound/forwardrules/get.go | 50 ++ .../commands/inbound/forwardrules/list.go | 82 ++++ .../commands/inbound/forwardrules/update.go | 71 +++ internal/commands/inbound/inbound.go | 4 +- .../inbound/messages/messages_test.go | 53 ++ internal/commands/inbound/threads/list.go | 20 +- .../commands/inbound/threads/threads_test.go | 164 +++++++ internal/output/page.go | 7 +- internal/output/page_test.go | 14 + skills/mailtrap-cli/SKILL.md | 2 +- skills/mailtrap-cli/references/inbound.md | 65 ++- 17 files changed, 1197 insertions(+), 15 deletions(-) create mode 100644 internal/commands/inbound/forwardrules/create.go create mode 100644 internal/commands/inbound/forwardrules/delete.go create mode 100644 internal/commands/inbound/forwardrules/forwardrules.go create mode 100644 internal/commands/inbound/forwardrules/forwardrules_test.go create mode 100644 internal/commands/inbound/forwardrules/get.go create mode 100644 internal/commands/inbound/forwardrules/list.go create mode 100644 internal/commands/inbound/forwardrules/update.go diff --git a/README.md b/README.md index 34572b0..b779382 100644 --- a/README.md +++ b/README.md @@ -135,12 +135,19 @@ 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 -# Inbound (folders, inboxes, messages, threads) +# Inbound (folders, inboxes, messages, threads, forward rules) mailtrap inbound folders list mailtrap inbound inboxes list --folder-id 90 mailtrap inbound messages list --inbox-id 735 mailtrap inbound messages reply --inbox-id 735 --id --text "Thanks for reaching out!" mailtrap inbound threads list --inbox-id 735 +mailtrap inbound threads list --inbox-id 735 --search acme +mailtrap inbound forward-rules list --inbox-id 735 +mailtrap inbound forward-rules create --inbox-id 735 --name "Copy billing mail to finance" \ + --conditions '[{"match_type":"sender","operator":"ends_with","value":"@billing.example.com"}]' \ + --destinations finance@example.com +mailtrap inbound forward-rules update --inbox-id 735 --id 7 --destinations finance@example.com,accounting@example.com +mailtrap inbound forward-rules delete --inbox-id 735 --id 7 # 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 @@ -201,7 +208,7 @@ mailtrap inbound messages list --inbox-id 735 -o json | jq -r '.last_id // empty | **Webhooks** | `webhooks list`, `webhooks get`, `webhooks create`, `webhooks update`, `webhooks delete` | | **Stats** | `stats get`, `stats by-domain`, `stats by-category`, `stats by-esp`, `stats by-date` | | **Email Logs** | `email-logs list`, `email-logs get` | -| **Inbound** | `inbound folders list/get/create/update/delete`, `inbound inboxes list/get/create/update/delete`, `inbound messages list/get/delete/reply/reply-all/forward`, `inbound threads list/get/delete` | +| **Inbound** | `inbound folders list/get/create/update/delete`, `inbound inboxes list/get/create/update/delete`, `inbound messages list/get/delete/reply/reply-all/forward`, `inbound threads list/get/delete`, `inbound forward-rules list/get/create/update/delete` | | **Contacts** | `contacts get`, `contacts create`, `contacts update`, `contacts delete`, `contacts import`, `contacts export`, `contacts import-status`, `contacts export-status`, `contacts create-event` | | **Contact Lists** | `contact-lists list`, `contact-lists get`, `contact-lists create`, `contact-lists update`, `contact-lists delete` | | **Contact Fields** | `contact-fields list`, `contact-fields get`, `contact-fields create`, `contact-fields update`, `contact-fields delete` | diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md index 74e558e..83efc4f 100644 --- a/docs/TEST_PLAN.md +++ b/docs/TEST_PLAN.md @@ -337,7 +337,7 @@ Prerequisite: Send an email with an attachment to the sandbox. ## 22. Inbound -**Note:** `inbound` commands take no `--account-id` (requests go to `/api/inbound/...`). Folder/inbox lists are bare arrays; message/thread lists return `{"data": [...], "total_count": N, "last_id": "..."}`; reply/forward return `{"message_ids": [...]}`. Reply/reply-all/forward send real email. +**Note:** `inbound` commands take no `--account-id` (requests go to `/api/inbound/...`). Folder/inbox lists are bare arrays; message/thread lists return `{"data": [...], "total_count": N, "last_id": "..."}`; forward-rule responses are wrapped in `{"data": ...}`; reply/forward return `{"message_ids": [...]}`. Reply/reply-all/forward send real email. | # | Test | Command | Expected | |---|------|---------|----------| @@ -359,8 +359,20 @@ Prerequisite: Send an email with an attachment to the sandbox. | 22.16 | List threads | `mailtrap inbound threads list --inbox-id ` | Table with thread entries | | 22.17 | Get thread | `mailtrap inbound threads get --inbox-id --id ` | Thread with messages | | 22.18 | Delete message | `mailtrap inbound messages delete --inbox-id --id ` | Success message | - -**Cleanup:** Delete created inbox and folder (`inbound inboxes delete`, `inbound folders delete`). +| 22.19 | Search threads | `mailtrap inbound threads list --inbox-id --search acme` | Only matching threads | +| 22.20 | Search threads (page) | `mailtrap inbound threads list --inbox-id --search acme --last-id ` | Next page of the filtered list | +| 22.21 | Create forward rule | `mailtrap inbound forward-rules create --inbox-id --name "Copy to team" --destinations team@example.com` | New rule | +| 22.22 | Create rule with conditions | `mailtrap inbound forward-rules create --inbox-id --name "Billing" --conditions '[{"match_type":"sender","operator":"ends_with","value":"@billing.example.com"}]' --destinations finance@example.com` | Rule with the condition | +| 22.23 | Invalid condition | `mailtrap inbound forward-rules create --inbox-id --name "Bad" --conditions '[{"match_type":"subject","operator":"equal","value":"x"}]'` | Validation error | +| 22.24 | List forward rules | `mailtrap inbound forward-rules list --inbox-id ` | Table with ID, NAME, CONDITIONS, DESTINATIONS, UPDATED AT | +| 22.25 | Get forward rule | `mailtrap inbound forward-rules get --inbox-id --id ` | Single rule details | +| 22.26 | Update rule destinations | `mailtrap inbound forward-rules update --inbox-id --id --destinations a@example.com,b@example.com` | Destinations replaced | +| 22.27 | Forward outcomes | Send mail to the inbox, then `mailtrap inbound messages get --inbox-id --id --output json` | `forwards` populated | +| 22.28 | Thread delivery | `mailtrap inbound threads get --inbox-id --id --output json` after a reply | Sent messages carry `delivery`; received messages carry `forwards` | +| 22.29 | Delete forward rule | `mailtrap inbound forward-rules delete --inbox-id --id ` | Success message | +| 22.30 | Forward rule missing ID | `mailtrap inbound forward-rules get --inbox-id ` | Error: `--id is required` | + +**Cleanup:** Delete created forward rules, inbox and folder (`inbound forward-rules delete`, `inbound inboxes delete`, `inbound folders delete`). --- @@ -403,7 +415,7 @@ Run tests in dependency order so earlier tests create resources needed by later 19. **Billing** (read-only) 20. **Organizations** (read-only, skip create unless safe) 21. **Configure** (local config only) -22. **Inbound** (CRUD for folders/inboxes; messages/threads need received mail) +22. **Inbound** (CRUD for folders/inboxes/forward rules; messages/threads need received mail) --- @@ -432,4 +444,5 @@ Run tests in dependency order so earlier tests create resources needed by later | Billing | 1 | 2 | | Organizations | 2 | 3 | | Configure | 1 | 2 | -| **Total** | **~90** | **~107** | +| Inbound | 24 | 30 | +| **Total** | **~114** | **~137** | diff --git a/internal/commands/inbound/forwardrules/create.go b/internal/commands/inbound/forwardrules/create.go new file mode 100644 index 0000000..1622648 --- /dev/null +++ b/internal/commands/inbound/forwardrules/create.go @@ -0,0 +1,123 @@ +package forwardrules + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "slices" + "strings" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +const conditionsUsage = "Conditions as a JSON array" + +const destinationsUsage = "Destination email addresses (comma-separated)" + +var ( + validMatchTypes = []string{"sender", "recipient", "header"} + validOperators = []string{"equal", "not_equal", "contains", "starts_with", "ends_with", "empty", "not_empty"} +) + +type conditionInput struct { + MatchType string `json:"match_type"` + Operator string `json:"operator"` + Value *string `json:"value,omitempty"` + HeaderKey *string `json:"header_key,omitempty"` +} + +func parseConditions(raw string) ([]conditionInput, error) { + if !strings.HasPrefix(strings.TrimSpace(raw), "[") { + return nil, fmt.Errorf("invalid --conditions: must be a JSON array") + } + + dec := json.NewDecoder(bytes.NewReader([]byte(raw))) + dec.DisallowUnknownFields() + + conditions := []conditionInput{} + if err := dec.Decode(&conditions); err != nil { + return nil, fmt.Errorf("invalid --conditions JSON: %w", err) + } + if _, err := dec.Token(); err != io.EOF { + return nil, fmt.Errorf("invalid --conditions JSON: unexpected data after the array") + } + + for i, cond := range conditions { + if !slices.Contains(validMatchTypes, cond.MatchType) { + return nil, fmt.Errorf("invalid --conditions: entry %d: match_type must be one of %s", i+1, strings.Join(validMatchTypes, ", ")) + } + if !slices.Contains(validOperators, cond.Operator) { + return nil, fmt.Errorf("invalid --conditions: entry %d: operator must be one of %s", i+1, strings.Join(validOperators, ", ")) + } + } + + return conditions, nil +} + +func buildDestinations(emails []string) []ForwardRuleDestination { + destinations := make([]ForwardRuleDestination, 0, len(emails)) + for _, email := range emails { + destinations = append(destinations, ForwardRuleDestination{Email: email}) + } + return destinations +} + +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + inboxID string + name string + conditions string + destinations []string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a forward rule", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + if err := cmdutil.RequireFlag("name", name); err != nil { + return err + } + + body := map[string]interface{}{"name": name} + if cmd.Flags().Changed("conditions") { + parsed, err := parseConditions(conditions) + if err != nil { + return err + } + body["conditions"] = parsed + } + if cmd.Flags().Changed("destinations") { + body["destinations"] = buildDestinations(destinations) + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/forward_rules", inboxID) + + var resp forwardRuleResponse + if err := c.Post(context.Background(), client.BaseGeneral, path, body, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp.Data, forwardRuleColumns) + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&name, "name", "", "Rule name (required)") + cmd.Flags().StringVar(&conditions, "conditions", "", conditionsUsage) + cmd.Flags().StringSliceVar(&destinations, "destinations", nil, destinationsUsage) + + return cmd +} diff --git a/internal/commands/inbound/forwardrules/delete.go b/internal/commands/inbound/forwardrules/delete.go new file mode 100644 index 0000000..e9c75cc --- /dev/null +++ b/internal/commands/inbound/forwardrules/delete.go @@ -0,0 +1,49 @@ +package forwardrules + +import ( + "context" + "fmt" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + var ( + inboxID string + ruleID string + ) + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a forward rule", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", ruleID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/forward_rules/%s", inboxID, ruleID) + + if err := c.Delete(context.Background(), client.BaseGeneral, path, nil); err != nil { + return err + } + + fmt.Fprintln(f.IOStreams.Out, "Forward rule deleted successfully.") + return nil + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&ruleID, "id", "", "Forward rule ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/forwardrules/forwardrules.go b/internal/commands/inbound/forwardrules/forwardrules.go new file mode 100644 index 0000000..6b16190 --- /dev/null +++ b/internal/commands/inbound/forwardrules/forwardrules.go @@ -0,0 +1,22 @@ +package forwardrules + +import ( + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdForwardRules creates the `inbound forward-rules` command group. +func NewCmdForwardRules(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "forward-rules", + Short: "Manage inbound forward rules", + } + + cmd.AddCommand(NewCmdList(f)) + cmd.AddCommand(NewCmdGet(f)) + cmd.AddCommand(NewCmdCreate(f)) + cmd.AddCommand(NewCmdUpdate(f)) + cmd.AddCommand(NewCmdDelete(f)) + + return cmd +} diff --git a/internal/commands/inbound/forwardrules/forwardrules_test.go b/internal/commands/inbound/forwardrules/forwardrules_test.go new file mode 100644 index 0000000..24b15a6 --- /dev/null +++ b/internal/commands/inbound/forwardrules/forwardrules_test.go @@ -0,0 +1,452 @@ +package forwardrules_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/commands/inbound/forwardrules" + "github.com/mailtrap/mailtrap-cli/internal/config" + "github.com/spf13/viper" +) + +func setupTest(handler http.HandlerFunc) (*cmdutil.Factory, *bytes.Buffer, func()) { + server := httptest.NewServer(handler) + + c := client.New("test-token") + c.SetBaseURL(client.BaseGeneral, server.URL) + + buf := &bytes.Buffer{} + f := &cmdutil.Factory{ + Config: func() *config.Config { + return &config.Config{APIToken: "test-token"} + }, + IOStreams: &cmdutil.IOStreams{ + Out: buf, + ErrOut: &bytes.Buffer{}, + }, + ClientOverride: c, + } + + viper.Set("api-token", "test-token") + viper.Set("output", "table") + + return f, buf, func() { + server.Close() + viper.Reset() + } +} + +func sampleRule(id int, name string) map[string]interface{} { + return map[string]interface{}{ + "id": id, + "name": name, + "created_at": "2026-05-08T10:30:00.000Z", + "updated_at": "2026-05-08T10:30:00.000Z", + "conditions": []map[string]interface{}{ + {"match_type": "sender", "operator": "ends_with", "value": "@billing.example.com", "header_key": nil}, + }, + "destinations": []map[string]interface{}{ + {"email": "finance@example.com"}, + }, + } +} + +func readBody(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + body, _ := io.ReadAll(r.Body) + var reqBody map[string]interface{} + if err := json.Unmarshal(body, &reqBody); err != nil { + t.Fatalf("request body is not valid JSON: %v\nbody: %s", err, body) + } + return reqBody +} + +func TestForwardRulesList(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 !strings.HasSuffix(r.URL.Path, "/api/inbound/inboxes/201/forward_rules") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{sampleRule(7, "Copy billing mail to finance")}, + }) + }) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"list", "--inbox-id", "201"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + for _, want := range []string{"CONDITIONS", "DESTINATIONS", "Copy billing mail to finance", "finance@example.com", "ends_with"} { + if !strings.Contains(out, want) { + t.Errorf("expected output to contain %q, got:\n%s", want, out) + } + } +} + +func TestForwardRulesListJSON(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{}{sampleRule(7, "Copy billing mail to finance")}, + }) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"list", "--inbox-id", "201"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result []forwardrules.ForwardRule + 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 || result[0].ID != 7 { + t.Fatalf("unexpected JSON result: %v", result) + } + if len(result[0].Conditions) != 1 || result[0].Conditions[0].Operator != "ends_with" { + t.Errorf("unexpected conditions: %+v", result[0].Conditions) + } + if len(result[0].Destinations) != 1 || result[0].Destinations[0].Email != "finance@example.com" { + t.Errorf("unexpected destinations: %+v", result[0].Destinations) + } +} + +func TestForwardRulesListMissingInboxID(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"list"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when --inbox-id is missing") + } + if !strings.Contains(err.Error(), "--inbox-id is required") { + t.Errorf("expected '--inbox-id is required' error, got: %v", err) + } +} + +func TestForwardRulesGet(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/inbound/inboxes/201/forward_rules/7") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"data": sampleRule(7, "Copy billing mail to finance")}) + }) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"get", "--inbox-id", "201", "--id", "7"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Copy billing mail to finance") { + t.Errorf("expected output to contain rule name, got:\n%s", buf.String()) + } +} + +func TestForwardRulesGetMissingID(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"get", "--inbox-id", "201"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when --id is missing") + } + if !strings.Contains(err.Error(), "--id is required") { + t.Errorf("expected '--id is required' error, got: %v", err) + } +} + +func TestForwardRulesCreate(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/api/inbound/inboxes/201/forward_rules") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + reqBody := readBody(t, r) + if _, wrapped := reqBody["forward_rule"]; wrapped { + t.Errorf("request body must not be wrapped, got %v", reqBody) + } + if reqBody["name"] != "Copy billing mail to finance" { + t.Errorf("unexpected name: %v", reqBody["name"]) + } + + conditions, _ := reqBody["conditions"].([]interface{}) + if len(conditions) != 2 { + t.Fatalf("expected 2 conditions, got %v", reqBody["conditions"]) + } + sender := conditions[0].(map[string]interface{}) + if sender["match_type"] != "sender" || sender["operator"] != "ends_with" || sender["value"] != "@billing.example.com" { + t.Errorf("unexpected sender condition: %v", sender) + } + if _, ok := sender["header_key"]; ok { + t.Errorf("header_key must be omitted when not given, got %v", sender) + } + header := conditions[1].(map[string]interface{}) + if header["header_key"] != "X-Priority" || header["operator"] != "not_empty" { + t.Errorf("unexpected header condition: %v", header) + } + if _, ok := header["value"]; ok { + t.Errorf("value must be omitted when not given, got %v", header) + } + + destinations, _ := reqBody["destinations"].([]interface{}) + if len(destinations) != 2 { + t.Fatalf("expected 2 destinations, got %v", reqBody["destinations"]) + } + if destinations[1].(map[string]interface{})["email"] != "accounting@example.com" { + t.Errorf("unexpected destinations: %v", destinations) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{"data": sampleRule(7, "Copy billing mail to finance")}) + }) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{ + "create", "--inbox-id", "201", "--name", "Copy billing mail to finance", + "--conditions", `[{"match_type":"sender","operator":"ends_with","value":"@billing.example.com"},{"match_type":"header","operator":"not_empty","header_key":"X-Priority"}]`, + "--destinations", "finance@example.com", + "--destinations", "accounting@example.com", + }) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Copy billing mail to finance") { + t.Errorf("expected output to contain rule name, got:\n%s", buf.String()) + } +} + +func TestForwardRulesCreateNameOnly(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + reqBody := readBody(t, r) + if len(reqBody) != 1 || reqBody["name"] != "Catch all" { + t.Errorf("expected only name in body, got %v", reqBody) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{"data": sampleRule(8, "Catch all")}) + }) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"create", "--inbox-id", "201", "--name", "Catch all"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestForwardRulesCreateMissingName(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + t.Error("no request expected") + }) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"create", "--inbox-id", "201"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when --name is missing") + } + if !strings.Contains(err.Error(), "--name is required") { + t.Errorf("expected '--name is required' error, got: %v", err) + } +} + +func TestForwardRulesCreateInvalidConditions(t *testing.T) { + cases := map[string]struct { + conditions string + wantErr string + }{ + "not an array": {`{"match_type":"sender"}`, "must be a JSON array"}, + "malformed": {`[{"match_type":`, "invalid --conditions JSON"}, + "unknown key": {`[{"match_type":"sender","operator":"equal","value":"a","typo":1}]`, "unknown field"}, + "bad match_type": {`[{"match_type":"subject","operator":"equal","value":"a"}]`, "match_type must be one of"}, + "bad operator": {`[{"match_type":"sender","operator":"regex","value":"a"}]`, "operator must be one of"}, + "missing operator": {`[{"match_type":"sender","value":"a"}]`, "operator must be one of"}, + "trailing data": {`[] garbage`, "unexpected data after the array"}, + "second array": {`[] [{"match_type":"sender","operator":"equal","value":"a"}]`, "unexpected data after the array"}, + "stray bracket": {`[]]`, "unexpected data after the array"}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + t.Error("no request expected") + }) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"create", "--inbox-id", "201", "--name", "Rule", "--conditions", tc.conditions}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for invalid --conditions") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("expected error containing %q, got: %v", tc.wantErr, err) + } + }) + } +} + +func TestForwardRulesUpdatePartial(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/api/inbound/inboxes/201/forward_rules/7") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + reqBody := readBody(t, r) + if len(reqBody) != 1 { + t.Errorf("expected only destinations in body, got %v", reqBody) + } + destinations, _ := reqBody["destinations"].([]interface{}) + if len(destinations) != 2 { + t.Errorf("expected 2 destinations, got %v", reqBody["destinations"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"data": sampleRule(7, "Copy billing mail to finance")}) + }) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"update", "--inbox-id", "201", "--id", "7", "--destinations", "finance@example.com,accounting@example.com"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Copy billing mail to finance") { + t.Errorf("expected output to contain rule name, got:\n%s", buf.String()) + } +} + +func TestForwardRulesUpdateClearsSets(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + var reqBody map[string]json.RawMessage + if err := json.Unmarshal(body, &reqBody); err != nil { + t.Fatalf("request body is not valid JSON: %v", err) + } + if string(reqBody["conditions"]) != "[]" { + t.Errorf("expected conditions to be sent as [], got %s", reqBody["conditions"]) + } + if string(reqBody["destinations"]) != "[]" { + t.Errorf("expected destinations to be sent as [], got %s", reqBody["destinations"]) + } + if string(reqBody["name"]) != `"Renamed"` { + t.Errorf("unexpected name: %s", reqBody["name"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"data": map[string]interface{}{ + "id": 7, "name": "Renamed", "conditions": []interface{}{}, "destinations": []interface{}{}, + }}) + }) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"update", "--inbox-id", "201", "--id", "7", "--name", "Renamed", "--conditions", "[]", "--destinations", ""}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestForwardRulesUpdateMissingID(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"update", "--inbox-id", "201", "--name", "Renamed"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when --id is missing") + } + if !strings.Contains(err.Error(), "--id is required") { + t.Errorf("expected '--id is required' error, got: %v", err) + } +} + +func TestForwardRulesDelete(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/api/inbound/inboxes/201/forward_rules/7") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + }) + defer cleanup() + + cmd := forwardrules.NewCmdForwardRules(f) + cmd.SetArgs([]string{"delete", "--inbox-id", "201", "--id", "7"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "deleted successfully") { + t.Errorf("expected success message, got:\n%s", buf.String()) + } +} diff --git a/internal/commands/inbound/forwardrules/get.go b/internal/commands/inbound/forwardrules/get.go new file mode 100644 index 0000000..e4f424e --- /dev/null +++ b/internal/commands/inbound/forwardrules/get.go @@ -0,0 +1,50 @@ +package forwardrules + +import ( + "context" + "fmt" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +func NewCmdGet(f *cmdutil.Factory) *cobra.Command { + var ( + inboxID string + ruleID string + ) + + cmd := &cobra.Command{ + Use: "get", + Short: "Get a forward rule", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", ruleID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/forward_rules/%s", inboxID, ruleID) + + var resp forwardRuleResponse + if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp.Data, forwardRuleColumns) + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&ruleID, "id", "", "Forward rule ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/forwardrules/list.go b/internal/commands/inbound/forwardrules/list.go new file mode 100644 index 0000000..8794a6e --- /dev/null +++ b/internal/commands/inbound/forwardrules/list.go @@ -0,0 +1,82 @@ +package forwardrules + +import ( + "context" + "fmt" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +// ForwardRule represents an inbound forward rule. +type ForwardRule struct { + ID int `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Conditions []ForwardRuleCondition `json:"conditions"` + Destinations []ForwardRuleDestination `json:"destinations"` +} + +// ForwardRuleCondition represents a forward rule condition. +type ForwardRuleCondition struct { + MatchType string `json:"match_type"` + Operator string `json:"operator"` + Value *string `json:"value"` + HeaderKey *string `json:"header_key"` +} + +// ForwardRuleDestination represents a forward rule destination. +type ForwardRuleDestination struct { + Email string `json:"email"` +} + +type forwardRuleResponse struct { + Data ForwardRule `json:"data"` +} + +type forwardRulesListResponse struct { + Data []ForwardRule `json:"data"` +} + +var forwardRuleColumns = []output.Column{ + {Header: "ID", Field: "id"}, + {Header: "NAME", Field: "name"}, + {Header: "CONDITIONS", Field: "conditions"}, + {Header: "DESTINATIONS", Field: "destinations"}, + {Header: "UPDATED AT", Field: "updated_at"}, +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var inboxID string + + cmd := &cobra.Command{ + Use: "list", + Short: "List forward rules in an inbox", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/forward_rules", inboxID) + + var resp forwardRulesListResponse + if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp.Data, forwardRuleColumns) + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/forwardrules/update.go b/internal/commands/inbound/forwardrules/update.go new file mode 100644 index 0000000..49ed938 --- /dev/null +++ b/internal/commands/inbound/forwardrules/update.go @@ -0,0 +1,71 @@ +package forwardrules + +import ( + "context" + "fmt" + + "github.com/mailtrap/mailtrap-cli/internal/client" + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + var ( + inboxID string + ruleID string + name string + conditions string + destinations []string + ) + + cmd := &cobra.Command{ + Use: "update", + Short: "Update a forward rule", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", ruleID); err != nil { + return err + } + + body := map[string]interface{}{} + if cmd.Flags().Changed("name") { + body["name"] = name + } + if cmd.Flags().Changed("conditions") { + parsed, err := parseConditions(conditions) + if err != nil { + return err + } + body["conditions"] = parsed + } + if cmd.Flags().Changed("destinations") { + body["destinations"] = buildDestinations(destinations) + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/forward_rules/%s", inboxID, ruleID) + + var resp forwardRuleResponse + if err := c.Patch(context.Background(), client.BaseGeneral, path, body, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp.Data, forwardRuleColumns) + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&ruleID, "id", "", "Forward rule ID (required)") + cmd.Flags().StringVar(&name, "name", "", "Rule name") + cmd.Flags().StringVar(&conditions, "conditions", "", conditionsUsage) + cmd.Flags().StringSliceVar(&destinations, "destinations", nil, destinationsUsage) + + return cmd +} diff --git a/internal/commands/inbound/inbound.go b/internal/commands/inbound/inbound.go index faa6f66..42f7009 100644 --- a/internal/commands/inbound/inbound.go +++ b/internal/commands/inbound/inbound.go @@ -3,6 +3,7 @@ package inbound import ( "github.com/mailtrap/mailtrap-cli/internal/cmdutil" "github.com/mailtrap/mailtrap-cli/internal/commands/inbound/folders" + "github.com/mailtrap/mailtrap-cli/internal/commands/inbound/forwardrules" "github.com/mailtrap/mailtrap-cli/internal/commands/inbound/inboxes" "github.com/mailtrap/mailtrap-cli/internal/commands/inbound/messages" "github.com/mailtrap/mailtrap-cli/internal/commands/inbound/threads" @@ -13,13 +14,14 @@ import ( func NewCmdInbound(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "inbound", - Short: "Manage inbound email folders, inboxes, messages, and threads", + Short: "Manage inbound email folders, inboxes, messages, threads, and forward rules", } cmd.AddCommand(folders.NewCmdFolders(f)) cmd.AddCommand(inboxes.NewCmdInboxes(f)) cmd.AddCommand(messages.NewCmdMessages(f)) cmd.AddCommand(threads.NewCmdThreads(f)) + cmd.AddCommand(forwardrules.NewCmdForwardRules(f)) return cmd } diff --git a/internal/commands/inbound/messages/messages_test.go b/internal/commands/inbound/messages/messages_test.go index b8595a5..e2fff2c 100644 --- a/internal/commands/inbound/messages/messages_test.go +++ b/internal/commands/inbound/messages/messages_test.go @@ -311,3 +311,56 @@ func TestMessagesGetJSONKeepsResponseAsIs(t *testing.T) { t.Errorf("expected the API response unchanged\nwant: %v\ngot: %v", want, got) } } + +func TestMessagesGetForwardsJSON(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": "msg_1", "subject": "Invoice", + "forwards": []map[string]interface{}{ + { + "rule_id": 7, "rule_name": nil, "destination": "loop@example.com", + "status": "rejected", "reason": "loop_prevention", "message_id": nil, + }, + }, + }) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := messages.NewCmdMessages(f) + cmd.SetArgs([]string{"get", "--inbox-id", "201", "--id", "msg_1"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result struct { + Forwards []struct { + RuleID int `json:"rule_id"` + RuleName *string `json:"rule_name"` + Destination string `json:"destination"` + Status string `json:"status"` + Reason *string `json:"reason"` + MessageID *string `json:"message_id"` + } `json:"forwards"` + } + 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.Forwards) != 1 { + t.Fatalf("expected 1 forward, got %+v", result.Forwards) + } + fwd := result.Forwards[0] + if fwd.RuleID != 7 || fwd.Destination != "loop@example.com" || fwd.Status != "rejected" { + t.Errorf("unexpected forward: %+v", fwd) + } + if fwd.Reason == nil || *fwd.Reason != "loop_prevention" { + t.Errorf("expected reason loop_prevention, got %v", fwd.Reason) + } + if fwd.RuleName != nil || fwd.MessageID != nil { + t.Errorf("expected nil rule_name and message_id, got %v / %v", fwd.RuleName, fwd.MessageID) + } +} diff --git a/internal/commands/inbound/threads/list.go b/internal/commands/inbound/threads/list.go index 1848565..324b78d 100644 --- a/internal/commands/inbound/threads/list.go +++ b/internal/commands/inbound/threads/list.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/url" + "strings" "github.com/mailtrap/mailtrap-cli/internal/client" "github.com/mailtrap/mailtrap-cli/internal/cmdutil" @@ -30,6 +31,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { var ( inboxID string lastID string + search string ) cmd := &cobra.Command{ @@ -47,23 +49,35 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { path := fmt.Sprintf("/api/inbound/inboxes/%s/threads", inboxID) - var params url.Values + params := url.Values{} if lastID != "" { - params = url.Values{} params.Set("last_id", lastID) } + if search != "" { + params.Set("search", search) + } var resp json.RawMessage if err := c.Get(context.Background(), client.BaseGeneral, path, params, &resp); err != nil { return err } - return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, threadsPage, threadColumns) + page := threadsPage + if search != "" { + page.NextArgs = "--search " + shellQuote(search) + } + + return output.PrintPage(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, page, threadColumns) }, } cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") cmd.Flags().StringVar(&lastID, "last-id", "", "Pagination cursor (last_id from previous response)") + cmd.Flags().StringVar(&search, "search", "", "Search threads by subject or address") return cmd } + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/commands/inbound/threads/threads_test.go b/internal/commands/inbound/threads/threads_test.go index 66d9eb0..707bd6a 100644 --- a/internal/commands/inbound/threads/threads_test.go +++ b/internal/commands/inbound/threads/threads_test.go @@ -168,3 +168,167 @@ func TestThreadsListJSON(t *testing.T) { t.Errorf("expected last_id 'thr_1', got %q", result.LastID) } } + +func TestThreadsListWithSearch(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("search"); got != "acme corp" { + t.Errorf("expected search=acme corp, got %q", got) + } + if got := r.URL.Query().Get("last_id"); got != "WzE3NzgyNDE5MDAwMDAsIjE3MDAwMDAwMDAwMDAxMjMiXQ==" { + t.Errorf("expected opaque last_id cursor, got %q", got) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{{"id": "thr_2", "subject": "ACME order"}}, + "total_count": 2, + "last_id": "WzE3NzgyNDE5MDAwMDAsIjE3MDAwMDAwMDAwMDAxMjQiXQ==", + }) + }) + defer cleanup() + + cmd := threads.NewCmdThreads(f) + cmd.SetArgs([]string{"list", "--inbox-id", "201", "--search", "acme corp", "--last-id", "WzE3NzgyNDE5MDAwMDAsIjE3MDAwMDAwMDAwMDAxMjMiXQ=="}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := `--last-id WzE3NzgyNDE5MDAwMDAsIjE3MDAwMDAwMDAwMDAxMjQiXQ== --search 'acme corp'` + if !strings.Contains(buf.String(), want) { + t.Errorf("expected next-page hint to carry the search, got:\n%s", buf.String()) + } +} + +func TestThreadsListSearchHintIsShellQuoted(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{}{{"id": "thr_1"}}, + "total_count": 2, + "last_id": "abc", + }) + }) + defer cleanup() + + cmd := threads.NewCmdThreads(f) + cmd.SetArgs([]string{"list", "--inbox-id", "201", "--search", "$HOME $(whoami) it's"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := `--last-id abc --search '$HOME $(whoami) it'\''s'` + if !strings.Contains(buf.String(), want) { + t.Errorf("expected shell-quoted search in the next-page hint, got:\n%s", buf.String()) + } +} + +func TestThreadsListWithoutSearchSendsNoQuery(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "" { + t.Errorf("expected no query string, got %q", r.URL.RawQuery) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"data": []interface{}{}, "total_count": 0, "last_id": nil}) + }) + defer cleanup() + + cmd := threads.NewCmdThreads(f) + cmd.SetArgs([]string{"list", "--inbox-id", "201"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestThreadsGetDeliveryAndForwardsJSON(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": "thr_1", "subject": "Support request", + "messages": []map[string]interface{}{ + {"visibility_status": "placeholder", "direction": "inbound"}, + { + "id": "msg_1", "direction": "inbound", "visibility_status": "available", + "forwards": []map[string]interface{}{ + { + "rule_id": 7, "rule_name": "Copy to support team", "destination": "team@example.com", + "status": "forwarded", "reason": nil, "message_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", + }, + }, + }, + { + "id": "1a2b3c4d", "direction": "outbound", "visibility_status": "available", + "delivery": map[string]interface{}{ + "to": "customer@example.com", "status": "delivered", + "delivered_at": "2026-05-08T11:40:05.000Z", "bounced_at": nil, + }, + }, + }, + }) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := threads.NewCmdThreads(f) + cmd.SetArgs([]string{"get", "--inbox-id", "201", "--id", "thr_1"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result struct { + Messages []struct { + Delivery *struct { + To string `json:"to"` + Status string `json:"status"` + DeliveredAt *string `json:"delivered_at"` + BouncedAt *string `json:"bounced_at"` + } `json:"delivery"` + Forwards []struct { + Destination string `json:"destination"` + Status string `json:"status"` + } `json:"forwards"` + } `json:"messages"` + } + 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.Messages) != 3 { + t.Fatalf("expected 3 messages, got %d", len(result.Messages)) + } + + placeholder := result.Messages[0] + if placeholder.Delivery != nil || placeholder.Forwards != nil { + t.Errorf("placeholder must carry neither delivery nor forwards: %+v", placeholder) + } + + inbound := result.Messages[1] + if inbound.Delivery != nil { + t.Errorf("inbound message must not carry delivery: %+v", inbound.Delivery) + } + if len(inbound.Forwards) != 1 || inbound.Forwards[0].Destination != "team@example.com" || inbound.Forwards[0].Status != "forwarded" { + t.Errorf("unexpected forwards: %+v", inbound.Forwards) + } + + outbound := result.Messages[2] + if outbound.Delivery == nil { + t.Fatal("expected delivery on the outbound message") + } + if outbound.Delivery.To != "customer@example.com" || outbound.Delivery.Status != "delivered" { + t.Errorf("unexpected delivery: %+v", outbound.Delivery) + } + if outbound.Delivery.DeliveredAt == nil || *outbound.Delivery.DeliveredAt != "2026-05-08T11:40:05.000Z" { + t.Errorf("unexpected delivered_at: %v", outbound.Delivery.DeliveredAt) + } + if outbound.Delivery.BouncedAt != nil { + t.Errorf("expected nil bounced_at, got %v", *outbound.Delivery.BouncedAt) + } +} 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..c0d3e55 100644 --- a/skills/mailtrap-cli/SKILL.md +++ b/skills/mailtrap-cli/SKILL.md @@ -61,7 +61,7 @@ A `null` cursor means there are no more pages. `suppressions list` and `messages | `templates` | Email template CRUD | [templates.md](references/templates.md) | | `stats` | Aggregated sending statistics | [email-logs.md](references/email-logs.md) | | `email-logs` | Individual email log lookup | [email-logs.md](references/email-logs.md) | -| `inbound` | Inbound email folders, inboxes, messages & threads | [inbound.md](references/inbound.md) | +| `inbound` | Inbound email folders, inboxes, messages, threads & forward rules | [inbound.md](references/inbound.md) | | `contacts` | Contact management & import/export | [contacts.md](references/contacts.md) | | `contact-lists` | Contact list CRUD | [contacts.md](references/contacts.md) | | `contact-fields` | Custom contact fields | [contacts.md](references/contacts.md) | diff --git a/skills/mailtrap-cli/references/inbound.md b/skills/mailtrap-cli/references/inbound.md index 193725a..e9285c5 100644 --- a/skills/mailtrap-cli/references/inbound.md +++ b/skills/mailtrap-cli/references/inbound.md @@ -1,6 +1,6 @@ # inbound -Detailed flag specifications for `mailtrap inbound` commands (folders, inboxes, messages, threads). +Detailed flag specifications for `mailtrap inbound` commands (folders, inboxes, messages, threads, forward rules). `inbound` commands go to `https://mailtrap.io/api/inbound/...` and do **not** use `--account-id`. @@ -112,6 +112,8 @@ Messages and threads are accessed via the top-level inbox route (`/api/inbound/i Returns the message with its body and attachment download URLs. JSON output is the API response as returned, including `attachments` (with `download_url`), `headers`, `references`, `bcc`, `reply_to`, `raw_message_url` and body sizes. +Messages from `list` and `get` include `forwards` in JSON output. + | Flag | Type | Required | Description | |------|------|----------|-------------| | `--inbox-id` | string | Yes | Inbox ID | @@ -163,6 +165,7 @@ Each sends a **real email** and returns the sent message IDs. |------|------|----------|-------------| | `--inbox-id` | string | Yes | Inbox ID | | `--last-id` | string | No | Pagination cursor (`last_id` from the previous response) | +| `--search` | string | No | Search threads by subject or address | **Output:** Same shape as `inbound messages list`: `{"data": [...], "total_count": N, "last_id": "..."}` in JSON. @@ -170,7 +173,7 @@ Each sends a **real email** and returns the sent message IDs. ## inbound threads get -Returns the thread with its messages embedded (oldest first). +Returns the thread with its messages embedded (oldest first). Sent messages carry `delivery`; received messages carry `forwards`. | Flag | Type | Required | Description | |------|------|----------|-------------| @@ -187,3 +190,61 @@ Inbound messages in the thread are removed; sent messages are preserved. |------|------|----------|-------------| | `--inbox-id` | string | Yes | Inbox ID | | `--id` | string | Yes | Thread ID | + +--- + +## inbound forward-rules list + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | + +--- + +## inbound forward-rules get + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | +| `--id` | string | Yes | Forward rule ID | + +--- + +## inbound forward-rules create + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | +| `--name` | string | Yes | Rule name | +| `--conditions` | string | No | Conditions as a JSON array (see below) | +| `--destinations` | string | No | Destination email addresses (comma-separated) | + +**Condition entries** (`--conditions`): + +| Key | Required | Values | +|-----|----------|--------| +| `match_type` | Yes | `sender`, `recipient`, `header` | +| `operator` | Yes | `equal`, `not_equal`, `contains`, `starts_with`, `ends_with`, `empty`, `not_empty` | +| `value` | No | Text to compare against | +| `header_key` | No | Header name | + +--- + +## inbound forward-rules update + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | +| `--id` | string | Yes | Forward rule ID | +| `--name` | string | No | New rule name | +| `--conditions` | string | No | Conditions as a JSON array | +| `--destinations` | string | No | Destination email addresses (comma-separated) | + +--- + +## inbound forward-rules delete + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | +| `--id` | string | Yes | Forward rule ID |