From 1a6715a594e8799d5c4ea0612ce4b7502ab1cdb0 Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Mon, 3 Aug 2026 16:28:57 +0200 Subject: [PATCH 1/7] Add inbound folders and inboxes commands Add the inbound command group with folders and inboxes subcommands (list/get/create/update/delete). Inbound endpoints are token-scoped under /api/inbound, so paths are built directly rather than via the account path helper. Register the group in the root command. --- cmd/root.go | 4 ++ internal/commands/inbound/folders/create.go | 42 ++++++++++++++ internal/commands/inbound/folders/delete.go | 42 ++++++++++++++ internal/commands/inbound/folders/folders.go | 22 ++++++++ internal/commands/inbound/folders/get.go | 43 +++++++++++++++ internal/commands/inbound/folders/list.go | 42 ++++++++++++++ internal/commands/inbound/folders/update.go | 52 ++++++++++++++++++ internal/commands/inbound/inbound.go | 21 +++++++ internal/commands/inbound/inboxes/create.go | 58 ++++++++++++++++++++ internal/commands/inbound/inboxes/delete.go | 49 +++++++++++++++++ internal/commands/inbound/inboxes/get.go | 50 +++++++++++++++++ internal/commands/inbound/inboxes/inboxes.go | 22 ++++++++ internal/commands/inbound/inboxes/list.go | 58 ++++++++++++++++++++ internal/commands/inbound/inboxes/update.go | 57 +++++++++++++++++++ 14 files changed, 562 insertions(+) create mode 100644 internal/commands/inbound/folders/create.go create mode 100644 internal/commands/inbound/folders/delete.go create mode 100644 internal/commands/inbound/folders/folders.go create mode 100644 internal/commands/inbound/folders/get.go create mode 100644 internal/commands/inbound/folders/list.go create mode 100644 internal/commands/inbound/folders/update.go create mode 100644 internal/commands/inbound/inbound.go create mode 100644 internal/commands/inbound/inboxes/create.go create mode 100644 internal/commands/inbound/inboxes/delete.go create mode 100644 internal/commands/inbound/inboxes/get.go create mode 100644 internal/commands/inbound/inboxes/inboxes.go create mode 100644 internal/commands/inbound/inboxes/list.go create mode 100644 internal/commands/inbound/inboxes/update.go diff --git a/cmd/root.go b/cmd/root.go index 3be8dcf..e09dbe7 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( "github.com/mailtrap/mailtrap-cli/internal/commands/contacts" "github.com/mailtrap/mailtrap-cli/internal/commands/domains" email_logs "github.com/mailtrap/mailtrap-cli/internal/commands/email_logs" + "github.com/mailtrap/mailtrap-cli/internal/commands/inbound" "github.com/mailtrap/mailtrap-cli/internal/commands/sandboxes" "github.com/mailtrap/mailtrap-cli/internal/commands/messages" "github.com/mailtrap/mailtrap-cli/internal/commands/organizations" @@ -60,6 +61,9 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(email_logs.NewCmdEmailLogs(f)) cmd.AddCommand(webhooks.NewCmdWebhooks(f)) + // Inbound + cmd.AddCommand(inbound.NewCmdInbound(f)) + // Sandbox cmd.AddCommand(projects.NewCmdProjects(f)) cmd.AddCommand(sandboxes.NewCmdSandboxes(f)) diff --git a/internal/commands/inbound/folders/create.go b/internal/commands/inbound/folders/create.go new file mode 100644 index 0000000..03f3fdc --- /dev/null +++ b/internal/commands/inbound/folders/create.go @@ -0,0 +1,42 @@ +package folders + +import ( + "context" + + "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 NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var name string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an inbound folder", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("name", name); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + body := map[string]interface{}{"name": name} + + var resp InboundFolder + if err := c.Post(context.Background(), client.BaseGeneral, "/api/inbound/folders", body, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, folderColumns) + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Folder name (required)") + + return cmd +} diff --git a/internal/commands/inbound/folders/delete.go b/internal/commands/inbound/folders/delete.go new file mode 100644 index 0000000..036384a --- /dev/null +++ b/internal/commands/inbound/folders/delete.go @@ -0,0 +1,42 @@ +package folders + +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 folderID string + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete an inbound folder", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("id", folderID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/folders/%s", folderID) + + if err := c.Delete(context.Background(), client.BaseGeneral, path, nil); err != nil { + return err + } + + fmt.Fprintln(f.IOStreams.Out, "Inbound folder deleted successfully.") + return nil + }, + } + + cmd.Flags().StringVar(&folderID, "id", "", "Folder ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/folders/folders.go b/internal/commands/inbound/folders/folders.go new file mode 100644 index 0000000..8e78f82 --- /dev/null +++ b/internal/commands/inbound/folders/folders.go @@ -0,0 +1,22 @@ +package folders + +import ( + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdFolders creates the `inbound folders` command group. +func NewCmdFolders(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "folders", + Short: "Manage inbound folders", + } + + 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/folders/get.go b/internal/commands/inbound/folders/get.go new file mode 100644 index 0000000..391327e --- /dev/null +++ b/internal/commands/inbound/folders/get.go @@ -0,0 +1,43 @@ +package folders + +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 folderID string + + cmd := &cobra.Command{ + Use: "get", + Short: "Get an inbound folder", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("id", folderID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/folders/%s", folderID) + + var resp InboundFolder + if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, folderColumns) + }, + } + + cmd.Flags().StringVar(&folderID, "id", "", "Folder ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/folders/list.go b/internal/commands/inbound/folders/list.go new file mode 100644 index 0000000..5b24224 --- /dev/null +++ b/internal/commands/inbound/folders/list.go @@ -0,0 +1,42 @@ +package folders + +import ( + "context" + + "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" +) + +// InboundFolder represents an inbound folder. +type InboundFolder struct { + ID int `json:"id"` + Name string `json:"name"` +} + +var folderColumns = []output.Column{ + {Header: "ID", Field: "id"}, + {Header: "NAME", Field: "name"}, +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all inbound folders", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := f.NewClient() + if err != nil { + return err + } + + var resp []InboundFolder + if err := c.Get(context.Background(), client.BaseGeneral, "/api/inbound/folders", nil, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, folderColumns) + }, + } + return cmd +} diff --git a/internal/commands/inbound/folders/update.go b/internal/commands/inbound/folders/update.go new file mode 100644 index 0000000..992522f --- /dev/null +++ b/internal/commands/inbound/folders/update.go @@ -0,0 +1,52 @@ +package folders + +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 ( + folderID string + name string + ) + + cmd := &cobra.Command{ + Use: "update", + Short: "Update an inbound folder", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("id", folderID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/folders/%s", folderID) + + body := map[string]interface{}{} + if cmd.Flags().Changed("name") { + body["name"] = name + } + + var resp InboundFolder + if err := c.Patch(context.Background(), client.BaseGeneral, path, body, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, folderColumns) + }, + } + + cmd.Flags().StringVar(&folderID, "id", "", "Folder ID (required)") + cmd.Flags().StringVar(&name, "name", "", "Folder name") + + return cmd +} diff --git a/internal/commands/inbound/inbound.go b/internal/commands/inbound/inbound.go new file mode 100644 index 0000000..8fa2b12 --- /dev/null +++ b/internal/commands/inbound/inbound.go @@ -0,0 +1,21 @@ +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/inboxes" + "github.com/spf13/cobra" +) + +// NewCmdInbound creates the `inbound` command group. +func NewCmdInbound(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "inbound", + Short: "Manage inbound email folders, inboxes, messages, and threads", + } + + cmd.AddCommand(folders.NewCmdFolders(f)) + cmd.AddCommand(inboxes.NewCmdInboxes(f)) + + return cmd +} diff --git a/internal/commands/inbound/inboxes/create.go b/internal/commands/inbound/inboxes/create.go new file mode 100644 index 0000000..ea5fd29 --- /dev/null +++ b/internal/commands/inbound/inboxes/create.go @@ -0,0 +1,58 @@ +package inboxes + +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 NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + var ( + folderID string + name string + domainID int + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an inbound inbox", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("folder-id", folderID); err != nil { + return err + } + if err := cmdutil.RequireFlag("name", name); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/folders/%s/inboxes", folderID) + + // Omit domain-id for a Mailtrap-hosted inbox; set it to create a custom-domain (catch-all) inbox. + body := map[string]interface{}{"name": name} + if cmd.Flags().Changed("domain-id") { + body["domain_id"] = domainID + } + + var resp InboundInbox + if err := c.Post(context.Background(), client.BaseGeneral, path, body, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, inboxColumns) + }, + } + + cmd.Flags().StringVar(&folderID, "folder-id", "", "Folder ID (required)") + cmd.Flags().StringVar(&name, "name", "", "Inbox name (required)") + cmd.Flags().IntVar(&domainID, "domain-id", 0, "Sending domain ID for a custom-domain (catch-all) inbox") + + return cmd +} diff --git a/internal/commands/inbound/inboxes/delete.go b/internal/commands/inbound/inboxes/delete.go new file mode 100644 index 0000000..9614788 --- /dev/null +++ b/internal/commands/inbound/inboxes/delete.go @@ -0,0 +1,49 @@ +package inboxes + +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 ( + folderID string + inboxID string + ) + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete an inbound inbox", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("folder-id", folderID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", inboxID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/folders/%s/inboxes/%s", folderID, inboxID) + + if err := c.Delete(context.Background(), client.BaseGeneral, path, nil); err != nil { + return err + } + + fmt.Fprintln(f.IOStreams.Out, "Inbound inbox deleted successfully.") + return nil + }, + } + + cmd.Flags().StringVar(&folderID, "folder-id", "", "Folder ID (required)") + cmd.Flags().StringVar(&inboxID, "id", "", "Inbox ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/inboxes/get.go b/internal/commands/inbound/inboxes/get.go new file mode 100644 index 0000000..23f58d8 --- /dev/null +++ b/internal/commands/inbound/inboxes/get.go @@ -0,0 +1,50 @@ +package inboxes + +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 ( + folderID string + inboxID string + ) + + cmd := &cobra.Command{ + Use: "get", + Short: "Get an inbound inbox", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("folder-id", folderID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", inboxID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/folders/%s/inboxes/%s", folderID, inboxID) + + var resp InboundInbox + if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, inboxColumns) + }, + } + + cmd.Flags().StringVar(&folderID, "folder-id", "", "Folder ID (required)") + cmd.Flags().StringVar(&inboxID, "id", "", "Inbox ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/inboxes/inboxes.go b/internal/commands/inbound/inboxes/inboxes.go new file mode 100644 index 0000000..5e167bb --- /dev/null +++ b/internal/commands/inbound/inboxes/inboxes.go @@ -0,0 +1,22 @@ +package inboxes + +import ( + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdInboxes creates the `inbound inboxes` command group. +func NewCmdInboxes(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "inboxes", + Short: "Manage inbound inboxes", + } + + 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/inboxes/list.go b/internal/commands/inbound/inboxes/list.go new file mode 100644 index 0000000..789ce79 --- /dev/null +++ b/internal/commands/inbound/inboxes/list.go @@ -0,0 +1,58 @@ +package inboxes + +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" +) + +// InboundInbox represents an inbound inbox. +type InboundInbox struct { + ID int `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + DomainID *int `json:"domain_id,omitempty"` +} + +var inboxColumns = []output.Column{ + {Header: "ID", Field: "id"}, + {Header: "NAME", Field: "name"}, + {Header: "ADDRESS", Field: "address"}, + {Header: "DOMAIN ID", Field: "domain_id"}, +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var folderID string + + cmd := &cobra.Command{ + Use: "list", + Short: "List inboxes in a folder", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("folder-id", folderID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/folders/%s/inboxes", folderID) + + var resp []InboundInbox + if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, inboxColumns) + }, + } + + cmd.Flags().StringVar(&folderID, "folder-id", "", "Folder ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/inboxes/update.go b/internal/commands/inbound/inboxes/update.go new file mode 100644 index 0000000..f805fc2 --- /dev/null +++ b/internal/commands/inbound/inboxes/update.go @@ -0,0 +1,57 @@ +package inboxes + +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 ( + folderID string + inboxID string + name string + ) + + cmd := &cobra.Command{ + Use: "update", + Short: "Update an inbound inbox", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("folder-id", folderID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", inboxID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/folders/%s/inboxes/%s", folderID, inboxID) + + body := map[string]interface{}{} + if cmd.Flags().Changed("name") { + body["name"] = name + } + + var resp InboundInbox + if err := c.Patch(context.Background(), client.BaseGeneral, path, body, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, inboxColumns) + }, + } + + cmd.Flags().StringVar(&folderID, "folder-id", "", "Folder ID (required)") + cmd.Flags().StringVar(&inboxID, "id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&name, "name", "", "Inbox name") + + return cmd +} From ba84703d637b2259317cc8a76b60eafb3c70a705 Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Mon, 3 Aug 2026 16:40:37 +0200 Subject: [PATCH 2/7] Add inbound messages and threads commands Add the inbound messages group (list/get/delete plus reply, reply-all, and forward) and the threads group (list/get/delete). Message and thread list paginate via a manual --last-id cursor flag, matching the email-logs command. Extract the shared email-address parser ('Name ' / 'email') into cmdutil and reuse it in the send command and for reply/reply-all/forward, so inbound recipient parsing matches send. Wire both groups into the inbound command. --- internal/cmdutil/address.go | 47 ++++++++++ .../parse_test.go => cmdutil/address_test.go} | 16 ++-- internal/commands/inbound/inbound.go | 4 + internal/commands/inbound/messages/delete.go | 49 ++++++++++ internal/commands/inbound/messages/forward.go | 60 ++++++++++++ internal/commands/inbound/messages/get.go | 50 ++++++++++ internal/commands/inbound/messages/list.go | 84 +++++++++++++++++ .../commands/inbound/messages/messages.go | 23 +++++ internal/commands/inbound/messages/reply.go | 57 ++++++++++++ .../commands/inbound/messages/reply_all.go | 57 ++++++++++++ internal/commands/inbound/messages/send.go | 92 +++++++++++++++++++ internal/commands/inbound/threads/delete.go | 49 ++++++++++ internal/commands/inbound/threads/get.go | 50 ++++++++++ internal/commands/inbound/threads/list.go | 90 ++++++++++++++++++ internal/commands/inbound/threads/threads.go | 20 ++++ internal/commands/send/send.go | 43 --------- internal/commands/send/transactional.go | 30 +++--- 17 files changed, 756 insertions(+), 65 deletions(-) create mode 100644 internal/cmdutil/address.go rename internal/{commands/send/parse_test.go => cmdutil/address_test.go} (80%) create mode 100644 internal/commands/inbound/messages/delete.go create mode 100644 internal/commands/inbound/messages/forward.go create mode 100644 internal/commands/inbound/messages/get.go create mode 100644 internal/commands/inbound/messages/list.go create mode 100644 internal/commands/inbound/messages/messages.go create mode 100644 internal/commands/inbound/messages/reply.go create mode 100644 internal/commands/inbound/messages/reply_all.go create mode 100644 internal/commands/inbound/messages/send.go create mode 100644 internal/commands/inbound/threads/delete.go create mode 100644 internal/commands/inbound/threads/get.go create mode 100644 internal/commands/inbound/threads/list.go create mode 100644 internal/commands/inbound/threads/threads.go diff --git a/internal/cmdutil/address.go b/internal/cmdutil/address.go new file mode 100644 index 0000000..35ae134 --- /dev/null +++ b/internal/cmdutil/address.go @@ -0,0 +1,47 @@ +package cmdutil + +import ( + "fmt" + "regexp" + "strings" +) + +// EmailAddr represents an email address with an optional display name. +type EmailAddr struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` +} + +var emailAddrRe = regexp.MustCompile(`^(.+?)\s*<([^>]+)>$`) + +// ParseEmailAddr parses an email address string in either "email" or "Name " format. +func ParseEmailAddr(s string) (EmailAddr, error) { + s = strings.TrimSpace(s) + if s == "" { + return EmailAddr{}, fmt.Errorf("empty email address") + } + + // Try "Name " format. + if matches := emailAddrRe.FindStringSubmatch(s); matches != nil { + return EmailAddr{ + Name: strings.TrimSpace(matches[1]), + Email: strings.TrimSpace(matches[2]), + }, nil + } + + // Plain email address. + return EmailAddr{Email: s}, nil +} + +// ParseEmailAddrs parses a slice of email address strings. +func ParseEmailAddrs(addrs []string) ([]EmailAddr, error) { + result := make([]EmailAddr, 0, len(addrs)) + for _, s := range addrs { + addr, err := ParseEmailAddr(s) + if err != nil { + return nil, fmt.Errorf("invalid email address %q: %w", s, err) + } + result = append(result, addr) + } + return result, nil +} diff --git a/internal/commands/send/parse_test.go b/internal/cmdutil/address_test.go similarity index 80% rename from internal/commands/send/parse_test.go rename to internal/cmdutil/address_test.go index 4d8926b..6f8ddac 100644 --- a/internal/commands/send/parse_test.go +++ b/internal/cmdutil/address_test.go @@ -1,11 +1,13 @@ -package send +package cmdutil_test import ( "testing" + + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" ) func TestParseEmailAddrPlain(t *testing.T) { - addr, err := parseEmailAddr("email@test.com") + addr, err := cmdutil.ParseEmailAddr("email@test.com") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -18,7 +20,7 @@ func TestParseEmailAddrPlain(t *testing.T) { } func TestParseEmailAddrWithName(t *testing.T) { - addr, err := parseEmailAddr("Name ") + addr, err := cmdutil.ParseEmailAddr("Name ") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -31,7 +33,7 @@ func TestParseEmailAddrWithName(t *testing.T) { } func TestParseEmailAddrWithFullName(t *testing.T) { - addr, err := parseEmailAddr("John Doe ") + addr, err := cmdutil.ParseEmailAddr("John Doe ") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -44,14 +46,14 @@ func TestParseEmailAddrWithFullName(t *testing.T) { } func TestParseEmailAddrEmpty(t *testing.T) { - _, err := parseEmailAddr("") + _, err := cmdutil.ParseEmailAddr("") if err == nil { t.Fatal("expected error for empty address") } } func TestParseEmailAddrWhitespace(t *testing.T) { - addr, err := parseEmailAddr(" email@test.com ") + addr, err := cmdutil.ParseEmailAddr(" email@test.com ") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -61,7 +63,7 @@ func TestParseEmailAddrWhitespace(t *testing.T) { } func TestParseEmailAddrs(t *testing.T) { - addrs, err := parseEmailAddrs([]string{"a@test.com", "Name "}) + addrs, err := cmdutil.ParseEmailAddrs([]string{"a@test.com", "Name "}) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/commands/inbound/inbound.go b/internal/commands/inbound/inbound.go index 8fa2b12..faa6f66 100644 --- a/internal/commands/inbound/inbound.go +++ b/internal/commands/inbound/inbound.go @@ -4,6 +4,8 @@ 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/inboxes" + "github.com/mailtrap/mailtrap-cli/internal/commands/inbound/messages" + "github.com/mailtrap/mailtrap-cli/internal/commands/inbound/threads" "github.com/spf13/cobra" ) @@ -16,6 +18,8 @@ func NewCmdInbound(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(folders.NewCmdFolders(f)) cmd.AddCommand(inboxes.NewCmdInboxes(f)) + cmd.AddCommand(messages.NewCmdMessages(f)) + cmd.AddCommand(threads.NewCmdThreads(f)) return cmd } diff --git a/internal/commands/inbound/messages/delete.go b/internal/commands/inbound/messages/delete.go new file mode 100644 index 0000000..d60b7dc --- /dev/null +++ b/internal/commands/inbound/messages/delete.go @@ -0,0 +1,49 @@ +package messages + +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 + messageID string + ) + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete an inbound message", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", messageID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/messages/%s", inboxID, messageID) + + if err := c.Delete(context.Background(), client.BaseGeneral, path, nil); err != nil { + return err + } + + fmt.Fprintln(f.IOStreams.Out, "Inbound message deleted successfully.") + return nil + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&messageID, "id", "", "Message ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/messages/forward.go b/internal/commands/inbound/messages/forward.go new file mode 100644 index 0000000..28959af --- /dev/null +++ b/internal/commands/inbound/messages/forward.go @@ -0,0 +1,60 @@ +package messages + +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 NewCmdForward(f *cmdutil.Factory) *cobra.Command { + var ( + inboxID string + messageID string + send sendFlags + ) + + cmd := &cobra.Command{ + Use: "forward", + Short: "Forward an inbound message to new recipients (sends a real email)", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", messageID); err != nil { + return err + } + if len(send.to) == 0 { + return fmt.Errorf("--to is required") + } + + c, err := f.NewClient() + if err != nil { + return err + } + + body, err := send.body() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/messages/%s/forward", inboxID, messageID) + + var resp SendMessageResult + if err := c.Post(context.Background(), client.BaseGeneral, path, body, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, sendResultColumns) + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&messageID, "id", "", "Message ID (required)") + send.register(cmd) + + return cmd +} diff --git a/internal/commands/inbound/messages/get.go b/internal/commands/inbound/messages/get.go new file mode 100644 index 0000000..a9d868c --- /dev/null +++ b/internal/commands/inbound/messages/get.go @@ -0,0 +1,50 @@ +package messages + +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 + messageID string + ) + + cmd := &cobra.Command{ + Use: "get", + Short: "Get an inbound message with its body and attachment download URLs", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", messageID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/messages/%s", inboxID, messageID) + + var resp InboundMessage + if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, messageColumns) + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&messageID, "id", "", "Message ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/messages/list.go b/internal/commands/inbound/messages/list.go new file mode 100644 index 0000000..7809507 --- /dev/null +++ b/internal/commands/inbound/messages/list.go @@ -0,0 +1,84 @@ +package messages + +import ( + "context" + "fmt" + "net/url" + + "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" +) + +// InboundMessage represents a received inbound message. Body fields +// (html_body, text_body) are populated only on get-by-id. +type InboundMessage struct { + ID string `json:"id"` + InboxID *int `json:"inbox_id,omitempty"` + From string `json:"from,omitempty"` + To []string `json:"to,omitempty"` + Cc []string `json:"cc,omitempty"` + Subject string `json:"subject,omitempty"` + Size *int `json:"size,omitempty"` + ReceivedAt string `json:"received_at,omitempty"` + ThreadID string `json:"thread_id,omitempty"` + HTMLBody string `json:"html_body,omitempty"` + TextBody string `json:"text_body,omitempty"` +} + +type messagesListResponse struct { + Data []InboundMessage `json:"data"` + TotalCount int `json:"total_count"` + LastID string `json:"last_id"` +} + +var messageColumns = []output.Column{ + {Header: "ID", Field: "id"}, + {Header: "FROM", Field: "from"}, + {Header: "SUBJECT", Field: "subject"}, + {Header: "RECEIVED AT", Field: "received_at"}, + {Header: "THREAD ID", Field: "thread_id"}, +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + inboxID string + lastID string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List received messages 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/messages", inboxID) + + var params url.Values + if lastID != "" { + params = url.Values{} + params.Set("last_id", lastID) + } + + var resp messagesListResponse + if err := c.Get(context.Background(), client.BaseGeneral, path, params, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp.Data, messageColumns) + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&lastID, "last-id", "", "Pagination cursor (last_id from previous response)") + + return cmd +} diff --git a/internal/commands/inbound/messages/messages.go b/internal/commands/inbound/messages/messages.go new file mode 100644 index 0000000..be4078b --- /dev/null +++ b/internal/commands/inbound/messages/messages.go @@ -0,0 +1,23 @@ +package messages + +import ( + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdMessages creates the `inbound messages` command group. +func NewCmdMessages(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "messages", + Short: "Manage inbound messages", + } + + cmd.AddCommand(NewCmdList(f)) + cmd.AddCommand(NewCmdGet(f)) + cmd.AddCommand(NewCmdDelete(f)) + cmd.AddCommand(NewCmdReply(f)) + cmd.AddCommand(NewCmdReplyAll(f)) + cmd.AddCommand(NewCmdForward(f)) + + return cmd +} diff --git a/internal/commands/inbound/messages/reply.go b/internal/commands/inbound/messages/reply.go new file mode 100644 index 0000000..3ed8879 --- /dev/null +++ b/internal/commands/inbound/messages/reply.go @@ -0,0 +1,57 @@ +package messages + +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 NewCmdReply(f *cmdutil.Factory) *cobra.Command { + var ( + inboxID string + messageID string + send sendFlags + ) + + cmd := &cobra.Command{ + Use: "reply", + Short: "Reply to an inbound message (sends a real email to the original sender)", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", messageID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + body, err := send.body() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/messages/%s/reply", inboxID, messageID) + + var resp SendMessageResult + if err := c.Post(context.Background(), client.BaseGeneral, path, body, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, sendResultColumns) + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&messageID, "id", "", "Message ID (required)") + send.register(cmd) + + return cmd +} diff --git a/internal/commands/inbound/messages/reply_all.go b/internal/commands/inbound/messages/reply_all.go new file mode 100644 index 0000000..5e75e5c --- /dev/null +++ b/internal/commands/inbound/messages/reply_all.go @@ -0,0 +1,57 @@ +package messages + +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 NewCmdReplyAll(f *cmdutil.Factory) *cobra.Command { + var ( + inboxID string + messageID string + send sendFlags + ) + + cmd := &cobra.Command{ + Use: "reply-all", + Short: "Reply to an inbound message and copy the original's other recipients (sends a real email)", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", messageID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + body, err := send.body() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/messages/%s/reply_all", inboxID, messageID) + + var resp SendMessageResult + if err := c.Post(context.Background(), client.BaseGeneral, path, body, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, sendResultColumns) + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&messageID, "id", "", "Message ID (required)") + send.register(cmd) + + return cmd +} diff --git a/internal/commands/inbound/messages/send.go b/internal/commands/inbound/messages/send.go new file mode 100644 index 0000000..eeafb17 --- /dev/null +++ b/internal/commands/inbound/messages/send.go @@ -0,0 +1,92 @@ +package messages + +import ( + "fmt" + + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/mailtrap/mailtrap-cli/internal/output" + "github.com/spf13/cobra" +) + +// SendMessageResult is the result of a reply, reply-all, or forward (each sends a real email). +type SendMessageResult struct { + MessageIDs []string `json:"message_ids"` +} + +var sendResultColumns = []output.Column{ + {Header: "MESSAGE IDS", Field: "message_ids"}, +} + +// sendFlags holds the fields shared by the reply, reply-all, and forward commands. +type sendFlags struct { + from string + to []string + cc []string + bcc []string + replyTo string + text string + html string + category string +} + +func (s *sendFlags) register(cmd *cobra.Command) { + cmd.Flags().StringVar(&s.from, "from", "", "Sender address, 'Name ' or 'email' (custom-domain inboxes only)") + cmd.Flags().StringSliceVar(&s.to, "to", nil, "Recipient address, 'Name ' or 'email' (can be repeated)") + cmd.Flags().StringSliceVar(&s.cc, "cc", nil, "CC recipient (can be repeated)") + cmd.Flags().StringSliceVar(&s.bcc, "bcc", nil, "BCC recipient (can be repeated)") + cmd.Flags().StringVar(&s.replyTo, "reply-to", "", "Reply-To address") + cmd.Flags().StringVar(&s.text, "text", "", "Plain-text body") + cmd.Flags().StringVar(&s.html, "html", "", "HTML body") + cmd.Flags().StringVar(&s.category, "category", "", "Email API category for the sent message") +} + +func (s *sendFlags) body() (map[string]interface{}, error) { + body := map[string]interface{}{} + + if s.from != "" { + addr, err := cmdutil.ParseEmailAddr(s.from) + if err != nil { + return nil, fmt.Errorf("invalid --from address: %w", err) + } + body["from"] = addr + } + if len(s.to) > 0 { + addrs, err := cmdutil.ParseEmailAddrs(s.to) + if err != nil { + return nil, fmt.Errorf("invalid --to address: %w", err) + } + body["to"] = addrs + } + if len(s.cc) > 0 { + addrs, err := cmdutil.ParseEmailAddrs(s.cc) + if err != nil { + return nil, fmt.Errorf("invalid --cc address: %w", err) + } + body["cc"] = addrs + } + if len(s.bcc) > 0 { + addrs, err := cmdutil.ParseEmailAddrs(s.bcc) + if err != nil { + return nil, fmt.Errorf("invalid --bcc address: %w", err) + } + body["bcc"] = addrs + } + if s.replyTo != "" { + addr, err := cmdutil.ParseEmailAddr(s.replyTo) + if err != nil { + return nil, fmt.Errorf("invalid --reply-to address: %w", err) + } + body["reply_to"] = addr + } + if s.text != "" { + body["text"] = s.text + } + if s.html != "" { + body["html"] = s.html + } + if s.category != "" { + body["category"] = s.category + } + + return body, nil +} diff --git a/internal/commands/inbound/threads/delete.go b/internal/commands/inbound/threads/delete.go new file mode 100644 index 0000000..bed78f7 --- /dev/null +++ b/internal/commands/inbound/threads/delete.go @@ -0,0 +1,49 @@ +package threads + +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 + threadID string + ) + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a conversation thread", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", threadID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/threads/%s", inboxID, threadID) + + if err := c.Delete(context.Background(), client.BaseGeneral, path, nil); err != nil { + return err + } + + fmt.Fprintln(f.IOStreams.Out, "Inbound thread deleted successfully.") + return nil + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&threadID, "id", "", "Thread ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/threads/get.go b/internal/commands/inbound/threads/get.go new file mode 100644 index 0000000..fc69f56 --- /dev/null +++ b/internal/commands/inbound/threads/get.go @@ -0,0 +1,50 @@ +package threads + +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 + threadID string + ) + + cmd := &cobra.Command{ + Use: "get", + Short: "Get a conversation thread with its messages", + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.RequireFlag("inbox-id", inboxID); err != nil { + return err + } + if err := cmdutil.RequireFlag("id", threadID); err != nil { + return err + } + + c, err := f.NewClient() + if err != nil { + return err + } + + path := fmt.Sprintf("/api/inbound/inboxes/%s/threads/%s", inboxID, threadID) + + var resp InboundThread + if err := c.Get(context.Background(), client.BaseGeneral, path, nil, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp, threadColumns) + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&threadID, "id", "", "Thread ID (required)") + + return cmd +} diff --git a/internal/commands/inbound/threads/list.go b/internal/commands/inbound/threads/list.go new file mode 100644 index 0000000..d5c3449 --- /dev/null +++ b/internal/commands/inbound/threads/list.go @@ -0,0 +1,90 @@ +package threads + +import ( + "context" + "fmt" + "net/url" + + "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" +) + +// InboundThreadMessage represents a message inside a thread. Populated on get-by-id. +type InboundThreadMessage struct { + ID string `json:"id,omitempty"` + VisibilityStatus string `json:"visibility_status,omitempty"` + Direction string `json:"direction,omitempty"` + Subject string `json:"subject,omitempty"` + From string `json:"from,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + DeliveryStatus string `json:"delivery_status,omitempty"` +} + +// InboundThread represents a conversation thread. Messages are populated on get-by-id. +type InboundThread struct { + ID string `json:"id"` + Subject string `json:"subject,omitempty"` + MessageCount *int `json:"message_count,omitempty"` + Size *int `json:"size,omitempty"` + LastActivityAt string `json:"last_activity_at,omitempty"` + Senders []string `json:"senders,omitempty"` + Recipients []string `json:"recipients,omitempty"` + Messages []InboundThreadMessage `json:"messages,omitempty"` +} + +type threadsListResponse struct { + Data []InboundThread `json:"data"` + TotalCount int `json:"total_count"` + LastID string `json:"last_id"` +} + +var threadColumns = []output.Column{ + {Header: "ID", Field: "id"}, + {Header: "SUBJECT", Field: "subject"}, + {Header: "MESSAGES", Field: "message_count"}, + {Header: "LAST ACTIVITY", Field: "last_activity_at"}, +} + +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + var ( + inboxID string + lastID string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List conversation threads 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/threads", inboxID) + + var params url.Values + if lastID != "" { + params = url.Values{} + params.Set("last_id", lastID) + } + + var resp threadsListResponse + if err := c.Get(context.Background(), client.BaseGeneral, path, params, &resp); err != nil { + return err + } + + return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp.Data, threadColumns) + }, + } + + cmd.Flags().StringVar(&inboxID, "inbox-id", "", "Inbox ID (required)") + cmd.Flags().StringVar(&lastID, "last-id", "", "Pagination cursor (last_id from previous response)") + + return cmd +} diff --git a/internal/commands/inbound/threads/threads.go b/internal/commands/inbound/threads/threads.go new file mode 100644 index 0000000..638f14c --- /dev/null +++ b/internal/commands/inbound/threads/threads.go @@ -0,0 +1,20 @@ +package threads + +import ( + "github.com/mailtrap/mailtrap-cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdThreads creates the `inbound threads` command group. +func NewCmdThreads(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "threads", + Short: "Manage inbound conversation threads", + } + + cmd.AddCommand(NewCmdList(f)) + cmd.AddCommand(NewCmdGet(f)) + cmd.AddCommand(NewCmdDelete(f)) + + return cmd +} diff --git a/internal/commands/send/send.go b/internal/commands/send/send.go index a425a47..bdeb9f6 100644 --- a/internal/commands/send/send.go +++ b/internal/commands/send/send.go @@ -1,10 +1,6 @@ package send import ( - "fmt" - "regexp" - "strings" - "github.com/mailtrap/mailtrap-cli/internal/cmdutil" "github.com/spf13/cobra" ) @@ -23,42 +19,3 @@ func NewCmdSend(f *cmdutil.Factory) *cobra.Command { return cmd } - -// emailAddr represents an email address with an optional name. -type emailAddr struct { - Email string `json:"email"` - Name string `json:"name,omitempty"` -} - -// parseEmailAddr parses an email address string in either "email" or "Name " format. -func parseEmailAddr(s string) (emailAddr, error) { - s = strings.TrimSpace(s) - if s == "" { - return emailAddr{}, fmt.Errorf("empty email address") - } - - // Try "Name " format. - re := regexp.MustCompile(`^(.+?)\s*<([^>]+)>$`) - if matches := re.FindStringSubmatch(s); matches != nil { - return emailAddr{ - Name: strings.TrimSpace(matches[1]), - Email: strings.TrimSpace(matches[2]), - }, nil - } - - // Plain email address. - return emailAddr{Email: s}, nil -} - -// parseEmailAddrs parses a slice of email address strings. -func parseEmailAddrs(addrs []string) ([]emailAddr, error) { - result := make([]emailAddr, 0, len(addrs)) - for _, s := range addrs { - addr, err := parseEmailAddr(s) - if err != nil { - return nil, fmt.Errorf("invalid email address %q: %w", s, err) - } - result = append(result, addr) - } - return result, nil -} diff --git a/internal/commands/send/transactional.go b/internal/commands/send/transactional.go index 97ed557..327d826 100644 --- a/internal/commands/send/transactional.go +++ b/internal/commands/send/transactional.go @@ -11,16 +11,16 @@ import ( ) type sendRequest struct { - From emailAddr `json:"from"` - To []emailAddr `json:"to"` - Subject string `json:"subject"` - Text string `json:"text,omitempty"` - HTML string `json:"html,omitempty"` - CC []emailAddr `json:"cc,omitempty"` - BCC []emailAddr `json:"bcc,omitempty"` - Category string `json:"category,omitempty"` - TemplateUUID string `json:"template_uuid,omitempty"` - ReplyTo *emailAddr `json:"reply_to,omitempty"` + From cmdutil.EmailAddr `json:"from"` + To []cmdutil.EmailAddr `json:"to"` + Subject string `json:"subject"` + Text string `json:"text,omitempty"` + HTML string `json:"html,omitempty"` + CC []cmdutil.EmailAddr `json:"cc,omitempty"` + BCC []cmdutil.EmailAddr `json:"bcc,omitempty"` + Category string `json:"category,omitempty"` + TemplateUUID string `json:"template_uuid,omitempty"` + ReplyTo *cmdutil.EmailAddr `json:"reply_to,omitempty"` } type sendResponse struct { @@ -55,12 +55,12 @@ func newSendCmd(f *cmdutil.Factory, name, short string, base client.BaseURL) *co return err } - fromAddr, err := parseEmailAddr(from) + fromAddr, err := cmdutil.ParseEmailAddr(from) if err != nil { return fmt.Errorf("invalid --from address: %w", err) } - toAddrs, err := parseEmailAddrs(to) + toAddrs, err := cmdutil.ParseEmailAddrs(to) if err != nil { return fmt.Errorf("invalid --to address: %w", err) } @@ -76,7 +76,7 @@ func newSendCmd(f *cmdutil.Factory, name, short string, base client.BaseURL) *co } if len(cc) > 0 { - ccAddrs, err := parseEmailAddrs(cc) + ccAddrs, err := cmdutil.ParseEmailAddrs(cc) if err != nil { return fmt.Errorf("invalid --cc address: %w", err) } @@ -84,7 +84,7 @@ func newSendCmd(f *cmdutil.Factory, name, short string, base client.BaseURL) *co } if len(bcc) > 0 { - bccAddrs, err := parseEmailAddrs(bcc) + bccAddrs, err := cmdutil.ParseEmailAddrs(bcc) if err != nil { return fmt.Errorf("invalid --bcc address: %w", err) } @@ -92,7 +92,7 @@ func newSendCmd(f *cmdutil.Factory, name, short string, base client.BaseURL) *co } if replyTo != "" { - addr, err := parseEmailAddr(replyTo) + addr, err := cmdutil.ParseEmailAddr(replyTo) if err != nil { return fmt.Errorf("invalid --reply-to address: %w", err) } From 98e2cc737ea7f1ebb98dc402bcdab38d5bacfd32 Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Mon, 3 Aug 2026 16:45:47 +0200 Subject: [PATCH 3/7] Surface inbound fields on existing commands Add --inbound-inbox-id to webhooks create/update and the inbound_receiving type; add inbound_enabled/inbound_verified to the domain output; add the inbound threading fields (rfc_message_id, in_reply_to, references, thread_id) to the email-log output. --- internal/commands/domains/list.go | 4 ++++ internal/commands/email_logs/list.go | 17 +++++++++++------ internal/commands/webhooks/create.go | 23 ++++++++++++++--------- internal/commands/webhooks/list.go | 18 ++++++++++-------- internal/commands/webhooks/update.go | 17 +++++++++++------ 5 files changed, 50 insertions(+), 29 deletions(-) diff --git a/internal/commands/domains/list.go b/internal/commands/domains/list.go index 07e49ef..7c6c0ad 100644 --- a/internal/commands/domains/list.go +++ b/internal/commands/domains/list.go @@ -15,6 +15,8 @@ type Domain struct { DomainName string `json:"domain_name"` DNSVerified bool `json:"dns_verified"` ComplianceStatus string `json:"compliance_status"` + InboundEnabled bool `json:"inbound_enabled"` + InboundVerified bool `json:"inbound_verified"` } type domainListResponse struct { @@ -26,6 +28,8 @@ var domainColumns = []output.Column{ {Header: "DOMAIN", Field: "domain_name"}, {Header: "DNS VERIFIED", Field: "dns_verified"}, {Header: "COMPLIANCE", Field: "compliance_status"}, + {Header: "INBOUND ENABLED", Field: "inbound_enabled"}, + {Header: "INBOUND VERIFIED", Field: "inbound_verified"}, } func NewCmdList(f *cmdutil.Factory) *cobra.Command { diff --git a/internal/commands/email_logs/list.go b/internal/commands/email_logs/list.go index 606681e..ee7b6d8 100644 --- a/internal/commands/email_logs/list.go +++ b/internal/commands/email_logs/list.go @@ -12,12 +12,16 @@ import ( ) 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"` + 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 { @@ -33,6 +37,7 @@ var emailLogColumns = []output.Column{ {Header: "TO", Field: "to"}, {Header: "STATUS", Field: "status"}, {Header: "SENT AT", Field: "sent_at"}, + {Header: "THREAD ID", Field: "thread_id"}, } func NewCmdList(f *cmdutil.Factory) *cobra.Command { diff --git a/internal/commands/webhooks/create.go b/internal/commands/webhooks/create.go index a71f0e5..b96ea4b 100644 --- a/internal/commands/webhooks/create.go +++ b/internal/commands/webhooks/create.go @@ -23,13 +23,14 @@ var webhookCreateColumns = append(append([]output.Column{}, webhookColumns...), func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { var ( - webhookURL string - webhookType string - active bool - payloadFormat string - sendingStream string - eventTypes []string - domainID int + webhookURL string + webhookType string + active bool + payloadFormat string + sendingStream string + eventTypes []string + domainID int + inboundInboxID int ) cmd := &cobra.Command{ @@ -73,6 +74,9 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { if cmd.Flags().Changed("domain-id") { webhookFields["domain_id"] = domainID } + if cmd.Flags().Changed("inbound-inbox-id") { + webhookFields["inbound_inbox_id"] = inboundInboxID + } body := map[string]interface{}{"webhook": webhookFields} @@ -87,12 +91,13 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().StringVar(&webhookURL, "url", "", "Webhook URL (required)") - cmd.Flags().StringVar(&webhookType, "type", "", "Webhook type: email_sending, audit_log (required)") + cmd.Flags().StringVar(&webhookType, "type", "", "Webhook type: email_sending, audit_log, inbound_receiving (required)") cmd.Flags().BoolVar(&active, "active", true, "Whether the webhook is active") cmd.Flags().StringVar(&payloadFormat, "payload-format", "", "Payload format: json, jsonlines") cmd.Flags().StringVar(&sendingStream, "sending-stream", "", "Sending stream: transactional, bulk") cmd.Flags().StringSliceVar(&eventTypes, "event-types", nil, "Event types (comma-separated): delivery, soft_bounce, bounce, suspension, unsubscribe, open, spam_complaint, click, reject") - cmd.Flags().IntVar(&domainID, "domain-id", 0, "Domain ID to scope the webhook to") + cmd.Flags().IntVar(&domainID, "domain-id", 0, "Domain ID to scope the webhook to (email_sending only)") + cmd.Flags().IntVar(&inboundInboxID, "inbound-inbox-id", 0, "Inbox ID to scope the webhook to (inbound_receiving only; omit to apply to all inboxes)") return cmd } diff --git a/internal/commands/webhooks/list.go b/internal/commands/webhooks/list.go index 45153a8..3a1068d 100644 --- a/internal/commands/webhooks/list.go +++ b/internal/commands/webhooks/list.go @@ -11,14 +11,15 @@ import ( ) type Webhook struct { - ID int `json:"id"` - URL string `json:"url"` - Active bool `json:"active"` - WebhookType string `json:"webhook_type"` - PayloadFormat string `json:"payload_format"` - SendingStream *string `json:"sending_stream,omitempty"` - DomainID *int `json:"domain_id,omitempty"` - EventTypes []string `json:"event_types,omitempty"` + ID int `json:"id"` + URL string `json:"url"` + Active bool `json:"active"` + WebhookType string `json:"webhook_type"` + PayloadFormat string `json:"payload_format"` + SendingStream *string `json:"sending_stream,omitempty"` + DomainID *int `json:"domain_id,omitempty"` + InboundInboxID *int `json:"inbound_inbox_id,omitempty"` + EventTypes []string `json:"event_types,omitempty"` } type webhookListResponse struct { @@ -37,6 +38,7 @@ var webhookColumns = []output.Column{ {Header: "FORMAT", Field: "payload_format"}, {Header: "STREAM", Field: "sending_stream"}, {Header: "DOMAIN ID", Field: "domain_id"}, + {Header: "INBOUND INBOX ID", Field: "inbound_inbox_id"}, {Header: "EVENTS", Field: "event_types"}, } diff --git a/internal/commands/webhooks/update.go b/internal/commands/webhooks/update.go index 4ba0e4b..4be0afc 100644 --- a/internal/commands/webhooks/update.go +++ b/internal/commands/webhooks/update.go @@ -12,11 +12,12 @@ import ( func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { var ( - webhookID string - webhookURL string - active bool - payloadFormat string - eventTypes []string + webhookID string + webhookURL string + active bool + payloadFormat string + eventTypes []string + inboundInboxID int ) cmd := &cobra.Command{ @@ -51,6 +52,9 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { if cmd.Flags().Changed("event-types") { webhookFields["event_types"] = eventTypes } + if cmd.Flags().Changed("inbound-inbox-id") { + webhookFields["inbound_inbox_id"] = inboundInboxID + } body := map[string]interface{}{"webhook": webhookFields} @@ -69,6 +73,7 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().BoolVar(&active, "active", true, "Whether the webhook is active") cmd.Flags().StringVar(&payloadFormat, "payload-format", "", "Payload format: json, jsonlines") cmd.Flags().StringSliceVar(&eventTypes, "event-types", nil, "Event types (comma-separated): delivery, soft_bounce, bounce, suspension, unsubscribe, open, spam_complaint, click, reject") + cmd.Flags().IntVar(&inboundInboxID, "inbound-inbox-id", 0, "Inbox ID to scope the webhook to (inbound_receiving only)") return cmd -} \ No newline at end of file +} From c98e2e08193d932cbb34cd4b936361f2dd6142ba Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Mon, 3 Aug 2026 17:08:39 +0200 Subject: [PATCH 4/7] Add inbound command tests Add tests for the inbound folders, inboxes, messages, and threads commands using an httptest server, asserting the request method, token-scoped /api/inbound path, request bodies, and rendered output. --- .../commands/inbound/folders/folders_test.go | 257 +++++++++++++++++ .../commands/inbound/inboxes/inboxes_test.go | 262 +++++++++++++++++ .../inbound/messages/messages_test.go | 265 ++++++++++++++++++ .../commands/inbound/threads/threads_test.go | 157 +++++++++++ 4 files changed, 941 insertions(+) create mode 100644 internal/commands/inbound/folders/folders_test.go create mode 100644 internal/commands/inbound/inboxes/inboxes_test.go create mode 100644 internal/commands/inbound/messages/messages_test.go create mode 100644 internal/commands/inbound/threads/threads_test.go diff --git a/internal/commands/inbound/folders/folders_test.go b/internal/commands/inbound/folders/folders_test.go new file mode 100644 index 0000000..83fa256 --- /dev/null +++ b/internal/commands/inbound/folders/folders_test.go @@ -0,0 +1,257 @@ +package folders_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/folders" + "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 TestFoldersList(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/folders") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Api-Token") != "test-token" { + t.Errorf("expected Api-Token header 'test-token', got %q", r.Header.Get("Api-Token")) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 101, "name": "Support"}, + {"id": 102, "name": "Sales"}, + }) + }) + defer cleanup() + + cmd := folders.NewCmdFolders(f) + cmd.SetArgs([]string{"list"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Support") { + t.Errorf("expected output to contain 'Support', got:\n%s", buf.String()) + } +} + +func TestFoldersGet(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/folders/101") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": 101, "name": "Support"}) + }) + defer cleanup() + + cmd := folders.NewCmdFolders(f) + cmd.SetArgs([]string{"get", "--id", "101"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Support") { + t.Errorf("expected output to contain 'Support', got:\n%s", buf.String()) + } +} + +func TestFoldersGetMissingID(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + cmd := folders.NewCmdFolders(f) + cmd.SetArgs([]string{"get"}) + 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 TestFoldersCreate(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/folders") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + body, _ := io.ReadAll(r.Body) + var reqBody map[string]interface{} + json.Unmarshal(body, &reqBody) + if reqBody["name"] != "Support" { + t.Errorf("unexpected name: %v", reqBody["name"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": 103, "name": "Support"}) + }) + defer cleanup() + + cmd := folders.NewCmdFolders(f) + cmd.SetArgs([]string{"create", "--name", "Support"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Support") { + t.Errorf("expected output to contain 'Support', got:\n%s", buf.String()) + } +} + +func TestFoldersDelete(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/folders/101") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + }) + defer cleanup() + + cmd := folders.NewCmdFolders(f) + cmd.SetArgs([]string{"delete", "--id", "101"}) + 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()) + } +} + +func TestFoldersListJSON(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": 101, "name": "Support"}}) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := folders.NewCmdFolders(f) + cmd.SetArgs([]string{"list"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result []map[string]interface{} + 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]["name"] != "Support" { + t.Errorf("unexpected JSON result: %v", result) + } +} + +func TestFoldersCreateMissingName(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + cmd := folders.NewCmdFolders(f) + cmd.SetArgs([]string{"create"}) + 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 TestFoldersUpdate(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/folders/101") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + body, _ := io.ReadAll(r.Body) + var reqBody map[string]interface{} + json.Unmarshal(body, &reqBody) + if reqBody["name"] != "Renamed folder" { + t.Errorf("unexpected name: %v", reqBody["name"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": 101, "name": "Renamed folder"}) + }) + defer cleanup() + + cmd := folders.NewCmdFolders(f) + cmd.SetArgs([]string{"update", "--id", "101", "--name", "Renamed folder"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Renamed folder") { + t.Errorf("expected output to contain 'Renamed folder', got:\n%s", buf.String()) + } +} diff --git a/internal/commands/inbound/inboxes/inboxes_test.go b/internal/commands/inbound/inboxes/inboxes_test.go new file mode 100644 index 0000000..0d5366e --- /dev/null +++ b/internal/commands/inbound/inboxes/inboxes_test.go @@ -0,0 +1,262 @@ +package inboxes_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/inboxes" + "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 TestInboxesList(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/folders/90/inboxes") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 201, "name": "Support inbox", "address": "support@inbound-mailtrap.io"}, + {"id": 202, "name": "Catch-all", "address": "catch-all@example.com", "domain_id": 6}, + }) + }) + defer cleanup() + + cmd := inboxes.NewCmdInboxes(f) + cmd.SetArgs([]string{"list", "--folder-id", "90"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "support@inbound-mailtrap.io") { + t.Errorf("expected output to contain inbox address, got:\n%s", buf.String()) + } +} + +func TestInboxesListMissingFolderID(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + cmd := inboxes.NewCmdInboxes(f) + cmd.SetArgs([]string{"list"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when --folder-id is missing") + } + if !strings.Contains(err.Error(), "--folder-id is required") { + t.Errorf("expected '--folder-id is required' error, got: %v", err) + } +} + +func TestInboxesGet(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/inbound/folders/90/inboxes/201") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 201, "name": "Support inbox", "address": "support@inbound-mailtrap.io", + }) + }) + defer cleanup() + + cmd := inboxes.NewCmdInboxes(f) + cmd.SetArgs([]string{"get", "--folder-id", "90", "--id", "201"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Support inbox") { + t.Errorf("expected output to contain 'Support inbox', got:\n%s", buf.String()) + } +} + +func TestInboxesCreateWithDomainID(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/folders/90/inboxes") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + body, _ := io.ReadAll(r.Body) + var reqBody map[string]interface{} + json.Unmarshal(body, &reqBody) + if reqBody["name"] != "Custom domain inbox" { + t.Errorf("unexpected name: %v", reqBody["name"]) + } + if reqBody["domain_id"] != float64(6) { + t.Errorf("expected domain_id 6, got %v", reqBody["domain_id"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 203, "name": "Custom domain inbox", "address": "catch-all@example.com", "domain_id": 6, + }) + }) + defer cleanup() + + cmd := inboxes.NewCmdInboxes(f) + cmd.SetArgs([]string{"create", "--folder-id", "90", "--name", "Custom domain inbox", "--domain-id", "6"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Custom domain inbox") { + t.Errorf("expected output to contain inbox name, got:\n%s", buf.String()) + } +} + +func TestInboxesDelete(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/folders/90/inboxes/201") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + }) + defer cleanup() + + cmd := inboxes.NewCmdInboxes(f) + cmd.SetArgs([]string{"delete", "--folder-id", "90", "--id", "201"}) + 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()) + } +} + +func TestInboxesListJSON(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": 201, "name": "Support inbox", "address": "support@inbound-mailtrap.io"}, + }) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := inboxes.NewCmdInboxes(f) + cmd.SetArgs([]string{"list", "--folder-id", "90"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var result []map[string]interface{} + 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]["address"] != "support@inbound-mailtrap.io" { + t.Errorf("unexpected JSON result: %v", result) + } +} + +func TestInboxesCreateMissingName(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + cmd := inboxes.NewCmdInboxes(f) + cmd.SetArgs([]string{"create", "--folder-id", "90"}) + 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 TestInboxesUpdate(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/folders/90/inboxes/201") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + body, _ := io.ReadAll(r.Body) + var reqBody map[string]interface{} + json.Unmarshal(body, &reqBody) + if reqBody["name"] != "Renamed inbox" { + t.Errorf("unexpected name: %v", reqBody["name"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 201, "name": "Renamed inbox", "address": "support@inbound-mailtrap.io", + }) + }) + defer cleanup() + + cmd := inboxes.NewCmdInboxes(f) + cmd.SetArgs([]string{"update", "--folder-id", "90", "--id", "201", "--name", "Renamed inbox"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Renamed inbox") { + t.Errorf("expected output to contain 'Renamed inbox', got:\n%s", buf.String()) + } +} diff --git a/internal/commands/inbound/messages/messages_test.go b/internal/commands/inbound/messages/messages_test.go new file mode 100644 index 0000000..3da0bbb --- /dev/null +++ b/internal/commands/inbound/messages/messages_test.go @@ -0,0 +1,265 @@ +package messages_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/messages" + "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 TestMessagesList(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/inbound/inboxes/201/messages") { + 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{}{ + {"id": "msg_1", "from": "customer@example.com", "subject": "Support request", "thread_id": "thr_1"}, + }, + "total_count": 1, + "last_id": "msg_1", + }) + }) + defer cleanup() + + cmd := messages.NewCmdMessages(f) + cmd.SetArgs([]string{"list", "--inbox-id", "201"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "Support request") { + t.Errorf("expected output to contain subject, got:\n%s", buf.String()) + } +} + +func TestMessagesListWithCursor(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("last_id"); got != "msg_2" { + t.Errorf("expected last_id=msg_2, got %q", got) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{{"id": "msg_3", "subject": "Next page"}}, + "total_count": 3, + "last_id": "msg_3", + }) + }) + defer cleanup() + + cmd := messages.NewCmdMessages(f) + cmd.SetArgs([]string{"list", "--inbox-id", "201", "--last-id", "msg_2"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestMessagesGet(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/inbound/inboxes/201/messages/msg_1") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "msg_1", "subject": "Support request", "html_body": "

Hello

", + }) + }) + defer cleanup() + + 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) + } + + if !strings.Contains(buf.String(), "Support request") { + t.Errorf("expected output to contain subject, got:\n%s", buf.String()) + } +} + +func TestMessagesReply(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/messages/msg_1/reply") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + body, _ := io.ReadAll(r.Body) + var reqBody map[string]interface{} + json.Unmarshal(body, &reqBody) + if reqBody["text"] != "Thanks!" { + t.Errorf("unexpected text: %v", reqBody["text"]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"message_ids": []string{"0000000000000001"}}) + }) + defer cleanup() + + cmd := messages.NewCmdMessages(f) + cmd.SetArgs([]string{"reply", "--inbox-id", "201", "--id", "msg_1", "--text", "Thanks!"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "0000000000000001") { + t.Errorf("expected output to contain message id, got:\n%s", buf.String()) + } +} + +func TestMessagesForward(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/inbound/inboxes/201/messages/msg_1/forward") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + body, _ := io.ReadAll(r.Body) + var reqBody map[string]interface{} + json.Unmarshal(body, &reqBody) + to, ok := reqBody["to"].([]interface{}) + if !ok || len(to) != 1 { + t.Fatalf("expected one 'to' recipient, got %v", reqBody["to"]) + } + if to[0].(map[string]interface{})["email"] != "colleague@example.com" { + t.Errorf("unexpected to: %v", to[0]) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"message_ids": []string{"0000000000000002"}}) + }) + defer cleanup() + + cmd := messages.NewCmdMessages(f) + cmd.SetArgs([]string{"forward", "--inbox-id", "201", "--id", "msg_1", "--to", "colleague@example.com", "--text", "FYI"}) + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(buf.String(), "0000000000000002") { + t.Errorf("expected output to contain message id, got:\n%s", buf.String()) + } +} + +func TestMessagesForwardMissingTo(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) {}) + defer cleanup() + + cmd := messages.NewCmdMessages(f) + cmd.SetArgs([]string{"forward", "--inbox-id", "201", "--id", "msg_1", "--text", "FYI"}) + cmd.SetOut(buf) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when --to is missing") + } + if !strings.Contains(err.Error(), "--to is required") { + t.Errorf("expected '--to is required' error, got: %v", err) + } +} + +func TestMessagesDelete(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/messages/msg_1") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + }) + defer cleanup() + + cmd := messages.NewCmdMessages(f) + cmd.SetArgs([]string{"delete", "--inbox-id", "201", "--id", "msg_1"}) + 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()) + } +} + +func TestMessagesListJSON(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": "msg_1", "subject": "Support request"}}, + "total_count": 1, + "last_id": "msg_1", + }) + }) + defer cleanup() + + viper.Set("output", "json") + + cmd := messages.NewCmdMessages(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 []map[string]interface{} + 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"] != "msg_1" { + t.Errorf("unexpected JSON result: %v", result) + } +} diff --git a/internal/commands/inbound/threads/threads_test.go b/internal/commands/inbound/threads/threads_test.go new file mode 100644 index 0000000..6a1064b --- /dev/null +++ b/internal/commands/inbound/threads/threads_test.go @@ -0,0 +1,157 @@ +package threads_test + +import ( + "bytes" + "encoding/json" + "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/threads" + "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 TestThreadsList(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/inbound/inboxes/201/threads") { + 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{}{ + {"id": "thr_1", "subject": "Support request", "message_count": 3}, + }, + "total_count": 1, + "last_id": "thr_1", + }) + }) + 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) + } + + if !strings.Contains(buf.String(), "Support request") { + t.Errorf("expected output to contain subject, got:\n%s", buf.String()) + } +} + +func TestThreadsGet(t *testing.T) { + f, buf, cleanup := setupTest(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/inbound/inboxes/201/threads/thr_1") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "thr_1", "subject": "Support request", + "messages": []map[string]interface{}{ + {"id": "msg_1", "direction": "inbound", "visibility_status": "available"}, + }, + }) + }) + defer cleanup() + + 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) + } + + if !strings.Contains(buf.String(), "Support request") { + t.Errorf("expected output to contain subject, got:\n%s", buf.String()) + } +} + +func TestThreadsDelete(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/threads/thr_1") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + }) + defer cleanup() + + cmd := threads.NewCmdThreads(f) + cmd.SetArgs([]string{"delete", "--inbox-id", "201", "--id", "thr_1"}) + 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()) + } +} + +func TestThreadsListJSON(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", "subject": "Support request"}}, + "total_count": 1, + "last_id": "thr_1", + }) + }) + defer cleanup() + + viper.Set("output", "json") + + 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) + } + + var result []map[string]interface{} + 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"] != "thr_1" { + t.Errorf("unexpected JSON result: %v", result) + } +} From 9388c5b5351325c68aa8b63c0de4fa35bfb314fd Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Mon, 3 Aug 2026 17:31:16 +0200 Subject: [PATCH 5/7] Document inbound commands Add the inbound command reference (skills/mailtrap-cli/references/inbound.md), a Commands-table row and usage examples in the README, an Inbound section in the test plan, and the inbound entry in the skill's command-group table. --- README.md | 8 + docs/TEST_PLAN.md | 28 ++++ skills/mailtrap-cli/SKILL.md | 3 +- skills/mailtrap-cli/references/inbound.md | 185 ++++++++++++++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 skills/mailtrap-cli/references/inbound.md diff --git a/README.md b/README.md index ad7ebd6..f39bf50 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,13 @@ 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) +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 + # Contacts mailtrap contacts create --email "user@example.com" --first-name "John" mailtrap contact-lists list @@ -152,6 +159,7 @@ mailtrap domains list --output text | **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` | | **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 403910f..74e558e 100644 --- a/docs/TEST_PLAN.md +++ b/docs/TEST_PLAN.md @@ -335,6 +335,33 @@ Prerequisite: Send an email with an attachment to the sandbox. | 21.1 | Configure with token | `mailtrap configure --api-token test-token-123` | Config saved message | | 21.2 | Configure without token | `mailtrap configure` | Error or prompts for token | +## 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. + +| # | Test | Command | Expected | +|---|------|---------|----------| +| 22.1 | List folders | `mailtrap inbound folders list` | Table with folder entries | +| 22.2 | Create folder | `mailtrap inbound folders create --name "Support"` | New folder in output | +| 22.3 | Get folder | `mailtrap inbound folders get --id ` | Single folder details | +| 22.4 | Update folder | `mailtrap inbound folders update --id --name "Renamed"` | Updated folder | +| 22.5 | Folder missing ID | `mailtrap inbound folders get` | Error: `--id is required` | +| 22.6 | List inboxes | `mailtrap inbound inboxes list --folder-id ` | Table with inbox entries | +| 22.7 | Create inbox | `mailtrap inbound inboxes create --folder-id --name "Support inbox"` | New inbox in output | +| 22.8 | Get inbox | `mailtrap inbound inboxes get --folder-id --id ` | Single inbox details | +| 22.9 | Inbox missing folder-id | `mailtrap inbound inboxes list` | Error: `--folder-id is required` | +| 22.10 | List messages | `mailtrap inbound messages list --inbox-id ` | Table with message entries | +| 22.11 | List messages (page) | `mailtrap inbound messages list --inbox-id --last-id ` | Next page | +| 22.12 | Get message | `mailtrap inbound messages get --inbox-id --id ` | Message with body | +| 22.13 | Reply | `mailtrap inbound messages reply --inbox-id --id --text "Thanks"` | Message IDs of the sent reply | +| 22.14 | Forward | `mailtrap inbound messages forward --inbox-id --id --to a@b.com` | Message IDs of the forward | +| 22.15 | Forward missing to | `mailtrap inbound messages forward --inbox-id --id ` | Error: `--to is required` | +| 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`). + --- ## Discovered Bugs / Issues @@ -376,6 +403,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) --- diff --git a/skills/mailtrap-cli/SKILL.md b/skills/mailtrap-cli/SKILL.md index f823e7f..64c7e58 100644 --- a/skills/mailtrap-cli/SKILL.md +++ b/skills/mailtrap-cli/SKILL.md @@ -37,7 +37,7 @@ For scripting and piping, always use `--output json`. - **Batch operations**: use `--file path/to/payload.json` with a JSON array of email objects - **Config priority**: CLI flags > environment variables > config file - **Exit codes**: 0 on success, 1 on error (with descriptive message) -- **API base**: all requests go to `https://mailtrap.io/api/accounts/{account-id}/...` +- **API base**: most requests go to `https://mailtrap.io/api/accounts/{account-id}/...`. The `inbound` group is the exception — it goes to `https://mailtrap.io/api/inbound/...` and takes no `--account-id`. ## Command Groups @@ -48,6 +48,7 @@ For scripting and piping, always use `--output json`. | `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) | | `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 new file mode 100644 index 0000000..5c454ad --- /dev/null +++ b/skills/mailtrap-cli/references/inbound.md @@ -0,0 +1,185 @@ +# inbound + +Detailed flag specifications for `mailtrap inbound` commands (folders, inboxes, messages, threads). + +`inbound` commands go to `https://mailtrap.io/api/inbound/...` and do **not** use `--account-id`. + +--- + +## inbound folders list + +List all inbound folders. No additional flags. + +--- + +## inbound folders get + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--id` | string | Yes | Folder ID | + +--- + +## inbound folders create + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--name` | string | Yes | Folder name | + +--- + +## inbound folders update + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--id` | string | Yes | Folder ID | +| `--name` | string | No | New folder name | + +--- + +## inbound folders delete + +Removes the folder and all of its inboxes. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--id` | string | Yes | Folder ID | + +--- + +## inbound inboxes list + +Inboxes are managed within a folder. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--folder-id` | string | Yes | Folder ID | + +--- + +## inbound inboxes get + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--folder-id` | string | Yes | Folder ID | +| `--id` | string | Yes | Inbox ID | + +--- + +## inbound inboxes create + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--folder-id` | string | Yes | Folder ID | +| `--name` | string | Yes | Inbox name | +| `--domain-id` | int | No | Sending domain ID for a custom-domain (catch-all) inbox; omit for a Mailtrap-hosted inbox | + +--- + +## inbound inboxes update + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--folder-id` | string | Yes | Folder ID | +| `--id` | string | Yes | Inbox ID | +| `--name` | string | No | New inbox name | + +--- + +## inbound inboxes delete + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--folder-id` | string | Yes | Folder ID | +| `--id` | string | Yes | Inbox ID | + +--- + +## inbound messages list + +Messages and threads are accessed via the top-level inbox route (`/api/inbound/inboxes/{inbox-id}/...`). + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | +| `--last-id` | string | No | Pagination cursor (`last_id` from the previous response) | + +--- + +## inbound messages get + +Returns the message with its body and attachment download URLs. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | +| `--id` | string | Yes | Message ID | + +--- + +## inbound messages delete + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | +| `--id` | string | Yes | Message ID | + +--- + +## inbound messages reply / reply-all / forward + +Each sends a **real email** and returns the sent message IDs. + +- `reply` — sends to the original sender. +- `reply-all` — sends to the original sender and copies the other recipients. +- `forward` — sends to new recipients; `--to` is required. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | +| `--id` | string | Yes | Message ID | +| `--from` | string | No | Sender address, `Name ` or `email` (custom-domain inboxes only) | +| `--to` | string | forward only | Recipient address, `Name ` or `email` (repeatable) | +| `--cc` | string | No | CC recipient (repeatable) | +| `--bcc` | string | No | BCC recipient (repeatable) | +| `--reply-to` | string | No | Reply-To address | +| `--text` | string | No | Plain-text body | +| `--html` | string | No | HTML body | +| `--category` | string | No | Email API category for the sent message | + +**Notes:** + +- Addresses accept `Name ` or a bare `email`, the same as the `send` command. +- `reply`/`reply-all` require a body (`--text` and/or `--html`); the subject is derived from the original message (prefixed `Re:`) — there is no `--subject` flag. +- `forward` may omit the body (the original is quoted automatically) and requires at least one `--to`. + +--- + +## inbound threads list + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | +| `--last-id` | string | No | Pagination cursor (`last_id` from the previous response) | + +--- + +## inbound threads get + +Returns the thread with its messages embedded (oldest first). + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | +| `--id` | string | Yes | Thread ID | + +--- + +## inbound threads delete + +Inbound messages in the thread are removed; sent messages are preserved. + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | string | Yes | Inbox ID | +| `--id` | string | Yes | Thread ID | From 61c14024048c7a60f9754383e4dd396d70329ea8 Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Mon, 3 Aug 2026 17:57:09 +0200 Subject: [PATCH 6/7] Fix webhook create flag help Include the campaigns webhook type in --type, and correct --domain-id to note it scopes email_sending and campaigns (not email_sending only). --- internal/commands/webhooks/create.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/commands/webhooks/create.go b/internal/commands/webhooks/create.go index b96ea4b..f54d9ad 100644 --- a/internal/commands/webhooks/create.go +++ b/internal/commands/webhooks/create.go @@ -91,12 +91,12 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().StringVar(&webhookURL, "url", "", "Webhook URL (required)") - cmd.Flags().StringVar(&webhookType, "type", "", "Webhook type: email_sending, audit_log, inbound_receiving (required)") + cmd.Flags().StringVar(&webhookType, "type", "", "Webhook type: email_sending, campaigns, audit_log, inbound_receiving (required)") cmd.Flags().BoolVar(&active, "active", true, "Whether the webhook is active") cmd.Flags().StringVar(&payloadFormat, "payload-format", "", "Payload format: json, jsonlines") cmd.Flags().StringVar(&sendingStream, "sending-stream", "", "Sending stream: transactional, bulk") cmd.Flags().StringSliceVar(&eventTypes, "event-types", nil, "Event types (comma-separated): delivery, soft_bounce, bounce, suspension, unsubscribe, open, spam_complaint, click, reject") - cmd.Flags().IntVar(&domainID, "domain-id", 0, "Domain ID to scope the webhook to (email_sending only)") + cmd.Flags().IntVar(&domainID, "domain-id", 0, "Domain ID to scope the webhook to (email_sending and campaigns)") cmd.Flags().IntVar(&inboundInboxID, "inbound-inbox-id", 0, "Inbox ID to scope the webhook to (inbound_receiving only; omit to apply to all inboxes)") return cmd From 16cba725b4fe40b806625f64257cec0bce37b81c Mon Sep 17 00:00:00 2001 From: Marcin Klocek Date: Mon, 3 Aug 2026 17:57:15 +0200 Subject: [PATCH 7/7] Surface pagination cursor in list output inbound messages/threads list and email-logs list now print the next page cursor (--last-id / --cursor) after the table so it is discoverable for the next request. JSON output is unchanged. --- internal/commands/email_logs/email_logs_test.go | 3 +++ internal/commands/email_logs/list.go | 9 ++++++++- internal/commands/inbound/messages/list.go | 9 ++++++++- internal/commands/inbound/messages/messages_test.go | 3 +++ internal/commands/inbound/threads/list.go | 9 ++++++++- internal/commands/inbound/threads/threads_test.go | 3 +++ 6 files changed, 33 insertions(+), 3 deletions(-) diff --git a/internal/commands/email_logs/email_logs_test.go b/internal/commands/email_logs/email_logs_test.go index 5d587ff..bbc2489 100644 --- a/internal/commands/email_logs/email_logs_test.go +++ b/internal/commands/email_logs/email_logs_test.go @@ -79,6 +79,9 @@ func TestEmailLogsList(t *testing.T) { if !strings.Contains(output, "delivered") { t.Errorf("expected output to contain 'delivered', got:\n%s", output) } + if !strings.Contains(output, "--cursor cursor-abc") { + t.Errorf("expected output to surface the next-page cursor, got:\n%s", output) + } } func TestEmailLogsListJSON(t *testing.T) { diff --git a/internal/commands/email_logs/list.go b/internal/commands/email_logs/list.go index ee7b6d8..23913c8 100644 --- a/internal/commands/email_logs/list.go +++ b/internal/commands/email_logs/list.go @@ -2,6 +2,7 @@ package emaillogs import ( "context" + "fmt" "net/url" "github.com/mailtrap/mailtrap-cli/internal/client" @@ -115,7 +116,13 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { } format := cmdutil.GetOutputFormat() - return output.Print(f.IOStreams.Out, format, resp.Messages, emailLogColumns) + 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 }, } diff --git a/internal/commands/inbound/messages/list.go b/internal/commands/inbound/messages/list.go index 7809507..a7f39e3 100644 --- a/internal/commands/inbound/messages/list.go +++ b/internal/commands/inbound/messages/list.go @@ -73,7 +73,14 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return err } - return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp.Data, messageColumns) + format := cmdutil.GetOutputFormat() + if err := output.Print(f.IOStreams.Out, format, resp.Data, messageColumns); err != nil { + return err + } + if format != output.FormatJSON && resp.LastID != "" { + fmt.Fprintf(f.IOStreams.Out, "\nNext page: --last-id %s\n", resp.LastID) + } + return nil }, } diff --git a/internal/commands/inbound/messages/messages_test.go b/internal/commands/inbound/messages/messages_test.go index 3da0bbb..2b198e6 100644 --- a/internal/commands/inbound/messages/messages_test.go +++ b/internal/commands/inbound/messages/messages_test.go @@ -71,6 +71,9 @@ func TestMessagesList(t *testing.T) { if !strings.Contains(buf.String(), "Support request") { t.Errorf("expected output to contain subject, got:\n%s", buf.String()) } + if !strings.Contains(buf.String(), "--last-id msg_1") { + t.Errorf("expected output to surface the next-page cursor, got:\n%s", buf.String()) + } } func TestMessagesListWithCursor(t *testing.T) { diff --git a/internal/commands/inbound/threads/list.go b/internal/commands/inbound/threads/list.go index d5c3449..5e37edb 100644 --- a/internal/commands/inbound/threads/list.go +++ b/internal/commands/inbound/threads/list.go @@ -79,7 +79,14 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return err } - return output.Print(f.IOStreams.Out, cmdutil.GetOutputFormat(), resp.Data, threadColumns) + format := cmdutil.GetOutputFormat() + if err := output.Print(f.IOStreams.Out, format, resp.Data, threadColumns); err != nil { + return err + } + if format != output.FormatJSON && resp.LastID != "" { + fmt.Fprintf(f.IOStreams.Out, "\nNext page: --last-id %s\n", resp.LastID) + } + return nil }, } diff --git a/internal/commands/inbound/threads/threads_test.go b/internal/commands/inbound/threads/threads_test.go index 6a1064b..6fcd32f 100644 --- a/internal/commands/inbound/threads/threads_test.go +++ b/internal/commands/inbound/threads/threads_test.go @@ -70,6 +70,9 @@ func TestThreadsList(t *testing.T) { if !strings.Contains(buf.String(), "Support request") { t.Errorf("expected output to contain subject, got:\n%s", buf.String()) } + if !strings.Contains(buf.String(), "--last-id thr_1") { + t.Errorf("expected output to surface the next-page cursor, got:\n%s", buf.String()) + } } func TestThreadsGet(t *testing.T) {