Repository: mailgun/mailgun-go
Pull request #328 (2024-09-17 17:15:17)

DE-1315 Add support for metrics API


Patch:
diff --git a/.gitignore b/.gitignore
index c64a944..4daa342 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 .DS_Store
 .idea/
 cmd/mailgun/mailgun
+/.env
diff --git a/Makefile b/Makefile
index d4028eb..1060781 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 0000000..3b93f4a
--- /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 0000000..470f30b
--- /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 0000000..5696a65
--- /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/go.mod b/go.mod
index b1ac5f5..647faad 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 f69e9b0..578eb4d 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 46f28b2..b48865d 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/mailgun.go b/mailgun.go
index 0324f8d..876ea4b 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/mock.go b/mock.go
index 23f0896..1120318 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 0000000..868bc5b
--- /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 0000000..f981383
--- /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 2b6def4..8837257 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 26463b2..6ff26fd 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 04fe760..533c13d 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 b5aac2c..75d9bb0 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)
+}


Test patch:
diff --git a/analytics_test.go b/analytics_test.go
new file mode 100644
index 0000000..1de81e0
--- /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 0c43a8e..8ee7ef6 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/integration_test.go b/integration_test.go
new file mode 100644
index 0000000..e58be40
--- /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_test.go b/mailgun_test.go
index 8ac3f70..9c343ec 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
+}