diff --git a/README.md b/README.md index 45cbcb1..eaaefbe 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,13 @@ client.HTTPClient = urlfetch.Client(ctx) * [x] `GET /messages/inbound/:id/details` * [x] `PUT /messages/inbound/:id/bypass` * [x] `PUT /messages/inbound/:id/retry` +* [x] Message Streams + * [x] `GET /message-streams` + * [x] `POST /message-streams` + * [x] `GET /message-streams/{stream_ID}` + * [x] `PATCH /message-streams/{stream_ID}` + * [x] `POST /message-streams/{stream_ID}/archive` + * [x] `POST /message-streams/{stream_ID}/unarchive` * [ ] Sender signatures * [x] `GET /senders` * [ ] Get a sender signature’s details diff --git a/message_streams.go b/message_streams.go new file mode 100644 index 0000000..231aafd --- /dev/null +++ b/message_streams.go @@ -0,0 +1,190 @@ +package postmark + +import ( + "context" + "fmt" + "net/http" +) + +// MessageStreamType is an Enum representing the type of a message stream. +type MessageStreamType string + +// MessageStreamUnsubscribeHandlingType is an Enum with the possible values for +// the unsubscribe handling in a message stream. +type MessageStreamUnsubscribeHandlingType string + +const ( + // InboundMessageStreamType indicates a message stream is for inbound messages. + InboundMessageStreamType MessageStreamType = "Inbound" + // BroadcastMessageStreamType indicates a message stream is for broadcast messages. + BroadcastMessageStreamType MessageStreamType = "Broadcast" + // TransactionalMessageStreamType indicates a message stream is for transactional messages. + TransactionalMessageStreamType MessageStreamType = "Transactional" + + // NoneUnsubscribeHandlingType indicates a message stream unsubscribe + // handling will be performed by the user. + NoneUnsubscribeHandlingType MessageStreamUnsubscribeHandlingType = "None" + // PostmarkUnsubscribeHandlingType indicates a message stream unsubscribe + // handling will be performed by postmark. + PostmarkUnsubscribeHandlingType MessageStreamUnsubscribeHandlingType = "Postmark" + // CustomUnsubscribeHandlingType indicates a message stream unsubscribe + // handling is custom. + CustomUnsubscribeHandlingType MessageStreamUnsubscribeHandlingType = "Custom" +) + +// MessageStreamSubscriptionManagementConfiguration is the configuration for +// subscriptions to the message stream. +type MessageStreamSubscriptionManagementConfiguration struct { + // The unsubscribe management option used for the Stream. Broadcast Message + // Streams require unsubscribe management, Postmark is default. For Inbound + // and Transactional Streams default is none. + UnsubscribeHandlingType MessageStreamUnsubscribeHandlingType `json:"UnsubscribeHandlingType"` +} + +// MessageStream holes the configuration for a message stream on a server. +// https://postmarkapp.com/developer/api/message-streams-api +type MessageStream struct { + // ID of message stream. + ID string `json:"ID"` + // ID of server the message stream is associated with. + ServerID int `json:"ServerID"` + // Name of message stream. + Name string `json:"Name"` + // Description of message stream. This value can be null. + Description *string `json:"Description,omitempty"` + // Type of message stream. + MessageStreamType MessageStreamType `json:"MessageStreamType"` + // Timestamp when message stream was created. + CreatedAt string `json:"CreatedAt"` + // Timestamp when message stream was last updated. This value can be null. + UpdatedAt *string `json:"UpdatedAt,omitempty"` + // Timestamp when message stream was archived. This value can be null. + ArchivedAt *string `json:"ArchivedAt,omitempty"` + // Archived streams are deleted 45 days after archiving date. Until this + // date, it can be restored. This value is null if the stream is not + // archived. + ExpectedPurgeDate *string `json:"ExpectedPurgeDate,omitempty"` + // Subscription management options for the Stream + SubscriptionManagementConfiguration MessageStreamSubscriptionManagementConfiguration `json:"SubscriptionManagementConfiguration"` +} + +// ListMessageStreams returns all message streams for a server. +// messageStreamType must be one of "All", "Inbound", "Transactional", +// "Broadcasts" and defaults to "All". +func (client *Client) ListMessageStreams(ctx context.Context, messageStreamType string, includeArchived bool) ([]MessageStream, error) { + switch messageStreamType { + case "Inbound", "Transactional", "Broadcasts": + break + default: + messageStreamType = "All" + } + + var res struct { + MessageStreams []MessageStream + } + + err := client.doRequest(ctx, parameters{ + Method: http.MethodGet, + Path: fmt.Sprintf("message-streams?MessageStreamType=%s&IncludeArchivedStreams=%t", messageStreamType, includeArchived), + TokenType: serverToken, + }, &res) + + return res.MessageStreams, err +} + +// GetMessageStream retrieves a specific message stream by the message stream's ID. +func (client *Client) GetMessageStream(ctx context.Context, id string) (MessageStream, error) { + var res MessageStream + err := client.doRequest(ctx, parameters{ + Method: http.MethodGet, + Path: fmt.Sprintf("message-streams/%s", id), + TokenType: serverToken, + }, &res) + return res, err +} + +// EditMessageStreamRequest is the request body for EditMessageStream. It +// contains only a subset of the fields of MessageStream. +type EditMessageStreamRequest struct { + // Name of message stream. + Name string `json:"Name"` + // Description of message stream. This value can be null. + Description *string `json:"Description,omitempty"` + // Subscription management options for the Stream + SubscriptionManagementConfiguration MessageStreamSubscriptionManagementConfiguration `json:"SubscriptionManagementConfiguration"` +} + +// EditMessageStream updates a message stream. +func (client *Client) EditMessageStream(ctx context.Context, id string, req EditMessageStreamRequest) (MessageStream, error) { + var res MessageStream + err := client.doRequest(ctx, parameters{ + Method: http.MethodPatch, + Path: fmt.Sprintf("message-streams/%s", id), + TokenType: serverToken, + Payload: req, + }, &res) + return res, err +} + +// CreateMessageStreamRequest is the request body for CreateMessageStream. It +// contains only a subset of the fields of MessageStream. +type CreateMessageStreamRequest struct { + // ID of message stream. + ID string `json:"ID"` + // Name of message stream. + Name string `json:"Name"` + // Description of message stream. This value can be null. + Description *string `json:"Description,omitempty"` + // Type of message stream. + MessageStreamType MessageStreamType `json:"MessageStreamType"` + // Subscription management options for the Stream + SubscriptionManagementConfiguration MessageStreamSubscriptionManagementConfiguration `json:"SubscriptionManagementConfiguration"` +} + +// CreateMessageStream makes a new message stream. It will be created on the +// server of the token used by this Client. +func (client *Client) CreateMessageStream(ctx context.Context, req CreateMessageStreamRequest) (MessageStream, error) { + var res MessageStream + err := client.doRequest(ctx, parameters{ + Method: http.MethodPost, + Path: "message-streams", + TokenType: serverToken, + Payload: req, + }, &res) + return res, err +} + +// ArchiveMessageStreamResponse is the response body for ArchiveMessageStream. +type ArchiveMessageStreamResponse struct { + // ID of message stream. + ID string `json:"ID"` + // Server ID of message stream. + ServerID int `json:"ServerID"` + // Expected purge date of message stream. Stream is deleted 45 days after + // archiving date. Until this date, it can be restored. + ExpectedPurgeDate string `json:"ExpectedPurgeDate"` +} + +// ArchiveMessageStream archives a message stream. Archived streams are deleted +// after 45 days, but they can be restored until that point. +func (client *Client) ArchiveMessageStream(ctx context.Context, id string) (ArchiveMessageStreamResponse, error) { + var res ArchiveMessageStreamResponse + err := client.doRequest(ctx, parameters{ + Method: http.MethodPost, + Path: fmt.Sprintf("message-streams/%s/archive", id), + TokenType: serverToken, + }, &res) + return res, err +} + +// UnarchiveMessageStream unarchives a message stream if it has not been deleted yet. +// The ArchivedAt value will be null after calling this method. +func (client *Client) UnarchiveMessageStream(ctx context.Context, id string) (MessageStream, error) { + var res MessageStream + err := client.doRequest(ctx, parameters{ + Method: http.MethodPost, + Path: fmt.Sprintf("message-streams/%s/unarchive", id), + TokenType: serverToken, + }, &res) + return res, err +} diff --git a/message_streams_test.go b/message_streams_test.go new file mode 100644 index 0000000..a81f817 --- /dev/null +++ b/message_streams_test.go @@ -0,0 +1,326 @@ +package postmark + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "goji.io/pat" +) + +const ( + transactionalDev = "transactional-dev" +) + +func TestListMessageStreams(t *testing.T) { + responseJSON := `{ + "MessageStreams": [ { + "ID": "outbound", + "ServerID": 123457, + "Name": "Transactional Stream", + "Description": "This is my stream to send transactional messages", + "MessageStreamType": "Transactional", + "CreatedAt": "2020-07-01T00:00:00-04:00", + "UpdatedAt": "2020-07-05T00:00:00-04:00", + "ArchivedAt": null, + "ExpectedPurgeDate": null, + "SubscriptionManagementConfiguration": { + "UnsubscribeHandlingType": "none" + } + }, + { + "ID": "inbound", + "ServerID": 123457, + "Name": "Inbound Stream", + "Description": "Stream used for receiving inbound messages", + "MessageStreamType": "Inbound", + "CreatedAt": "2020-07-01T00:00:00-04:00", + "UpdatedAt": null, + "ArchivedAt": null, + "ExpectedPurgeDate": null, + "SubscriptionManagementConfiguration": { + "UnsubscribeHandlingType": "none" + } + }, + { + "ID": "transactional-dev", + "ServerID": 123457, + "Name": "My Dev Transactional Stream", + "Description": "This is my second transactional stream", + "MessageStreamType": "Transactional", + "CreatedAt": "2020-07-02T00:00:00-04:00", + "UpdatedAt": "2020-07-04T00:00:00-04:00", + "ArchivedAt": null, + "ExpectedPurgeDate": null, + "SubscriptionManagementConfiguration": { + "UnsubscribeHandlingType": "none" + } + } + ], + "TotalCount": 3 + }` + + tMux.HandleFunc(pat.Get("/message-streams"), func(w http.ResponseWriter, req *http.Request) { + if req.URL.Query().Get("IncludeArchivedStreams") != "false" { + t.Fatalf("MessageStreams: wrong IncludeArchivedStreams value (%s)", req.URL.Query().Get("IncludeArchivedStreams")) + } + if req.URL.Query().Get("MessageStreamType") != "All" { + t.Fatalf("MessageStreams: wrong messageStreamType value (%s)", req.URL.Query().Get("MessageStreamType")) + } + _, _ = w.Write([]byte(responseJSON)) + }) + + res, err := client.ListMessageStreams(context.Background(), "All", false) + if err != nil { + t.Fatalf("MessageStreams: %s", err.Error()) + } + + if len(res) != 3 { + t.Fatalf("MessageStreams: wrong number of message streams (%d)", len(res)) + } + + // For each message stream, check the ServerID + for _, ms := range res { + if ms.ServerID != 123457 { + t.Fatalf("MessageStreams: wrong ServerID (%d)", ms.ServerID) + } + if ms.ArchivedAt != nil { + t.Fatalf("MessageStreams: wrong ArchivedAt (%s)", *ms.ArchivedAt) + } + } + + if res[0].ID != "outbound" { + t.Fatalf("MessageStreams: wrong ID (%s)", res[0].ID) + } + if res[1].ID != "inbound" { + t.Fatalf("MessageStreams: wrong ID (%s)", res[1].ID) + } + if res[2].ID != transactionalDev { + t.Fatalf("MessageStreams: wrong ID (%s)", res[2].ID) + } +} + +func TestGetUnknownMessageStream(t *testing.T) { + responseJSON := `{"ErrorCode":1226,"Message":"The message stream for the provided 'ID' was not found."}` + + tMux.HandleFunc(pat.Get("/message-streams/unknown"), func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(responseJSON)) + }) + + res, err := client.GetMessageStream(context.Background(), "unknown") + if err == nil { + t.Fatalf("MessageStream: expected error") + } + if err.Error() != "The message stream for the provided 'ID' was not found." { + t.Fatalf("MessageStream: wrong error message (%s)", err.Error()) + } + + var zero MessageStream + if res != zero { + t.Fatalf("MessageStream: expected empty response") + } +} + +func TestGetMessageStream(t *testing.T) { + responseJSON := `{ + "ID": "broadcasts", + "ServerID": 123456, + "Name": "Broadcast Stream", + "Description": "This is my stream to send broadcast messages", + "MessageStreamType": "Broadcasts", + "CreatedAt": "2020-07-01T00:00:00-04:00", + "UpdatedAt": "2020-07-01T00:00:00-04:00", + "ArchivedAt": null, + "ExpectedPurgeDate": null, + "SubscriptionManagementConfiguration": { + "UnsubscribeHandlingType": "Postmark" + } + }` + + tMux.HandleFunc(pat.Get("/message-streams/broadcasts"), func(w http.ResponseWriter, req *http.Request) { + _, _ = w.Write([]byte(responseJSON)) + }) + + res, err := client.GetMessageStream(context.Background(), "broadcasts") + if err != nil { + t.Fatalf("MessageStream: %s", err.Error()) + } + + if res.ID != "broadcasts" { + t.Fatalf("MessageStream: wrong ID (%s)", res.ID) + } + + if res.Name != "Broadcast Stream" { + t.Fatalf("MessageStream: wrong Name (%s)", res.Name) + } + + if *res.Description != "This is my stream to send broadcast messages" { + t.Fatalf("MessageStream: wrong Description (%s)", *res.Description) + } +} + +func TestEditMessageStream(t *testing.T) { + responseJSON := `{ + "ID": "transactional-dev", + "ServerID": 123457, + "Name": "Updated Dev Stream", + "Description": "Updating my dev transactional stream", + "MessageStreamType": "Transactional", + "CreatedAt": "2020-07-02T00:00:00-04:00", + "UpdatedAt": "2020-07-03T00:00:00-04:00", + "ArchivedAt": null, + "ExpectedPurgeDate": null, + "SubscriptionManagementConfiguration": { + "UnsubscribeHandlingType": "none" + } + }` + + editReq := EditMessageStreamRequest{ + Name: "Updated Dev Stream", + SubscriptionManagementConfiguration: MessageStreamSubscriptionManagementConfiguration{ + UnsubscribeHandlingType: "none", + }, + } + + tMux.HandleFunc(pat.Patch("/message-streams/transactional-dev"), func(w http.ResponseWriter, req *http.Request) { + var body EditMessageStreamRequest + err := json.NewDecoder(req.Body).Decode(&body) + if err != nil { + t.Fatalf("Failed to read request body: %s", err.Error()) + } + + if body.Description != nil { + t.Fatalf("EditMessageStream: wrong Description (%v)", body.Description) + } + if editReq.Name != body.Name { + t.Fatalf("EditMessageStream: wrong Name (%s)", body.Name) + } + if editReq.SubscriptionManagementConfiguration.UnsubscribeHandlingType != body.SubscriptionManagementConfiguration.UnsubscribeHandlingType { + t.Fatalf("EditMessageStream: wrong UnsubscribeHandlingType (%s)", body.SubscriptionManagementConfiguration.UnsubscribeHandlingType) + } + + _, _ = w.Write([]byte(responseJSON)) + }) + + res, err := client.EditMessageStream(context.Background(), transactionalDev, editReq) + if err != nil { + t.Fatalf("MessageStream: %s", err.Error()) + } + + if res.ID != transactionalDev { + t.Fatalf("MessageStream: wrong ID (%s)", res.ID) + } + if res.ServerID != 123457 { + t.Fatalf("MessageStream: wrong ServerID (%d)", res.ServerID) + } + if *res.Description != "Updating my dev transactional stream" { + t.Fatalf("MessageStream: wrong Description (%s)", *res.Description) + } +} + +func TestCreateMessageStream(t *testing.T) { + responseJSON := `{ + "ID": "transactional-dev", + "ServerID": 123457, + "Name": "My Dev Transactional Stream", + "Description": "This is my second transactional stream", + "MessageStreamType": "Transactional", + "CreatedAt": "2020-07-02T00:00:00-04:00", + "UpdatedAt": "2020-07-02T00:00:00-04:00", + "ArchivedAt": "2020-07-02T00:00:00-04:00", + "SubscriptionManagementConfiguration": { + "UnsubscribeHandlingType": "None" + } + }` + + desc := "This is my second transactional stream" + createReq := CreateMessageStreamRequest{ + ID: transactionalDev, + Name: "My Dev Transactional Stream", + Description: &desc, + MessageStreamType: "Transactional", + SubscriptionManagementConfiguration: MessageStreamSubscriptionManagementConfiguration{ + UnsubscribeHandlingType: "None", + }, + } + + tMux.HandleFunc(pat.Post("/message-streams"), func(w http.ResponseWriter, req *http.Request) { + _, _ = w.Write([]byte(responseJSON)) + }) + + res, err := client.CreateMessageStream(context.Background(), createReq) + if err != nil { + t.Fatalf("MessageStream: %s", err.Error()) + } + + if res.ID != transactionalDev { + t.Fatalf("MessageStream: wrong ID (%s)", res.ID) + } + if res.ServerID != 123457 { + t.Fatalf("MessageStream: wrong ServerID (%d)", res.ServerID) + } + if res.MessageStreamType != "Transactional" { + t.Fatalf("MessageStream: wrong MessageStreamType (%s)", res.MessageStreamType) + } +} + +func TestArchiveMessageStream(t *testing.T) { + responseJSON := `{ + "ID": "transactional-dev", + "ServerID": 123457, + "ExpectedPurgeDate": "2020-08-30T12:30:00.00-04:00" + }` + + tMux.HandleFunc(pat.Post("/message-streams/transactional-dev/archive"), func(w http.ResponseWriter, req *http.Request) { + _, _ = w.Write([]byte(responseJSON)) + }) + + res, err := client.ArchiveMessageStream(context.Background(), transactionalDev) + if err != nil { + t.Fatalf("MessageStream: %s", err.Error()) + } + + if res.ID != transactionalDev { + t.Fatalf("MessageStream: wrong ID (%s)", res.ID) + } + if res.ServerID != 123457 { + t.Fatalf("MessageStream: wrong ServerID (%d)", res.ServerID) + } + if res.ExpectedPurgeDate != "2020-08-30T12:30:00.00-04:00" { + t.Fatalf("MessageStream: wrong ExpectedPurgeDate (%s)", res.ExpectedPurgeDate) + } +} + +func TestUnarchiveMessageStream(t *testing.T) { + responseJSON := `{ + "ID": "transactional-dev", + "ServerID": 123457, + "Name": "Updated Dev Stream", + "Description": "Updating my dev transactional stream", + "MessageStreamType": "Transactional", + "CreatedAt": "2020-07-02T00:00:00-04:00", + "UpdatedAt": "2020-07-04T00:00:00-04:00", + "ArchivedAt": null, + "SubscriptionManagementConfiguration": { + "UnsubscribeHandlingType": "none" + } + }` + + tMux.HandleFunc(pat.Post("/message-streams/transactional-dev/unarchive"), func(w http.ResponseWriter, req *http.Request) { + _, _ = w.Write([]byte(responseJSON)) + }) + + res, err := client.UnarchiveMessageStream(context.Background(), transactionalDev) + if err != nil { + t.Fatalf("MessageStream: %s", err.Error()) + } + + if res.ID != transactionalDev { + t.Fatalf("MessageStream: wrong ID (%s)", res.ID) + } + if res.ServerID != 123457 { + t.Fatalf("MessageStream: wrong ServerID (%d)", res.ServerID) + } +} diff --git a/postmark.go b/postmark.go index 104df5c..038d92f 100644 --- a/postmark.go +++ b/postmark.go @@ -96,6 +96,17 @@ func (client *Client) doRequest(ctx context.Context, opts parameters, dst interf if err != nil { return } + + if res.StatusCode >= 400 { + // If the status code is not a success, attempt to unmarshall the body into the APIError struct. + var apiErr APIError + err = json.Unmarshal(body, &apiErr) + if err != nil { + return + } + return apiErr + } + err = json.Unmarshal(body, dst) return } @@ -103,9 +114,9 @@ func (client *Client) doRequest(ctx context.Context, opts parameters, dst interf // APIError represents errors returned by Postmark type APIError struct { // ErrorCode: see error codes here (https://postmarkapp.com/developer/api/overview#error-codes) - ErrorCode int64 + ErrorCode int64 `json:"ErrorCode"` // Message contains error details - Message string + Message string `json:"Message"` } // Error returns the error message details