diff --git a/README.md b/README.md index 62c1a3c..6e3b6ef 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Account & organization management: - Permissions — list resources & bulk-update access permissions — [`examples/permissions`](examples/permissions) - API token management — list, create, get, reset & delete — [`examples/api-tokens`](examples/api-tokens) - Billing — current billing-cycle usage across Sandbox, Sending & Marketing — [`examples/billing`](examples/billing) +- Organization sub-accounts — list & create — [`examples/sub-accounts`](examples/sub-accounts) Contact management: diff --git a/client.go b/client.go index 8dea6cc..88d449e 100644 --- a/client.go +++ b/client.go @@ -57,6 +57,9 @@ type Client struct { bulk bool sandboxID int64 + // organizationID scopes SubAccounts operations; set via WithOrganizationID. + organizationID int64 + // Projects manages sandbox projects. Projects *ProjectsService // Sandboxes manages sandboxes (testing inboxes) and their actions. @@ -89,6 +92,8 @@ type Client struct { APITokens *APITokensService // Billing reads current billing-cycle usage. Billing *BillingService + // SubAccounts lists and creates sub-accounts within an organization. + SubAccounts *SubAccountsService // Contacts manages email marketing contacts. Contacts *ContactsService @@ -161,6 +166,7 @@ func NewClient(token string, opts ...Option) (*Client, error) { c.Permissions = &PermissionsService{client: c} c.APITokens = &APITokensService{client: c} c.Billing = &BillingService{client: c} + c.SubAccounts = &SubAccountsService{client: c} c.Contacts = &ContactsService{client: c} c.ContactEvents = &ContactEventsService{client: c} c.ContactLists = &ContactListsService{client: c} @@ -247,6 +253,19 @@ func WithSandboxID(sandboxID int64) Option { } } +// WithOrganizationID sets the organization that SubAccounts operations target. +// A token can access a single organization, so it is configured once here +// rather than passed to every call. Required before using client.SubAccounts. +func WithOrganizationID(organizationID int64) Option { + return func(c *Client) error { + if organizationID <= 0 { + return fmt.Errorf("mailtrap: organization ID must be valid, got %d", organizationID) + } + c.organizationID = organizationID + return nil + } +} + // WithBaseURL overrides the base URL for a host, primarily for testing against // an httptest server. func WithBaseURL(host Host, rawURL string) Option { diff --git a/client_test.go b/client_test.go index bcbca71..378797a 100644 --- a/client_test.go +++ b/client_test.go @@ -65,6 +65,8 @@ func TestNewClient_validation(t *testing.T) { {name: "bulk and sandbox conflict", token: "tok", opts: []mailtrap.Option{mailtrap.WithSandbox(true), mailtrap.WithSandboxID(1), mailtrap.WithBulk(true)}, wantErr: true}, {name: "stray sandbox id ignored without sandbox", token: "tok", opts: []mailtrap.Option{mailtrap.WithSandboxID(1)}}, {name: "negative sandbox id", token: "tok", opts: []mailtrap.Option{mailtrap.WithSandboxID(-1)}, wantErr: true}, + {name: "valid organization id", token: "tok", opts: []mailtrap.Option{mailtrap.WithOrganizationID(1001)}}, + {name: "invalid organization id", token: "tok", opts: []mailtrap.Option{mailtrap.WithOrganizationID(0)}, wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/examples/sub-accounts/main.go b/examples/sub-accounts/main.go new file mode 100644 index 0000000..c8692a3 --- /dev/null +++ b/examples/sub-accounts/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "strconv" + + "github.com/mailtrap/mailtrap-go" +) + +func main() { + organizationID, err := strconv.ParseInt(os.Getenv("MAILTRAP_ORGANIZATION_ID"), 10, 64) + if err != nil { + log.Fatal(err) + } + + client, err := mailtrap.NewClient(os.Getenv("MAILTRAP_API_TOKEN"), + mailtrap.WithOrganizationID(organizationID), + ) + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + + subAccount, _, err := client.SubAccounts.Create(ctx, "Development Team Account") + if err != nil { + log.Fatal(err) + } + fmt.Printf("created sub-account %d (%s)\n", subAccount.ID, subAccount.Name) + + subAccounts, _, err := client.SubAccounts.List(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Printf("organization has %d sub-account(s)\n", len(subAccounts)) +} diff --git a/sub_accounts.go b/sub_accounts.go new file mode 100644 index 0000000..5bbe66f --- /dev/null +++ b/sub_accounts.go @@ -0,0 +1,49 @@ +package mailtrap + +import ( + "context" + "errors" + "fmt" + "net/http" +) + +// errNoOrganizationID is returned by SubAccounts operations when the client was +// created without WithOrganizationID. +var errNoOrganizationID = errors.New("mailtrap: WithOrganizationID is required for sub-account operations") + +// SubAccountsService lists and creates the sub-accounts of an organization. +// These endpoints require an organization token with sub-account management +// permission. +type SubAccountsService struct { + client *Client +} + +// SubAccount is an account within an organization. +type SubAccount struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +// List returns the sub-accounts of the organization set with WithOrganizationID. +func (s *SubAccountsService) List(ctx context.Context) ([]*SubAccount, *Response, error) { + if s.client.organizationID == 0 { + return nil, nil, errNoOrganizationID + } + path := fmt.Sprintf("/api/organizations/%d/sub_accounts", s.client.organizationID) + var subAccounts []*SubAccount + resp, err := s.client.do(ctx, HostGeneral, http.MethodGet, path, nil, nil, &subAccounts) + return subAccounts, resp, err +} + +// Create adds a sub-account with the given name under the organization set with +// WithOrganizationID. +func (s *SubAccountsService) Create(ctx context.Context, name string) (*SubAccount, *Response, error) { + if s.client.organizationID == 0 { + return nil, nil, errNoOrganizationID + } + path := fmt.Sprintf("/api/organizations/%d/sub_accounts", s.client.organizationID) + body := map[string]any{"account": map[string]string{"name": name}} + subAccount := new(SubAccount) + resp, err := s.client.do(ctx, HostGeneral, http.MethodPost, path, nil, body, subAccount) + return subAccount, resp, err +} diff --git a/sub_accounts_test.go b/sub_accounts_test.go new file mode 100644 index 0000000..f1b7259 --- /dev/null +++ b/sub_accounts_test.go @@ -0,0 +1,50 @@ +package mailtrap_test + +import ( + "context" + "net/http" + "testing" + + "github.com/mailtrap/mailtrap-go" +) + +func TestSubAccounts_List(t *testing.T) { + mux, client := setup(t, mailtrap.WithOrganizationID(1001)) + mux.HandleFunc("GET /api/organizations/1001/sub_accounts", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[{"id":12345,"name":"Development Team Account"},{"id":12346,"name":"QA Team Account"}]`)) + }) + + subAccounts, _, err := client.SubAccounts.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(subAccounts) != 2 || subAccounts[0].ID != 12345 || subAccounts[0].Name != "Development Team Account" { + t.Fatalf("subAccounts = %+v", subAccounts) + } +} + +func TestSubAccounts_Create(t *testing.T) { + mux, client := setup(t, mailtrap.WithOrganizationID(1001)) + mux.HandleFunc("POST /api/organizations/1001/sub_accounts", func(w http.ResponseWriter, r *http.Request) { + wantJSONBody(t, r, `{"account":{"name":"New Team Account"}}`) + _, _ = w.Write([]byte(`{"id":12347,"name":"New Team Account"}`)) + }) + + subAccount, _, err := client.SubAccounts.Create(context.Background(), "New Team Account") + if err != nil { + t.Fatalf("Create: %v", err) + } + if subAccount.ID != 12347 || subAccount.Name != "New Team Account" { + t.Errorf("subAccount = %+v", subAccount) + } +} + +func TestSubAccounts_requiresOrganizationID(t *testing.T) { + _, client := setup(t) // no WithOrganizationID + if _, _, err := client.SubAccounts.List(context.Background()); err == nil { + t.Error("List without organization ID: want error, got nil") + } + if _, _, err := client.SubAccounts.Create(context.Background(), "x"); err == nil { + t.Error("Create without organization ID: want error, got nil") + } +}