From 1733cdbe9f74ba2f1a59716069f7ef03455357fa Mon Sep 17 00:00:00 2001 From: Vilen Topchii <32271530+vtopc@users.noreply.github.com> Date: Fri, 25 Oct 2024 20:49:24 +0300 Subject: [PATCH] DE-1315 Add support for metrics API (#328) --- .gitignore | 1 + Makefile | 5 +- analytics.go | 110 ++++++++++++++++++++++++++++++++++++++++++ analytics_request.go | 40 +++++++++++++++ analytics_response.go | 95 ++++++++++++++++++++++++++++++++++++ analytics_test.go | 65 +++++++++++++++++++++++++ bounces_test.go | 4 +- go.mod | 3 ++ go.sum | 5 +- httphelpers.go | 4 +- integration_test.go | 59 ++++++++++++++++++++++ mailgun.go | 34 +++---------- mailgun_test.go | 6 ++- mock.go | 6 +++ mock_analytics.go | 47 ++++++++++++++++++ reporting.go | 11 +++++ rest_shim.go | 23 +++++---- rfc2822.go | 5 +- stats.go | 17 ++----- subaccounts.go | 5 ++ 20 files changed, 486 insertions(+), 59 deletions(-) create mode 100644 analytics.go create mode 100644 analytics_request.go create mode 100644 analytics_response.go create mode 100644 analytics_test.go create mode 100644 integration_test.go create mode 100644 mock_analytics.go create mode 100644 reporting.go diff --git a/.gitignore b/.gitignore index c64a9442..4daa3427 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store .idea/ cmd/mailgun/mailgun +/.env diff --git a/Makefile b/Makefile index d4028ebb..1060781a 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,10 @@ $(NILAWAY): go install go.uber.org/nilaway/cmd/nilaway@latest .PHONY: all -all: +all: test + +.PHONY: test +test: export GO111MODULE=on; go test . -v .PHONY: godoc diff --git a/analytics.go b/analytics.go new file mode 100644 index 00000000..3b93f4a6 --- /dev/null +++ b/analytics.go @@ -0,0 +1,110 @@ +package mailgun + +import ( + "context" + "strings" + + "github.com/mailgun/errors" +) + +type MetricsPagination struct { + // Colon-separated value indicating column name and sort direction e.g. 'domain:asc'. + Sort string `json:"sort"` + // The number of items to skip over when satisfying the request. To get the first page of data set skip to zero. Then increment the skip by the limit for subsequent calls. + Skip int `json:"skip"` + // The maximum number of items returned in the response. + Limit int `json:"limit"` + // The total number of items in the query result set. + Total int `json:"total"` +} + +// ListMetrics returns domain/account metrics. +// +// NOTE: Only for v1 API. To use the /v1 version define MG_URL in the environment variable +// as `https://api.mailgun.net/v1` or set `mg.SetAPIBase("https://api.mailgun.net/v1")` +// +// https://documentation.mailgun.com/docs/mailgun/api-reference/openapi-final/tag/Metrics/ +func (mg *MailgunImpl) ListMetrics(opts MetricsOptions) (*MetricsIterator, error) { + if !strings.HasSuffix(mg.APIBase(), "/v1") { + return nil, errors.New("only v1 API is supported") + } + + domain := mg.Domain() + if domain != "" { + domainFilter := MetricsFilterPredicate{ + Attribute: "domain", + Comparator: "=", + LabeledValues: []MetricsLabeledValue{{Label: domain, Value: domain}}, + } + + opts.Filter.BoolGroupAnd = append(opts.Filter.BoolGroupAnd, domainFilter) + } + + if opts.Pagination.Limit == 0 { + opts.Pagination.Limit = 10 + } + + req := newHTTPRequest(generatePublicApiUrl(mg, metricsEndpoint)) + req.setClient(mg.Client()) + req.setBasicAuth(basicAuthUser, mg.APIKey()) + + return &MetricsIterator{ + opts: opts, + req: req, + }, nil +} + +type MetricsIterator struct { + opts MetricsOptions + req *httpRequest + err error +} + +func (iter *MetricsIterator) Err() error { + return iter.err +} + +// Next retrieves the next page of items from the api. Returns false when there are +// no more pages to retrieve or if there was an error. +// Use `.Err()` to retrieve the error +func (iter *MetricsIterator) Next(ctx context.Context, resp *MetricsResponse) (more bool) { + if iter.err != nil { + return false + } + + iter.err = iter.fetch(ctx, resp) + if iter.err != nil { + return false + } + + iter.opts.Pagination.Skip = iter.opts.Pagination.Skip + iter.opts.Pagination.Limit + + if len(resp.Items) < iter.opts.Pagination.Limit { + return false + } + + return true +} + +func (iter *MetricsIterator) fetch(ctx context.Context, resp *MetricsResponse) error { + if resp == nil { + return errors.New("resp cannot be nil") + } + + payload := newJSONEncodedPayload(iter.opts) + + httpResp, err := makePostRequest(ctx, iter.req, payload) + if err != nil { + return err + } + + // preallocate + resp.Items = make([]MetricsItem, 0, iter.opts.Pagination.Limit) + + err = httpResp.parseFromJSON(resp) + if err != nil { + return errors.Wrap(err, "decoding response") + } + + return nil +} diff --git a/analytics_request.go b/analytics_request.go new file mode 100644 index 00000000..470f30b8 --- /dev/null +++ b/analytics_request.go @@ -0,0 +1,40 @@ +package mailgun + +type MetricsOptions struct { + // A start date (default: 7 days before current time). + Start RFC2822Time `json:"start,omitempty"` + // An end date (default: current time). + End RFC2822Time `json:"end,omitempty"` + // A resolution in the format of 'day' 'hour' 'month'. Default is day. + Resolution Resolution `json:"resolution,omitempty"` + // A duration in the format of '1d' '2h' '2m'. + // If duration is provided then it is calculated from the end date and overwrites the start date. + Duration string `json:"duration,omitempty"` + // Attributes of the metric data such as 'time' 'domain' 'ip' 'ip_pool' 'recipient_domain' 'tag' 'country' 'subaccount'. + Dimensions []string `json:"dimensions,omitempty"` + // Name of the metrics to receive the stats for such as 'accepted_count' 'delivered_count' 'accepted_rate'. + Metrics []string `json:"metrics,omitempty"` + // Filters to apply to the query. + Filter MetricsFilterPredicateGroup `json:"filter,omitempty"` + // Include stats from all subaccounts. + IncludeSubaccounts bool `json:"include_subaccounts,omitempty"` + // Include top-level aggregate metrics. + IncludeAggregates bool `json:"include_aggregates,omitempty"` + // Attributes used for pagination and sorting. + Pagination MetricsPagination `json:"pagination,omitempty"` +} + +type MetricsLabeledValue struct { + Label string `json:"label"` + Value string `json:"value"` +} + +type MetricsFilterPredicate struct { + Attribute string `json:"attribute"` + Comparator string `json:"comparator"` + LabeledValues []MetricsLabeledValue `json:"values,omitempty"` +} + +type MetricsFilterPredicateGroup struct { + BoolGroupAnd []MetricsFilterPredicate `json:"AND,omitempty"` +} diff --git a/analytics_response.go b/analytics_response.go new file mode 100644 index 00000000..5696a658 --- /dev/null +++ b/analytics_response.go @@ -0,0 +1,95 @@ +package mailgun + +type MetricsResponse struct { + Start RFC2822Time `json:"start"` + End RFC2822Time `json:"end"` + Resolution Resolution `json:"resolution"` + Duration string `json:"duration"` + Dimensions []string `json:"dimensions"` + Aggregates MetricsAggregates `json:"aggregates"` + Items []MetricsItem `json:"items"` + Pagination MetricsPagination `json:"pagination"` +} + +type MetricsItem struct { + Dimensions []MetricsDimension `json:"dimensions"` + Metrics Metrics `json:"metrics"` +} + +type MetricsAggregates struct { + Metrics Metrics `json:"metrics"` +} + +type Metrics struct { + AcceptedIncomingCount *uint64 `json:"accepted_incoming_count,omitempty"` + AcceptedOutgoingCount *uint64 `json:"accepted_outgoing_count,omitempty"` + AcceptedCount *uint64 `json:"accepted_count,omitempty"` + DeliveredSMTPCount *uint64 `json:"delivered_smtp_count,omitempty"` + DeliveredHTTPCount *uint64 `json:"delivered_http_count,omitempty"` + DeliveredOptimizedCount *uint64 `json:"delivered_optimized_count,omitempty"` + DeliveredCount *uint64 `json:"delivered_count,omitempty"` + StoredCount *uint64 `json:"stored_count,omitempty"` + ProcessedCount *uint64 `json:"processed_count,omitempty"` + SentCount *uint64 `json:"sent_count,omitempty"` + OpenedCount *uint64 `json:"opened_count,omitempty"` + ClickedCount *uint64 `json:"clicked_count,omitempty"` + UniqueOpenedCount *uint64 `json:"unique_opened_count,omitempty"` + UniqueClickedCount *uint64 `json:"unique_clicked_count,omitempty"` + UnsubscribedCount *uint64 `json:"unsubscribed_count,omitempty"` + ComplainedCount *uint64 `json:"complained_count,omitempty"` + FailedCount *uint64 `json:"failed_count,omitempty"` + TemporaryFailedCount *uint64 `json:"temporary_failed_count,omitempty"` + PermanentFailedCount *uint64 `json:"permanent_failed_count,omitempty"` + ESPBlockCount *uint64 `json:"esp_block_count,omitempty"` + WebhookCount *uint64 `json:"webhook_count,omitempty"` + PermanentFailedOptimizedCount *uint64 `json:"permanent_failed_optimized_count,omitempty"` + PermanentFailedOldCount *uint64 `json:"permanent_failed_old_count,omitempty"` + BouncedCount *uint64 `json:"bounced_count,omitempty"` + HardBouncesCount *uint64 `json:"hard_bounces_count,omitempty"` + SoftBouncesCount *uint64 `json:"soft_bounces_count,omitempty"` + DelayedBounceCount *uint64 `json:"delayed_bounce_count,omitempty"` + SuppressedBouncesCount *uint64 `json:"suppressed_bounces_count,omitempty"` + SuppressedUnsubscribedCount *uint64 `json:"suppressed_unsubscribed_count,omitempty"` + SuppressedComplaintsCount *uint64 `json:"suppressed_complaints_count,omitempty"` + DeliveredFirstAttemptCount *uint64 `json:"delivered_first_attempt_count,omitempty"` + DelayedFirstAttemptCount *uint64 `json:"delayed_first_attempt_count,omitempty"` + DeliveredSubsequentCount *uint64 `json:"delivered_subsequent_count,omitempty"` + DeliveredTwoPlusAttemptsCount *uint64 `json:"delivered_two_plus_attempts_count,omitempty"` + + DeliveredRate string `json:"delivered_rate,omitempty"` + OpenedRate string `json:"opened_rate,omitempty"` + ClickedRate string `json:"clicked_rate,omitempty"` + UniqueOpenedRate string `json:"unique_opened_rate,omitempty"` + UniqueClickedRate string `json:"unique_clicked_rate,omitempty"` + UnsubscribedRate string `json:"unsubscribed_rate,omitempty"` + ComplainedRate string `json:"complained_rate,omitempty"` + BounceRate string `json:"bounce_rate,omitempty"` + FailRate string `json:"fail_rate,omitempty"` + PermanentFailRate string `json:"permanent_fail_rate,omitempty"` + TemporaryFailRate string `json:"temporary_fail_rate,omitempty"` + DelayedRate string `json:"delayed_rate,omitempty"` + + // usage metrics + EmailValidationCount *uint64 `json:"email_validation_count,omitempty"` + EmailValidationPublicCount *uint64 `json:"email_validation_public_count,omitempty"` + EmailValidationValidCount *uint64 `json:"email_validation_valid_count,omitempty"` + EmailValidationSingleCount *uint64 `json:"email_validation_single_count,omitempty"` + EmailValidationBulkCount *uint64 `json:"email_validation_bulk_count,omitempty"` + EmailValidationListCount *uint64 `json:"email_validation_list_count,omitempty"` + EmailValidationMailgunCount *uint64 `json:"email_validation_mailgun_count,omitempty"` + EmailValidationMailjetCount *uint64 `json:"email_validation_mailjet_count,omitempty"` + EmailPreviewCount *uint64 `json:"email_preview_count,omitempty"` + EmailPreviewFailedCount *uint64 `json:"email_preview_failed_count,omitempty"` + LinkValidationCount *uint64 `json:"link_validation_count,omitempty"` + LinkValidationFailedCount *uint64 `json:"link_validation_failed_count,omitempty"` + SeedTestCount *uint64 `json:"seed_test_count,omitempty"` +} + +type MetricsDimension struct { + // The dimension + Dimension string `json:"dimension"` + // The dimension value + Value string `json:"value"` + // The dimension value in displayable form + DisplayValue string `json:"display_value"` +} diff --git a/analytics_test.go b/analytics_test.go new file mode 100644 index 00000000..1de81e0c --- /dev/null +++ b/analytics_test.go @@ -0,0 +1,65 @@ +package mailgun_test + +import ( + "context" + "testing" + + "github.com/mailgun/mailgun-go/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestListMetrics(t *testing.T) { + mg := mailgun.NewMailgun(testDomain, testKey) + mg.SetAPIBase(server.URL1()) + + start, _ := mailgun.NewRFC2822Time("Tue, 24 Sep 2024 00:00:00 +0000") + end, _ := mailgun.NewRFC2822Time("Tue, 24 Oct 2024 00:00:00 +0000") + + opts := mailgun.MetricsOptions{ + Start: start, + End: end, + Pagination: mailgun.MetricsPagination{ + Limit: 10, + }, + } + + wantResp := mailgun.MetricsResponse{ + Start: start, + End: end, + Resolution: "day", + Duration: "30d", + Dimensions: []string{"time"}, + Items: []mailgun.MetricsItem{ + { + Dimensions: []mailgun.MetricsDimension{{ + Dimension: "time", + Value: "Tue, 24 Sep 2024 00:00:00 +0000", + DisplayValue: "Tue, 24 Sep 2024 00:00:00 +0000", + }}, + Metrics: mailgun.Metrics{ + SentCount: ptr(uint64(4)), + DeliveredCount: ptr(uint64(3)), + OpenedCount: ptr(uint64(2)), + FailedCount: ptr(uint64(1)), + }, + }, + }, + Pagination: mailgun.MetricsPagination{ + Sort: "", + Skip: 0, + Limit: 10, + Total: 1, + }, + } + + it, err := mg.ListMetrics(opts) + require.NoError(t, err) + + var page mailgun.MetricsResponse + ctx := context.Background() + more := it.Next(ctx, &page) + require.Nil(t, it.Err()) + assert.False(t, more) + assert.Equal(t, wantResp, page) +} diff --git a/bounces_test.go b/bounces_test.go index 0c43a8e7..8ee7ef69 100644 --- a/bounces_test.go +++ b/bounces_test.go @@ -127,7 +127,7 @@ func TestAddDelBounceList(t *testing.T) { return false } - createdAt, err := mailgun.NewRFC2822Time("Thu, 13 Oct 2011 18:02:00 UTC") + createdAt, err := mailgun.NewRFC2822Time("Thu, 13 Oct 2011 18:02:00 +0000") if err != nil { t.Fatalf("invalid time") } @@ -162,7 +162,7 @@ func TestAddDelBounceList(t *testing.T) { t.Fatalf("Expected at least one bounce for %s", expect.Address) } t.Logf("Bounce Created At: %s", bounce.CreatedAt) - if !expect.CreatedAt.IsZero() && bounce.CreatedAt != expect.CreatedAt { + if !expect.CreatedAt.IsZero() && !time.Time(bounce.CreatedAt).Equal(time.Time(expect.CreatedAt)) { t.Fatalf("Expected bounce createdAt to be %s, got %s", expect.CreatedAt, bounce.CreatedAt) } } diff --git a/go.mod b/go.mod index b1ac5f5d..647faad4 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-chi/chi/v5 v5.0.8 github.com/json-iterator/go v1.1.10 github.com/mailgun/errors v0.3.0 + github.com/stretchr/testify v1.9.0 ) require ( @@ -15,6 +16,8 @@ require ( github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sirupsen/logrus v1.9.0 // indirect golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f69e9b07..578eb4d1 100644 --- a/go.sum +++ b/go.sum @@ -27,11 +27,12 @@ github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 h1:h+EGohizhe9XlX18rfpa8k8RAc5XyaeamM+0VHRd4lc= golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/httphelpers.go b/httphelpers.go index 46f28b29..b48865da 100644 --- a/httphelpers.go +++ b/httphelpers.go @@ -18,7 +18,7 @@ import ( "github.com/mailgun/errors" ) -var validURL = regexp.MustCompile(`/v[2-5].*`) +var validURL = regexp.MustCompile(`/v[1-5].*`) type httpRequest struct { URL string @@ -332,7 +332,7 @@ func (r *httpRequest) generateUrlWithParameters() (string, error) { } if !validURL.MatchString(url.Path) { - return "", errors.New(`BaseAPI must end with a /v2, /v3 or /v4; setBaseAPI("https://host/v3")`) + return "", errors.New(`BaseAPI must end with a /v1, /v2, /v3 or /v4; setBaseAPI("https://host/v3")`) } q := url.Query() diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 00000000..e58be40c --- /dev/null +++ b/integration_test.go @@ -0,0 +1,59 @@ +//go:build integration + +package mailgun_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/mailgun/mailgun-go/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIntegrationMailgunImpl_ListMetrics(t *testing.T) { + mg, err := mailgun.NewMailgunFromEnv() + if err != nil { + require.NoError(t, err) + } + + opts := mailgun.MetricsOptions{ + End: mailgun.RFC2822Time(time.Now().UTC()), + Duration: "30d", + Pagination: mailgun.MetricsPagination{ + Limit: 10, + }, + } + + iter, err := mg.ListMetrics(opts) + require.NoError(t, err) + + // create context to list all pages + ctx, cancel := context.WithTimeout(context.Background(), time.Second*60) + defer cancel() + + for i := 0; i < 2; i++ { + var resp mailgun.MetricsResponse + more := iter.Next(ctx, &resp) + if iter.Err() != nil { + require.NoError(t, err) + } + + t.Logf("Page %d: Start: %s; End: %s; Pagination: %+v\n", + i+1, resp.Start, resp.End, resp.Pagination) + + assert.GreaterOrEqual(t, len(resp.Items), 1) + + for _, item := range resp.Items { + b, _ := json.Marshal(item) + t.Logf("%s\n", b) + } + + if !more { + t.Log("no more pages") + break + } + } +} diff --git a/mailgun.go b/mailgun.go index 0324f8dd..876ea4b4 100644 --- a/mailgun.go +++ b/mailgun.go @@ -105,6 +105,7 @@ const ( mimeMessagesEndpoint = "messages.mime" bouncesEndpoint = "bounces" statsTotalEndpoint = "stats/total" + metricsEndpoint = "analytics/metrics" domainsEndpoint = "domains" tagsEndpoint = "tags" eventsEndpoint = "events" @@ -150,6 +151,9 @@ type Mailgun interface { DeleteBounce(ctx context.Context, address string) error DeleteBounceList(ctx context.Context) error + ListMetrics(opts MetricsOptions) (*MetricsIterator, error) + + // Deprecated: Use ListMetrics instead. GetStats(ctx context.Context, events []string, opts *GetStatOptions) ([]Stats, error) GetTag(ctx context.Context, tag string) (Tag, error) DeleteTag(ctx context.Context, tag string) error @@ -414,38 +418,16 @@ func generateCredentialsUrl(m Mailgun, login string) string { // return fmt.Sprintf("%s/domains/%s/credentials%s", apiBase, m.Domain(), tail) } -// generateStoredMessageUrl generates the URL needed to acquire a copy of a stored message. -func generateStoredMessageUrl(m Mailgun, endpoint, id string) string { - return generateDomainApiUrl(m, fmt.Sprintf("%s/%s", endpoint, id)) - // return fmt.Sprintf("%s/domains/%s/%s/%s", apiBase, m.Domain(), endpoint, id) -} - // generatePublicApiUrl works as generateApiUrl, except that generatePublicApiUrl has no need for the domain. 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() - if err != nil { - return "", err - } - params := string(paramBuffer.Bytes()) - return fmt.Sprintf("%s?%s", generateApiUrl(m, eventsEndpoint), params), nil -} - -// parseMailgunTime translates a timestamp as returned by Mailgun into a Go standard timestamp. -func parseMailgunTime(ts string) (t time.Time, err error) { - t, err = time.Parse("Mon, 2 Jan 2006 15:04:05 MST", ts) - return -} - // formatMailgunTime translates a timestamp into a human-readable form. func formatMailgunTime(t time.Time) string { return t.Format("Mon, 2 Jan 2006 15:04:05 -0700") } + +func ptr[T any](v T) *T { + return &v +} diff --git a/mailgun_test.go b/mailgun_test.go index 8ac3f705..9c343eca 100644 --- a/mailgun_test.go +++ b/mailgun_test.go @@ -34,7 +34,7 @@ func TestInvalidBaseAPI(t *testing.T) { ctx := context.Background() _, err := mg.GetDomain(ctx, "unknown.domain") ensure.NotNil(t, err) - ensure.DeepEqual(t, err.Error(), `BaseAPI must end with a /v2, /v3 or /v4; setBaseAPI("https://host/v3")`) + ensure.DeepEqual(t, err.Error(), `BaseAPI must end with a /v1, /v2, /v3 or /v4; setBaseAPI("https://host/v3")`) } func TestValidBaseAPI(t *testing.T) { @@ -60,3 +60,7 @@ func TestValidBaseAPI(t *testing.T) { ensure.Nil(t, err) } } + +func ptr[T any](v T) *T { + return &v +} diff --git a/mock.go b/mock.go index 23f08962..11203180 100644 --- a/mock.go +++ b/mock.go @@ -17,6 +17,7 @@ import ( type MockServer interface { Stop() + URL1() string URL4() string URL() string DomainIPS() []string @@ -140,6 +141,7 @@ func NewMockServer() MockServer { ms.addSubaccountRoutes(r) }) ms.addValidationRoutes(r) + ms.addAnalyticsRoutes(r) // Start the server ms.srv = httptest.NewServer(r) @@ -151,6 +153,10 @@ func (ms *mockServer) Stop() { ms.srv.Close() } +func (ms *mockServer) URL1() string { + return ms.srv.URL + "/v1" +} + func (ms *mockServer) URL4() string { return ms.srv.URL + "/v4" } diff --git a/mock_analytics.go b/mock_analytics.go new file mode 100644 index 00000000..868bc5b6 --- /dev/null +++ b/mock_analytics.go @@ -0,0 +1,47 @@ +package mailgun + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (ms *mockServer) addAnalyticsRoutes(r chi.Router) { + r.Post("/v1/"+metricsEndpoint, ms.listMetrics) +} + +func (ms *mockServer) listMetrics(w http.ResponseWriter, _ *http.Request) { + start, _ := NewRFC2822Time("Tue, 24 Sep 2024 00:00:00 +0000") + end, _ := NewRFC2822Time("Tue, 24 Oct 2024 00:00:00 +0000") + + resp := MetricsResponse{ + Start: start, + End: end, + Resolution: "day", + Duration: "30d", + Dimensions: []string{"time"}, + Items: []MetricsItem{ + { + Dimensions: []MetricsDimension{{ + Dimension: "time", + Value: "Tue, 24 Sep 2024 00:00:00 +0000", + DisplayValue: "Tue, 24 Sep 2024 00:00:00 +0000", + }}, + Metrics: Metrics{ + SentCount: ptr(uint64(4)), + DeliveredCount: ptr(uint64(3)), + OpenedCount: ptr(uint64(2)), + FailedCount: ptr(uint64(1)), + }, + }, + }, + Pagination: MetricsPagination{ + Sort: "", + Skip: 0, + Limit: 10, + Total: 1, + }, + } + + toJSON(w, resp) +} diff --git a/reporting.go b/reporting.go new file mode 100644 index 00000000..f9813839 --- /dev/null +++ b/reporting.go @@ -0,0 +1,11 @@ +package mailgun + +// common things for stats and metrics + +type Resolution string + +const ( + ResolutionHour = Resolution("hour") + ResolutionDay = Resolution("day") + ResolutionMonth = Resolution("month") +) diff --git a/rest_shim.go b/rest_shim.go index 2b6def48..8837257d 100644 --- a/rest_shim.go +++ b/rest_shim.go @@ -3,6 +3,7 @@ package mailgun import ( "context" "fmt" + "net/http" ) // The MailgunGoUserAgent identifies the client to the server, for logging purposes. @@ -16,6 +17,7 @@ const MailgunGoUserAgent = "mailgun-go/" + Version type UnexpectedResponseError struct { Expected []int Actual int + Method string URL string Data []byte } @@ -23,7 +25,8 @@ type UnexpectedResponseError struct { // String() converts the error into a human-readable, logfmt-compliant string. // See http://godoc.org/github.com/kr/logfmt for details on logfmt formatting. func (e *UnexpectedResponseError) String() string { - return fmt.Sprintf("UnexpectedResponseError URL=%s ExpectedOneOf=%#v Got=%d Error: %s", e.URL, e.Expected, e.Actual, string(e.Data)) + return fmt.Sprintf("UnexpectedResponseError Method=%s URL=%s ExpectedOneOf=%#v Got=%d Error: %s", + e.Method, e.URL, e.Expected, e.Actual, string(e.Data)) } // Error() performs as String(). @@ -32,7 +35,7 @@ func (e *UnexpectedResponseError) Error() string { } // newError creates a new error condition to be returned. -func newError(url string, expected []int, got *httpResponse) error { +func newError(method, url string, expected []int, got *httpResponse) error { return &UnexpectedResponseError{ URL: url, Expected: expected, @@ -62,7 +65,7 @@ func makeRequest(ctx context.Context, r *httpRequest, method string, p payload) r.addHeader("User-Agent", MailgunGoUserAgent) rsp, err := r.makeRequest(ctx, method, p) if (err == nil) && notGood(rsp.Code, expected) { - return rsp, newError(r.URL, expected, rsp) + return rsp, newError(method, r.URL, expected, rsp) } return rsp, err } @@ -76,7 +79,7 @@ func getResponseFromJSON(ctx context.Context, r *httpRequest, v interface{}) err return err } if notGood(response.Code, expected) { - return newError(r.URL, expected, response) + return newError(http.MethodGet, r.URL, expected, response) } return response.parseFromJSON(v) } @@ -90,7 +93,7 @@ func postResponseFromJSON(ctx context.Context, r *httpRequest, p payload, v inte return err } if notGood(response.Code, expected) { - return newError(r.URL, expected, response) + return newError(http.MethodPost, r.URL, expected, response) } return response.parseFromJSON(v) } @@ -104,7 +107,7 @@ func putResponseFromJSON(ctx context.Context, r *httpRequest, p payload, v inter return err } if notGood(response.Code, expected) { - return newError(r.URL, expected, response) + return newError(http.MethodPut, r.URL, expected, response) } return response.parseFromJSON(v) } @@ -115,7 +118,7 @@ func makeGetRequest(ctx context.Context, r *httpRequest) (*httpResponse, error) r.addHeader("User-Agent", MailgunGoUserAgent) rsp, err := r.makeGetRequest(ctx) if (err == nil) && notGood(rsp.Code, expected) { - return rsp, newError(r.URL, expected, rsp) + return rsp, newError(http.MethodGet, r.URL, expected, rsp) } return rsp, err } @@ -126,7 +129,7 @@ func makePostRequest(ctx context.Context, r *httpRequest, p payload) (*httpRespo r.addHeader("User-Agent", MailgunGoUserAgent) rsp, err := r.makePostRequest(ctx, p) if (err == nil) && notGood(rsp.Code, expected) { - return rsp, newError(r.URL, expected, rsp) + return rsp, newError(http.MethodPost, r.URL, expected, rsp) } return rsp, err } @@ -137,7 +140,7 @@ func makePutRequest(ctx context.Context, r *httpRequest, p payload) (*httpRespon r.addHeader("User-Agent", MailgunGoUserAgent) rsp, err := r.makePutRequest(ctx, p) if (err == nil) && notGood(rsp.Code, expected) { - return rsp, newError(r.URL, expected, rsp) + return rsp, newError(http.MethodPut, r.URL, expected, rsp) } return rsp, err } @@ -148,7 +151,7 @@ func makeDeleteRequest(ctx context.Context, r *httpRequest) (*httpResponse, erro r.addHeader("User-Agent", MailgunGoUserAgent) rsp, err := r.makeDeleteRequest(ctx) if (err == nil) && notGood(rsp.Code, expected) { - return rsp, newError(r.URL, expected, rsp) + return rsp, newError(http.MethodDelete, r.URL, expected, rsp) } return rsp, err } diff --git a/rfc2822.go b/rfc2822.go index 26463b2f..6ff26fdd 100644 --- a/rfc2822.go +++ b/rfc2822.go @@ -7,8 +7,9 @@ import ( "github.com/mailgun/errors" ) -// RFC2822Time Mailgun uses RFC2822 format for timestamps everywhere ('Thu, 13 Oct 2011 18:02:00 GMT'), but +// RFC2822Time Mailgun uses RFC2822 format for timestamps everywhere ('Thu, 13 Oct 2011 18:02:00 +0000'), but // by default Go's JSON package uses another format when decoding/encoding timestamps. +// https://documentation.mailgun.com/docs/mailgun/user-manual/get-started/#date-format type RFC2822Time time.Time func NewRFC2822Time(str string) (RFC2822Time, error) { @@ -28,7 +29,7 @@ func (t RFC2822Time) IsZero() bool { } func (t RFC2822Time) MarshalJSON() ([]byte, error) { - return []byte(strconv.Quote(time.Time(t).Format(time.RFC1123))), nil + return []byte(strconv.Quote(time.Time(t).Format(time.RFC1123Z))), nil } func (t *RFC2822Time) UnmarshalJSON(s []byte) error { diff --git a/stats.go b/stats.go index 04fe7605..533c13d9 100644 --- a/stats.go +++ b/stats.go @@ -67,16 +67,6 @@ type statsTotalResponse struct { Stats []Stats `json:"stats"` } -// Used by GetStats() to specify the resolution stats are for -type Resolution string - -// Indicate which resolution a stat response for request is for -const ( - ResolutionHour = Resolution("hour") - ResolutionDay = Resolution("day") - ResolutionMonth = Resolution("month") -) - // Options for GetStats() type GetStatOptions struct { Resolution Resolution @@ -85,7 +75,8 @@ type GetStatOptions struct { End time.Time } -// GetStats returns total stats for a given domain for the specified time period +// GetStats returns total stats for a given domain for the specified time period. +// Deprecated: Use ListMetrics instead. func (mg *MailgunImpl) GetStats(ctx context.Context, events []string, opts *GetStatOptions) ([]Stats, error) { r := newHTTPRequest(generateApiUrl(mg, statsTotalEndpoint)) @@ -115,7 +106,7 @@ func (mg *MailgunImpl) GetStats(ctx context.Context, events []string, opts *GetS err := getResponseFromJSON(ctx, r, &res) if err != nil { return nil, err - } else { - return res.Stats, nil } + + return res.Stats, nil } diff --git a/subaccounts.go b/subaccounts.go index b5aac2ca..75d9bb0e 100644 --- a/subaccounts.go +++ b/subaccounts.go @@ -2,6 +2,7 @@ package mailgun import ( "context" + "fmt" "strconv" ) @@ -244,3 +245,7 @@ func (mg *MailgunImpl) DisableSubaccount(ctx context.Context, subaccountId strin err := postResponseFromJSON(ctx, r, nil, &resp) return resp, err } + +func generateSubaccountsApiUrl(m Mailgun) string { + return fmt.Sprintf("%s/%s/%s", m.APIBase(), accountsEndpoint, subaccountsEndpoint) +}