Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <MESSAGE_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
Expand Down Expand Up @@ -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` |
Expand Down
4 changes: 4 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down
28 changes: 28 additions & 0 deletions docs/TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <FOLDER_ID>` | Single folder details |
| 22.4 | Update folder | `mailtrap inbound folders update --id <FOLDER_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 <FOLDER_ID>` | Table with inbox entries |
| 22.7 | Create inbox | `mailtrap inbound inboxes create --folder-id <FOLDER_ID> --name "Support inbox"` | New inbox in output |
| 22.8 | Get inbox | `mailtrap inbound inboxes get --folder-id <FOLDER_ID> --id <INBOX_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 <INBOX_ID>` | Table with message entries |
| 22.11 | List messages (page) | `mailtrap inbound messages list --inbox-id <INBOX_ID> --last-id <LAST_ID>` | Next page |
| 22.12 | Get message | `mailtrap inbound messages get --inbox-id <INBOX_ID> --id <MESSAGE_ID>` | Message with body |
| 22.13 | Reply | `mailtrap inbound messages reply --inbox-id <INBOX_ID> --id <MESSAGE_ID> --text "Thanks"` | Message IDs of the sent reply |
| 22.14 | Forward | `mailtrap inbound messages forward --inbox-id <INBOX_ID> --id <MESSAGE_ID> --to a@b.com` | Message IDs of the forward |
| 22.15 | Forward missing to | `mailtrap inbound messages forward --inbox-id <INBOX_ID> --id <MESSAGE_ID>` | Error: `--to is required` |
| 22.16 | List threads | `mailtrap inbound threads list --inbox-id <INBOX_ID>` | Table with thread entries |
| 22.17 | Get thread | `mailtrap inbound threads get --inbox-id <INBOX_ID> --id <THREAD_ID>` | Thread with messages |
| 22.18 | Delete message | `mailtrap inbound messages delete --inbox-id <INBOX_ID> --id <MESSAGE_ID>` | Success message |

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

---

## Discovered Bugs / Issues
Expand Down Expand Up @@ -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)

---

Expand Down
47 changes: 47 additions & 0 deletions internal/cmdutil/address.go
Original file line number Diff line number Diff line change
@@ -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 <email>" format.
func ParseEmailAddr(s string) (EmailAddr, error) {
s = strings.TrimSpace(s)
if s == "" {
return EmailAddr{}, fmt.Errorf("empty email address")
}

// Try "Name <email>" 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
Comment thread
mklocek marked this conversation as resolved.
}

// 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
}
Original file line number Diff line number Diff line change
@@ -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)
}
Expand All @@ -18,7 +20,7 @@ func TestParseEmailAddrPlain(t *testing.T) {
}

func TestParseEmailAddrWithName(t *testing.T) {
addr, err := parseEmailAddr("Name <email@test.com>")
addr, err := cmdutil.ParseEmailAddr("Name <email@test.com>")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -31,7 +33,7 @@ func TestParseEmailAddrWithName(t *testing.T) {
}

func TestParseEmailAddrWithFullName(t *testing.T) {
addr, err := parseEmailAddr("John Doe <john@example.com>")
addr, err := cmdutil.ParseEmailAddr("John Doe <john@example.com>")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -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)
}
Expand All @@ -61,7 +63,7 @@ func TestParseEmailAddrWhitespace(t *testing.T) {
}

func TestParseEmailAddrs(t *testing.T) {
addrs, err := parseEmailAddrs([]string{"a@test.com", "Name <b@test.com>"})
addrs, err := cmdutil.ParseEmailAddrs([]string{"a@test.com", "Name <b@test.com>"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down
4 changes: 4 additions & 0 deletions internal/commands/domains/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions internal/commands/email_logs/email_logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
26 changes: 19 additions & 7 deletions internal/commands/email_logs/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package emaillogs

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

"github.com/mailtrap/mailtrap-cli/internal/client"
Expand All @@ -12,12 +13,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 {
Expand All @@ -33,6 +38,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 {
Expand Down Expand Up @@ -110,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
},
}

Expand Down
42 changes: 42 additions & 0 deletions internal/commands/inbound/folders/create.go
Original file line number Diff line number Diff line change
@@ -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
}
42 changes: 42 additions & 0 deletions internal/commands/inbound/folders/delete.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 22 additions & 0 deletions internal/commands/inbound/folders/folders.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading