From 88d455cf93e9834b53564a654522ac1995120469 Mon Sep 17 00:00:00 2001 From: sadbeam Date: Mon, 8 Jan 2024 13:21:27 +0200 Subject: [PATCH 1/2] DE-1201/subaccounts crud and on-behalf-on-header support --- httphelpers.go | 4 +- mailgun.go | 24 +++++ messages_test.go | 39 +++++++ mock.go | 9 ++ mock_subaccounts.go | 127 +++++++++++++++++++++++ subaccounts.go | 246 ++++++++++++++++++++++++++++++++++++++++++++ subaccounts_test.go | 122 ++++++++++++++++++++++ 7 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 mock_subaccounts.go create mode 100644 subaccounts.go create mode 100644 subaccounts_test.go diff --git a/httphelpers.go b/httphelpers.go index 88121a5c..b88f820d 100644 --- a/httphelpers.go +++ b/httphelpers.go @@ -18,7 +18,7 @@ import ( "github.com/pkg/errors" ) -var validURL = regexp.MustCompile(`/v[2-4].*`) +var validURL = regexp.MustCompile(`/v[2-5].*`) type httpRequest struct { URL string @@ -364,7 +364,7 @@ func (r *httpRequest) curlString(req *http.Request, p payload) string { parts = append(parts, fmt.Sprintf("-H \"Host: %s\"", req.Host)) } - //parts = append(parts, fmt.Sprintf(" --user '%s:%s'", r.BasicAuthUser, r.BasicAuthPassword)) + // parts = append(parts, fmt.Sprintf(" --user '%s:%s'", r.BasicAuthUser, r.BasicAuthPassword)) if p != nil { if p.getContentType() == "application/json" { diff --git a/mailgun.go b/mailgun.go index 9ade5868..5f77d7ba 100644 --- a/mailgun.go +++ b/mailgun.go @@ -116,6 +116,9 @@ const ( listsEndpoint = "lists" basicAuthUser = "api" templatesEndpoint = "templates" + accountsEndpoint = "accounts" + subaccountsEndpoint = "subaccounts" + OnBehalfOfHeader = "X-Mailgun-On-Behalf-Of" ) // Mailgun defines the supported subset of the Mailgun API. @@ -246,6 +249,12 @@ type Mailgun interface { UpdateTemplateVersion(ctx context.Context, templateName string, version *TemplateVersion) error DeleteTemplateVersion(ctx context.Context, templateName, tag string) error ListTemplateVersions(templateName string, opts *ListOptions) *TemplateVersionsIterator + + ListSubaccounts(opts *ListSubaccountsOptions) *SubaccountsIterator + CreateSubaccount(ctx context.Context, subaccountName string) (SubaccountResponse, error) + SubaccountDetails(ctx context.Context, subaccountId string) (SubaccountResponse, error) + EnableSubaccount(ctx context.Context, subaccountId string) (SubaccountResponse, error) + DisableSubaccount(ctx context.Context, subaccountId string) (SubaccountResponse, error) } // MailgunImpl bundles data needed by a large number of methods in order to interact with the Mailgun API. @@ -317,6 +326,17 @@ func (mg *MailgunImpl) SetClient(c *http.Client) { mg.client = c } +// SetOnBehalfOfSubaccount sets X-Mailgun-On-Behalf-Of header to SUBACCOUNT_ACCOUNT_ID in order to perform API request +// on behalf of subaccount. +func (mg *MailgunImpl) SetOnBehalfOfSubaccount(subaccountId string) { + mg.AddOverrideHeader(OnBehalfOfHeader, subaccountId) +} + +// RemoveOnBehalfOfSubaccount remove X-Mailgun-On-Behalf-Of header for primary usage. +func (mg *MailgunImpl) RemoveOnBehalfOfSubaccount() { + delete(mg.overrideHeaders, OnBehalfOfHeader) +} + // SetAPIBase updates the API Base URL for this client. // // For EU Customers // mg.SetAPIBase(mailgun.APIBaseEU) @@ -402,6 +422,10 @@ func generatePublicApiUrl(m Mailgun, endpoint string) string { return fmt.Sprintf("%s/%s", m.APIBase(), endpoint) } +func generateSubaccountsApiUrl(m Mailgun) string { + return fmt.Sprintf("%s/%s/%s", m.APIBase(), accountsEndpoint, subaccountsEndpoint) +} + // generateParameterizedUrl works as generateApiUrl, but supports query parameters. func generateParameterizedUrl(m Mailgun, endpoint string, payload payload) (string, error) { paramBuffer, err := payload.getPayloadBuffer() diff --git a/messages_test.go b/messages_test.go index 09989e5b..cd9fed34 100644 --- a/messages_test.go +++ b/messages_test.go @@ -605,6 +605,45 @@ func TestAddOverrideHeader(t *testing.T) { ensure.StringContains(t, mg.GetCurlOutput(), "Host:") } +func TestOnBehalfOfSubaccount(t *testing.T) { + const ( + exampleDomain = "testDomain" + exampleAPIKey = "testAPIKey" + toUser = "test@test.com" + exampleMessage = "Queue. Thank you" + exampleID = "<20111114174239.25659.5817@samples.mailgun.org>" + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ensure.DeepEqual(t, req.Method, http.MethodPost) + ensure.DeepEqual(t, req.URL.Path, fmt.Sprintf("/v3/%s/messages", exampleDomain)) + ensure.DeepEqual(t, req.Header.Get("CustomHeader"), "custom-value") + ensure.DeepEqual(t, req.Host, "example.com") + ensure.DeepEqual(t, req.Header.Get(mailgun.OnBehalfOfHeader), "mailgun.subaccount") + + rsp := fmt.Sprintf(`{"message":"%s", "id":"%s"}`, exampleMessage, exampleID) + fmt.Fprint(w, rsp) + })) + defer srv.Close() + + mg := mailgun.NewMailgun(exampleDomain, exampleAPIKey) + mg.SetAPIBase(srv.URL + "/v3") + mg.AddOverrideHeader("Host", "example.com") + mg.AddOverrideHeader("CustomHeader", "custom-value") + mg.SetOnBehalfOfSubaccount("mailgun.subaccount") + ctx := context.Background() + + m := mg.NewMessage(fromUser, exampleSubject, exampleText, toUser) + m.SetRequireTLS(true) + m.SetSkipVerification(true) + + msg, id, err := mg.Send(ctx, m) + ensure.Nil(t, err) + ensure.DeepEqual(t, msg, exampleMessage) + ensure.DeepEqual(t, id, exampleID) + + ensure.StringContains(t, mg.GetCurlOutput(), "Host:") +} + func TestCaptureCurlOutput(t *testing.T) { const ( exampleDomain = "testDomain" diff --git a/mock.go b/mock.go index 86e368e4..23f08962 100644 --- a/mock.go +++ b/mock.go @@ -27,6 +27,7 @@ type MockServer interface { Events() []Event Webhooks() WebHooksListResponse Templates() []Template + SubaccountList() []Subaccount } // A mailgun api mock suitable for testing @@ -47,6 +48,7 @@ type mockServer struct { credentials []Credential stats []Stats tags []Tag + subaccountList []Subaccount webhooks WebHooksListResponse mutex sync.Mutex } @@ -105,6 +107,12 @@ func (ms *mockServer) Unsubscribes() []Unsubscribe { return ms.unsubscribes } +func (ms *mockServer) SubaccountList() []Subaccount { + defer ms.mutex.Unlock() + ms.mutex.Lock() + return ms.subaccountList +} + // Create a new instance of the mailgun API mock server func NewMockServer() MockServer { ms := mockServer{} @@ -129,6 +137,7 @@ func NewMockServer() MockServer { ms.addCredentialsRoutes(r) ms.addStatsRoutes(r) ms.addTagsRoutes(r) + ms.addSubaccountRoutes(r) }) ms.addValidationRoutes(r) diff --git a/mock_subaccounts.go b/mock_subaccounts.go new file mode 100644 index 00000000..c6141b28 --- /dev/null +++ b/mock_subaccounts.go @@ -0,0 +1,127 @@ +package mailgun + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (ms *mockServer) addSubaccountRoutes(r chi.Router) { + ms.subaccountList = append(ms.subaccountList, Subaccount{ + Id: "enabled.subaccount", + Name: "mailgun.test", + Status: "enabled", + }, Subaccount{ + Id: "disabled.subaccount", + Name: "mailgun.test", + Status: "disabled", + }) + + r.Get("/accounts/subaccounts", ms.listSubaccounts) + r.Post("/accounts/subaccounts", ms.createSubaccount) + + r.Get("/accounts/subaccounts/{subaccountID}", ms.getSubaccount) + r.Post("/accounts/subaccounts/{subaccountID}/enable", ms.enableSubaccount) + r.Post("/accounts/subaccounts/{subaccountID}/disable", ms.disableSubaccount) +} + +func (ms *mockServer) listSubaccounts(w http.ResponseWriter, r *http.Request) { + defer ms.mutex.Unlock() + ms.mutex.Lock() + + var list subaccountsListResponse + for _, subaccount := range ms.subaccountList { + list.Items = append(list.Items, subaccount) + } + + skip := stringToInt(r.FormValue("skip")) + limit := stringToInt(r.FormValue("limit")) + if limit == 0 { + limit = 100 + } + + if skip > len(list.Items) { + skip = len(list.Items) + } + + end := limit + skip + if end > len(list.Items) { + end = len(list.Items) + } + + // If we are at the end of the list + if skip == end { + toJSON(w, subaccountsListResponse{ + Total: len(list.Items), + Items: []Subaccount{}, + }) + return + } + + toJSON(w, subaccountsListResponse{ + Total: len(list.Items), + Items: list.Items[skip:end], + }) +} + +func (ms *mockServer) getSubaccount(w http.ResponseWriter, r *http.Request) { + defer ms.mutex.Unlock() + ms.mutex.Lock() + + for _, s := range ms.subaccountList { + if s.Id == chi.URLParam(r, "subaccountID") { + toJSON(w, SubaccountResponse{Item: s}) + return + } + } + w.WriteHeader(http.StatusNotFound) + toJSON(w, okResp{Message: "Not Found"}) +} + +func (ms *mockServer) createSubaccount(w http.ResponseWriter, r *http.Request) { + defer ms.mutex.Unlock() + ms.mutex.Lock() + + ms.subaccountList = append(ms.subaccountList, Subaccount{ + Id: "test", + Name: r.FormValue("name"), + Status: "active", + }) + toJSON(w, okResp{Message: "Subaccount has been created"}) +} + +func (ms *mockServer) enableSubaccount(w http.ResponseWriter, r *http.Request) { + defer ms.mutex.Unlock() + ms.mutex.Lock() + + for _, subaccount := range ms.subaccountList { + if subaccount.Id == chi.URLParam(r, "subaccountID") && subaccount.Status == "disabled" { + subaccount.Status = "enabled" + toJSON(w, SubaccountResponse{Item: subaccount}) + return + } + if subaccount.Id == chi.URLParam(r, "subaccountID") && subaccount.Status == "enabled" { + toJSON(w, okResp{Message: "subaccount is already enabled"}) + return + } + } + toJSON(w, okResp{Message: "Not Found"}) +} + +func (ms *mockServer) disableSubaccount(w http.ResponseWriter, r *http.Request) { + defer ms.mutex.Unlock() + ms.mutex.Lock() + + for _, subaccount := range ms.subaccountList { + if subaccount.Id == chi.URLParam(r, "subaccountID") && subaccount.Status == "enabled" { + subaccount.Status = "disabled" + toJSON(w, SubaccountResponse{Item: subaccount}) + return + } + if subaccount.Id == chi.URLParam(r, "subaccountID") && subaccount.Status == "disabled" { + toJSON(w, okResp{Message: "subaccount is already disabled"}) + return + } + } + toJSON(w, okResp{Message: "Not Found"}) +} diff --git a/subaccounts.go b/subaccounts.go new file mode 100644 index 00000000..b5aac2ca --- /dev/null +++ b/subaccounts.go @@ -0,0 +1,246 @@ +package mailgun + +import ( + "context" + "strconv" +) + +type ListSubaccountsOptions struct { + Limit int + Skip int + SortArray string + Enabled bool +} + +type SubaccountsIterator struct { + subaccountsListResponse + + mg Mailgun + limit int + offset int + skip int + sortArray string + enabled bool + url string + err error +} + +// A Subaccount structure holds information about a subaccount. +type Subaccount struct { + Id string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} + +type SubaccountResponse struct { + Item Subaccount `json:"subaccount"` +} + +type subaccountsListResponse struct { + Items []Subaccount `json:"subaccounts"` + Total int `json:"total"` +} + +// ListSubaccounts retrieves a set of subaccount linked to the primary Mailgun account. +func (mg *MailgunImpl) ListSubaccounts(opts *ListSubaccountsOptions) *SubaccountsIterator { + r := newHTTPRequest(generateSubaccountsApiUrl(mg)) + r.setClient(mg.client) + r.setBasicAuth(basicAuthUser, mg.APIKey()) + + var limit, skip int + var sortArray string + var enabled bool + if opts != nil { + limit = opts.Limit + skip = opts.Skip + sortArray = opts.SortArray + enabled = opts.Enabled + } + if limit == 0 { + limit = 10 + } + + return &SubaccountsIterator{ + mg: mg, + url: generateSubaccountsApiUrl(mg), + subaccountsListResponse: subaccountsListResponse{Total: -1}, + limit: limit, + skip: skip, + sortArray: sortArray, + enabled: enabled, + } +} + +// If an error occurred during iteration `Err()` will return non nil +func (ri *SubaccountsIterator) Err() error { + return ri.err +} + +// Offset returns the current offset of the iterator +func (ri *SubaccountsIterator) Offset() int { + return ri.offset +} + +// Next retrieves the next page of items from the api. Returns false when there +// no more pages to retrieve or if there was an error. Use `.Err()` to retrieve +// the error +func (ri *SubaccountsIterator) Next(ctx context.Context, items *[]Subaccount) bool { + if ri.err != nil { + return false + } + + ri.err = ri.fetch(ctx, ri.offset, ri.limit) + if ri.err != nil { + return false + } + + cpy := make([]Subaccount, len(ri.Items)) + copy(cpy, ri.Items) + *items = cpy + if len(ri.Items) == 0 { + return false + } + ri.offset = ri.offset + len(ri.Items) + return true +} + +// First retrieves the first page of items from the api. Returns false if there +// was an error. It also sets the iterator object to the first page. +// Use `.Err()` to retrieve the error. +func (ri *SubaccountsIterator) First(ctx context.Context, items *[]Subaccount) bool { + if ri.err != nil { + return false + } + ri.err = ri.fetch(ctx, 0, ri.limit) + if ri.err != nil { + return false + } + cpy := make([]Subaccount, len(ri.Items)) + copy(cpy, ri.Items) + *items = cpy + ri.offset = len(ri.Items) + return true +} + +// Last retrieves the last page of items from the api. +// Calling Last() is invalid unless you first call First() or Next() +// Returns false if there was an error. It also sets the iterator object +// to the last page. Use `.Err()` to retrieve the error. +func (ri *SubaccountsIterator) Last(ctx context.Context, items *[]Subaccount) bool { + if ri.err != nil { + return false + } + + if ri.Total == -1 { + return false + } + + ri.offset = ri.Total - ri.limit + if ri.offset < 0 { + ri.offset = 0 + } + + ri.err = ri.fetch(ctx, ri.offset, ri.limit) + if ri.err != nil { + return false + } + cpy := make([]Subaccount, len(ri.Items)) + copy(cpy, ri.Items) + *items = cpy + return true +} + +// Previous retrieves the previous page of items from the api. Returns false when there +// no more pages to retrieve or if there was an error. Use `.Err()` to retrieve +// the error if any +func (ri *SubaccountsIterator) Previous(ctx context.Context, items *[]Subaccount) bool { + if ri.err != nil { + return false + } + + if ri.Total == -1 { + return false + } + + ri.offset = ri.offset - (ri.limit * 2) + if ri.offset < 0 { + ri.offset = 0 + } + + ri.err = ri.fetch(ctx, ri.offset, ri.limit) + if ri.err != nil { + return false + } + cpy := make([]Subaccount, len(ri.Items)) + copy(cpy, ri.Items) + *items = cpy + if len(ri.Items) == 0 { + return false + } + return true +} + +func (ri *SubaccountsIterator) fetch(ctx context.Context, skip, limit int) error { + ri.Items = nil + r := newHTTPRequest(ri.url) + r.setBasicAuth(basicAuthUser, ri.mg.APIKey()) + r.setClient(ri.mg.Client()) + + if skip != 0 { + r.addParameter("skip", strconv.Itoa(skip)) + } + if limit != 0 { + r.addParameter("limit", strconv.Itoa(limit)) + } + + return getResponseFromJSON(ctx, r, &ri.subaccountsListResponse) +} + +// CreateSubaccount instructs Mailgun to create a new account (Subaccount) that is linked to the primary account. +// Subaccounts are child accounts that share the same plan and usage allocations as the primary, but have their own +// assets (sending domains, unique users, API key, SMTP credentials, settings, statistics and site login). +// All you need is the name of the subaccount. +func (mg *MailgunImpl) CreateSubaccount(ctx context.Context, subaccountName string) (SubaccountResponse, error) { + r := newHTTPRequest(generateSubaccountsApiUrl(mg)) + r.setClient(mg.client) + r.setBasicAuth(basicAuthUser, mg.APIKey()) + + payload := newUrlEncodedPayload() + payload.addValue("name", subaccountName) + resp := SubaccountResponse{} + err := postResponseFromJSON(ctx, r, payload, &resp) + return resp, err +} + +// SubaccountDetails retrieves detailed information about subaccount using subaccountId. +func (mg *MailgunImpl) SubaccountDetails(ctx context.Context, subaccountId string) (SubaccountResponse, error) { + r := newHTTPRequest(generateSubaccountsApiUrl(mg) + "/" + subaccountId) + r.setClient(mg.client) + r.setBasicAuth(basicAuthUser, mg.APIKey()) + + var resp SubaccountResponse + err := getResponseFromJSON(ctx, r, &resp) + return resp, err +} + +// EnableSubaccount instructs Mailgun to enable subaccount. +func (mg *MailgunImpl) EnableSubaccount(ctx context.Context, subaccountId string) (SubaccountResponse, error) { + r := newHTTPRequest(generateSubaccountsApiUrl(mg) + "/" + subaccountId + "/" + "enable") + r.setClient(mg.client) + r.setBasicAuth(basicAuthUser, mg.APIKey()) + + resp := SubaccountResponse{} + err := postResponseFromJSON(ctx, r, nil, &resp) + return resp, err +} + +// DisableSubaccount instructs Mailgun to disable subaccount. +func (mg *MailgunImpl) DisableSubaccount(ctx context.Context, subaccountId string) (SubaccountResponse, error) { + r := newHTTPRequest(generateSubaccountsApiUrl(mg) + "/" + subaccountId + "/" + "disable") + r.setClient(mg.client) + r.setBasicAuth(basicAuthUser, mg.APIKey()) + + resp := SubaccountResponse{} + err := postResponseFromJSON(ctx, r, nil, &resp) + return resp, err +} diff --git a/subaccounts_test.go b/subaccounts_test.go new file mode 100644 index 00000000..864b9fda --- /dev/null +++ b/subaccounts_test.go @@ -0,0 +1,122 @@ +package mailgun_test + +import ( + "context" + "net/http" + "testing" + + "github.com/facebookgo/ensure" + "github.com/mailgun/mailgun-go/v4" +) + +const ( + testSubaccountName = "mailgun.test" + testEnabledSubaccountId = "enabled.subaccount" + testDisabledSubaccountId = "disabled.subaccount" +) + +func TestListSubaccounts(t *testing.T) { + mg := mailgun.NewMailgun(testDomain, testKey) + mg.SetAPIBase(server.URL()) + + iterator := mg.ListSubaccounts(nil) + ensure.NotNil(t, iterator) + + ctx := context.Background() + + var page []mailgun.Subaccount + for iterator.Next(ctx, &page) { + for _, d := range page { + t.Logf("TestListSubaccounts: %#v\n", d) + } + } + t.Logf("TestListSubaccounts: %d subaccounts retrieved\n", iterator.Total) + ensure.Nil(t, iterator.Err()) + ensure.True(t, iterator.Total != 0) +} + +func TestSubaccountDetails(t *testing.T) { + mg := mailgun.NewMailgun(testDomain, testKey) + mg.SetAPIBase(server.URL()) + + ctx := context.Background() + + iterator := mg.ListSubaccounts(nil) + ensure.NotNil(t, iterator) + + page := []mailgun.Subaccount{} + ensure.True(t, iterator.Next(context.Background(), &page)) + ensure.Nil(t, iterator.Err()) + + resp, err := mg.SubaccountDetails(ctx, page[0].Id) + ensure.Nil(t, err) + ensure.NotNil(t, resp) +} + +func TestSubaccountDetailsStatusNotFound(t *testing.T) { + mg := mailgun.NewMailgun(testDomain, testKey) + mg.SetAPIBase(server.URL()) + + ctx := context.Background() + + _, err := mg.SubaccountDetails(ctx, "unexisting.id") + if err == nil { + t.Fatal("Did not expect a subaccount to exist") + } + ure, ok := err.(*mailgun.UnexpectedResponseError) + ensure.True(t, ok) + ensure.DeepEqual(t, ure.Actual, http.StatusNotFound) +} + +func TestCreateSubaccount(t *testing.T) { + mg := mailgun.NewMailgun(testDomain, testKey) + mg.SetAPIBase(server.URL()) + + ctx := context.Background() + + resp, err := mg.CreateSubaccount(ctx, testSubaccountName) + ensure.Nil(t, err) + ensure.NotNil(t, resp) +} + +func TestEnableSubaccountAlreadyEnabled(t *testing.T) { + mg := mailgun.NewMailgun(testDomain, testKey) + mg.SetAPIBase(server.URL()) + + ctx := context.Background() + + _, err := mg.EnableSubaccount(ctx, testEnabledSubaccountId) + ensure.Nil(t, err) +} + +func TestEnableSubaccount(t *testing.T) { + mg := mailgun.NewMailgun(testDomain, testKey) + mg.SetAPIBase(server.URL()) + + ctx := context.Background() + + resp, err := mg.EnableSubaccount(ctx, testDisabledSubaccountId) + ensure.Nil(t, err) + ensure.DeepEqual(t, resp.Item.Status, "enabled") +} + +func TestDisableSubaccount(t *testing.T) { + mg := mailgun.NewMailgun(testDomain, testKey) + mg.SetAPIBase(server.URL()) + + ctx := context.Background() + + resp, err := mg.DisableSubaccount(ctx, testEnabledSubaccountId) + ensure.Nil(t, err) + ensure.DeepEqual(t, resp.Item.Status, "disabled") +} + +func TestDisableSubaccountAlreadyDisabled(t *testing.T) { + mg := mailgun.NewMailgun(testDomain, testKey) + mg.SetAPIBase(server.URL()) + + ctx := context.Background() + + _, err := mg.DisableSubaccount(ctx, testDisabledSubaccountId) + ensure.Nil(t, err) +} From e758192714b6be8a0fb2009bcf1d42ab637e6168 Mon Sep 17 00:00:00 2001 From: sadbeam Date: Mon, 8 Jan 2024 13:26:15 +0200 Subject: [PATCH 2/2] DE-1201 added SetOnBehalfOfSubaccount and RemoveOnBehalfOfSubaccount to interface --- mailgun.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mailgun.go b/mailgun.go index 5f77d7ba..0324f8dd 100644 --- a/mailgun.go +++ b/mailgun.go @@ -255,6 +255,9 @@ type Mailgun interface { SubaccountDetails(ctx context.Context, subaccountId string) (SubaccountResponse, error) EnableSubaccount(ctx context.Context, subaccountId string) (SubaccountResponse, error) DisableSubaccount(ctx context.Context, subaccountId string) (SubaccountResponse, error) + + SetOnBehalfOfSubaccount(subaccountId string) + RemoveOnBehalfOfSubaccount() } // MailgunImpl bundles data needed by a large number of methods in order to interact with the Mailgun API.