Skip to content
Open
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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <MESSAGE_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
Expand Down Expand Up @@ -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` |
Expand Down
23 changes: 18 additions & 5 deletions docs/TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|------|---------|----------|
Expand All @@ -359,8 +359,20 @@ Prerequisite: Send an email with an attachment to the sandbox.
| 22.16 | List threads | `mailtrap inbound threads list --inbox-id <INBOX_ID>` | Table with thread entries |
| 22.17 | Get thread | `mailtrap inbound threads get --inbox-id <INBOX_ID> --id <THREAD_ID>` | Thread with messages |
| 22.18 | Delete message | `mailtrap inbound messages delete --inbox-id <INBOX_ID> --id <MESSAGE_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 <INBOX_ID> --search acme` | Only matching threads |
| 22.20 | Search threads (page) | `mailtrap inbound threads list --inbox-id <INBOX_ID> --search acme --last-id <LAST_ID>` | Next page of the filtered list |
| 22.21 | Create forward rule | `mailtrap inbound forward-rules create --inbox-id <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 <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 <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 <INBOX_ID>` | Table with ID, NAME, CONDITIONS, DESTINATIONS, UPDATED AT |
| 22.25 | Get forward rule | `mailtrap inbound forward-rules get --inbox-id <INBOX_ID> --id <RULE_ID>` | Single rule details |
| 22.26 | Update rule destinations | `mailtrap inbound forward-rules update --inbox-id <INBOX_ID> --id <RULE_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 <INBOX_ID> --id <MESSAGE_ID> --output json` | `forwards` populated |
| 22.28 | Thread delivery | `mailtrap inbound threads get --inbox-id <INBOX_ID> --id <THREAD_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 <INBOX_ID> --id <RULE_ID>` | Success message |
| 22.30 | Forward rule missing ID | `mailtrap inbound forward-rules get --inbox-id <INBOX_ID>` | Error: `--id is required` |

**Cleanup:** Delete created forward rules, inbox and folder (`inbound forward-rules delete`, `inbound inboxes delete`, `inbound folders delete`).

---

Expand Down Expand Up @@ -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)

---

Expand Down Expand Up @@ -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** |
123 changes: 123 additions & 0 deletions internal/commands/inbound/forwardrules/create.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
}
49 changes: 49 additions & 0 deletions internal/commands/inbound/forwardrules/delete.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 22 additions & 0 deletions internal/commands/inbound/forwardrules/forwardrules.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading