From 1273e76814cb7950c36e60668220a80641820ea1 Mon Sep 17 00:00:00 2001 From: Marcus Carey Date: Fri, 10 Jan 2025 14:58:34 -0600 Subject: [PATCH 1/5] fix: set server payload on PUT request --- servers.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/servers.go b/servers.go index 71e98f8..e37754a 100644 --- a/servers.go +++ b/servers.go @@ -92,12 +92,13 @@ func (client *Client) GetServer(ctx context.Context, serverID string) (Server, e // EditServer updates details for a specific server with serverID func (client *Client) EditServer(ctx context.Context, serverID string, server Server) (Server, error) { - // res := Server{} + res := Server{} err := client.doRequest(ctx, parameters{ Method: http.MethodPut, Path: fmt.Sprintf("servers/%s", serverID), TokenType: accountToken, - }, &server) + Payload: server, + }, &res) return server, err } From 24628c5d57f9ff8da239372e42732c2aaec09983 Mon Sep 17 00:00:00 2001 From: Marcus Carey Date: Fri, 10 Jan 2025 15:04:00 -0600 Subject: [PATCH 2/5] chore: return response --- servers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/servers.go b/servers.go index e37754a..f889374 100644 --- a/servers.go +++ b/servers.go @@ -99,7 +99,7 @@ func (client *Client) EditServer(ctx context.Context, serverID string, server Se TokenType: accountToken, Payload: server, }, &res) - return server, err + return res, err } // CreateServer creates a server From 9738931c655c5799ff710403e955555d5612e739 Mon Sep 17 00:00:00 2001 From: Marcus Carey Date: Sun, 12 Jan 2025 13:26:22 -0600 Subject: [PATCH 3/5] feat: use correct put/post body and add list api --- servers.go | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 6 deletions(-) diff --git a/servers.go b/servers.go index f889374..fd26aef 100644 --- a/servers.go +++ b/servers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" ) // Server represents a server registered in your Postmark account @@ -29,10 +30,12 @@ type Server struct { InboundAddress string `json:"InboundAddress"` // InboundHookURL to POST to every time an inbound event occurs. InboundHookURL string `json:"InboundHookUrl"` - // BounceHookURL to POST to every time a bounce event occurs. + // Deprecated: Use the Bounce Webhook API instead. BounceHookURL string `json:"BounceHookUrl"` - // OpenHookURL to POST to every time an open event occurs. + // Deprecated: Use the Open Tracking Webhook API instead. OpenHookURL string `json:"OpenHookUrl"` + // Deprecated: Use the Delivery Webhook API instead. + DeliveryHookURL string `json:"DeliveryHookUrl"` // PostFirstOpenOnly - If set to true, only the first open by a particular recipient will initiate the open webhook. Any // subsequent opens of the same email by the same recipient will not initiate the webhook. PostFirstOpenOnly bool `json:"PostFirstOpenOnly"` @@ -52,6 +55,89 @@ type Server struct { EnableSMTPAPIErrorHooks bool `json:"EnableSmtpApiErrorHooks"` } +// ServerCreateRequest represents the fields to create a server +type ServerCreateRequest struct { + // Name of server + Name string `json:"Name" binding:"required"` + // Color of the server in the server list, for quick identification. Purple Blue Turquoise Green Red Yellow Grey Orange + Color string `json:"Color"` + // SMTPAPIActivated specifies whether SMTP is enabled on this server. + SMTPAPIActivated bool `json:"SmtpApiActivated"` + // When enabled, the raw email content will be included with inbound webhook payloads under the RawEmail key. + RawEmailEnabled bool `json:"RawEmailEnabled"` + // Specifies the type of environment for your server. Possible options: Live Sandbox. Defaults to Live if not + // specified. This cannot be changed after the server has been created. + DeliveryType string `json:"DeliveryType"` + // URL to POST to every time an inbound event occurs. + InboundHookURL string `json:"InboundHookUrl"` + // Deprecated: Use the Bounce Webhook API instead. + BounceHookURL string `json:"BounceHookUrl"` + // Deprecated: Use the Open Tracking Webhook API instead. + OpenHookURL string `json:"OpenHookUrl"` + // Deprecated: Use the Delivery Webhook API instead. + DeliveryHookURL string `json:"DeliveryHookUrl"` + // Deprecated: Use the Click Webhook API instead. + ClickHookURL string `json:"ClickHookUrl"` + // PostFirstOpenOnly - If set to true, only the first open by a particular recipient will initiate the open webhook. Any + // subsequent opens of the same email by the same recipient will not initiate the webhook. + PostFirstOpenOnly bool `json:"PostFirstOpenOnly"` + // InboundDomain is the inbound domain for MX setup + InboundDomain string `json:"InboundDomain"` + // InboundSpamThreshold is the maximum spam score for an inbound message before it's blocked. + InboundSpamThreshold int64 `json:"InboundSpamThreshold"` + // TrackOpens indicates if all emails being sent through this server have open tracking enabled. + TrackOpens bool `json:"TrackOpens"` + // TrackLinks specifies link tracking in emails: None, HtmlAndText, HtmlOnly, TextOnly, defaults to "None" + TrackLinks string `json:"TrackLinks"` + // IncludeBounceContentInHook determines if bounce content is included in webhook. + IncludeBounceContentInHook bool `json:"IncludeBounceContentInHook"` + // EnableSMTPAPIErrorHooks specifies whether SMTP API Errors will be included with bounce webhooks. + EnableSMTPAPIErrorHooks bool `json:"EnableSmtpApiErrorHooks"` +} + +// ServerEditRequest represents the fields that can be updated for a server +type ServerEditRequest struct { + // Name of server + Name string `json:"Name" binding:"required"` + // Color of the server in the server list, for quick identification. Purple Blue Turquoise Green Red Yellow Grey Orange + Color string `json:"Color"` + // SMTPAPIActivated specifies whether SMTP is enabled on this server. + SMTPAPIActivated bool `json:"SmtpApiActivated"` + // When enabled, the raw email content will be included with inbound webhook payloads under the RawEmail key. + RawEmailEnabled bool `json:"RawEmailEnabled"` + // URL to POST to every time an inbound event occurs. + InboundHookURL string `json:"InboundHookUrl"` + // Deprecated: Use the Bounce Webhook API instead. + BounceHookURL string `json:"BounceHookUrl"` + // Deprecated: Use the Open Tracking Webhook API instead. + OpenHookURL string `json:"OpenHookUrl"` + // Deprecated: Use the Delivery Webhook API instead. + DeliveryHookURL string `json:"DeliveryHookUrl"` + // Deprecated: Use the Click Webhook API instead. + ClickHookURL string `json:"ClickHookUrl"` + // PostFirstOpenOnly - If set to true, only the first open by a particular recipient will initiate the open webhook. Any + // subsequent opens of the same email by the same recipient will not initiate the webhook. + PostFirstOpenOnly bool `json:"PostFirstOpenOnly"` + // InboundDomain is the inbound domain for MX setup + InboundDomain string `json:"InboundDomain"` + // InboundSpamThreshold is the maximum spam score for an inbound message before it's blocked. + InboundSpamThreshold int64 `json:"InboundSpamThreshold"` + // TrackOpens indicates if all emails being sent through this server have open tracking enabled. + TrackOpens bool `json:"TrackOpens"` + // TrackLinks specifies link tracking in emails: None, HtmlAndText, HtmlOnly, TextOnly, defaults to "None" + TrackLinks string `json:"TrackLinks"` + // IncludeBounceContentInHook determines if bounce content is included in webhook. + IncludeBounceContentInHook bool `json:"IncludeBounceContentInHook"` + // EnableSMTPAPIErrorHooks specifies whether SMTP API Errors will be included with bounce webhooks. + EnableSMTPAPIErrorHooks bool `json:"EnableSmtpApiErrorHooks"` +} + +// ServersList is just a list of Server as they are in the response +type ServersList struct { + TotalCount int + Servers []Server +} + // MarshalJSON customizes the JSON representation of the Server struct by setting default values for specific fields. func (s Server) MarshalJSON() ([]byte, error) { type Aux Server @@ -90,26 +176,48 @@ func (client *Client) GetServer(ctx context.Context, serverID string) (Server, e return res, err } +// GetServers fetches a list of servers on the account, limited by count and paged by offset +// Optionally filter by a specific server name. Note that this is a string search, so MyServer will match +// MyServer, MyServer Production, and MyServer Test. +func (client *Client) GetServers(ctx context.Context, count, offset int64, name string) (ServersList, error) { + res := ServersList{} + + values := &url.Values{} + values.Add("count", fmt.Sprintf("%d", count)) + values.Add("offset", fmt.Sprintf("%d", offset)) + + if name != "" { + values.Add("name", name) + } + + err := client.doRequest(ctx, parameters{ + Method: "GET", + Path: fmt.Sprintf("servers?%s", values.Encode()), + TokenType: accountToken, + }, &res) + return res, err +} + // EditServer updates details for a specific server with serverID -func (client *Client) EditServer(ctx context.Context, serverID string, server Server) (Server, error) { +func (client *Client) EditServer(ctx context.Context, serverID string, request ServerEditRequest) (Server, error) { res := Server{} err := client.doRequest(ctx, parameters{ Method: http.MethodPut, Path: fmt.Sprintf("servers/%s", serverID), TokenType: accountToken, - Payload: server, + Payload: request, }, &res) return res, err } // CreateServer creates a server -func (client *Client) CreateServer(ctx context.Context, server Server) (Server, error) { +func (client *Client) CreateServer(ctx context.Context, request ServerCreateRequest) (Server, error) { res := Server{} err := client.doRequest(ctx, parameters{ Method: http.MethodPost, Path: "servers", TokenType: accountToken, - Payload: server, + Payload: request, }, &res) return res, err } From f53db658cdc0e6a6e0b24b810d5d9e84377b0251 Mon Sep 17 00:00:00 2001 From: Marcus Carey Date: Sun, 12 Jan 2025 13:27:10 -0600 Subject: [PATCH 4/5] chore: update test --- servers_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/servers_test.go b/servers_test.go index fa32e83..359a6b2 100644 --- a/servers_test.go +++ b/servers_test.go @@ -70,7 +70,7 @@ func TestEditServer(t *testing.T) { _, _ = w.Write([]byte(responseJSON)) }) - res, err := client.EditServer(context.Background(), "1234", Server{ + res, err := client.EditServer(context.Background(), "1234", ServerEditRequest{ Name: "Production Testing", }) if err != nil { From f36b3611dce4f046a52ffa0e6da007b1ffdbcd0d Mon Sep 17 00:00:00 2001 From: Marcus Carey Date: Sun, 12 Jan 2025 16:06:45 -0600 Subject: [PATCH 5/5] feat: Add server delete request --- servers.go | 24 ++++++-- servers_test.go | 160 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 6 deletions(-) diff --git a/servers.go b/servers.go index fd26aef..0c42c67 100644 --- a/servers.go +++ b/servers.go @@ -166,11 +166,11 @@ func (s Server) MarshalJSON() ([]byte, error) { } // GetServer fetches a specific server via serverID -func (client *Client) GetServer(ctx context.Context, serverID string) (Server, error) { +func (client *Client) GetServer(ctx context.Context, serverID int64) (Server, error) { res := Server{} err := client.doRequest(ctx, parameters{ Method: http.MethodGet, - Path: fmt.Sprintf("servers/%s", serverID), + Path: fmt.Sprintf("servers/%d", serverID), TokenType: accountToken, }, &res) return res, err @@ -199,11 +199,11 @@ func (client *Client) GetServers(ctx context.Context, count, offset int64, name } // EditServer updates details for a specific server with serverID -func (client *Client) EditServer(ctx context.Context, serverID string, request ServerEditRequest) (Server, error) { +func (client *Client) EditServer(ctx context.Context, serverID int64, request ServerEditRequest) (Server, error) { res := Server{} err := client.doRequest(ctx, parameters{ Method: http.MethodPut, - Path: fmt.Sprintf("servers/%s", serverID), + Path: fmt.Sprintf("servers/%d", serverID), TokenType: accountToken, Payload: request, }, &res) @@ -221,3 +221,19 @@ func (client *Client) CreateServer(ctx context.Context, request ServerCreateRequ }, &res) return res, err } + +// DeleteServer removes a server. +func (client *Client) DeleteServer(ctx context.Context, serverID int64) error { + res := APIError{} + err := client.doRequest(ctx, parameters{ + Method: http.MethodDelete, + Path: fmt.Sprintf("servers/%d", serverID), + TokenType: accountToken, + }, &res) + + if res.ErrorCode != 0 { + return res + } + + return err +} diff --git a/servers_test.go b/servers_test.go index 359a6b2..a73670f 100644 --- a/servers_test.go +++ b/servers_test.go @@ -8,6 +8,83 @@ import ( "goji.io/pat" ) +func TestGetServers(t *testing.T) { + responseJSON := `{ + "TotalCount": 2, + "Servers": [ + { + "ID": 1, + "Name": "Production01", + "ApiTokens": [ + "server token" + ], + "Color": "red", + "SmtpApiActivated": true, + "RawEmailEnabled": false, + "DeliveryType": "Live", + "ServerLink": "https://postmarkapp.com/servers/1/streams", + "InboundAddress": "yourhash@inbound.postmarkapp.com", + "InboundHookUrl": "http://inboundhook.example.com/inbound", + "BounceHookUrl": "http://bouncehook.example.com/bounce", + "OpenHookUrl": "http://openhook.example.com/open", + "DeliveryHookUrl": "http://hooks.example.com/delivery", + "PostFirstOpenOnly": true, + "InboundDomain": "", + "InboundHash": "yourhash", + "InboundSpamThreshold": 5, + "TrackOpens": false, + "TrackLinks": "None", + "IncludeBounceContentInHook": true, + "ClickHookUrl": "http://hooks.example.com/click", + "EnableSmtpApiErrorHooks": false + }, + { + "ID": 2, + "Name": "Production02", + "ApiTokens": [ + "server token" + ], + "Color": "green", + "SmtpApiActivated": true, + "RawEmailEnabled": false, + "DeliveryType": "Sandbox", + "ServerLink": "https://postmarkapp.com/servers/2/streams", + "InboundAddress": "yourhash@inbound.postmarkapp.com", + "InboundHookUrl": "", + "BounceHookUrl": "", + "OpenHookUrl": "", + "DeliveryHookUrl": "http://hooks.example.com/delivery", + "PostFirstOpenOnly": false, + "InboundDomain": "", + "InboundHash": "yourhash", + "InboundSpamThreshold": 0, + "TrackOpens": true, + "TrackLinks": "HtmlAndText", + "IncludeBounceContentInHook": false, + "ClickHookUrl": "", + "EnableSmtpApiErrorHooks": false + } + ] +}` + + tMux.HandleFunc(pat.Get("/servers"), func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(responseJSON)) + }) + + res, err := client.GetServers(context.Background(), 100, 10, "") + if err != nil { + t.Fatalf("GetServers: %s", err.Error()) + } + + if len(res.Servers) == 0 { + t.Fatalf("GetServers: unmarshaled to empty") + } + + if res.TotalCount != 2 { + t.Fatalf("GetServers: unmarshaled to empty") + } +} + func TestGetServer(t *testing.T) { responseJSON := `{ "ID": 1, @@ -34,7 +111,7 @@ func TestGetServer(t *testing.T) { _, _ = w.Write([]byte(responseJSON)) }) - res, err := client.GetServer(context.Background(), "1") + res, err := client.GetServer(context.Background(), 1) if err != nil { t.Fatalf("GetServer: %s", err.Error()) } @@ -44,6 +121,57 @@ func TestGetServer(t *testing.T) { } } +func TestCreateServer(t *testing.T) { + responseJSON := `{ + "ID": 1, + "Name": "Staging Testing", + "ApiTokens": [ + "server token" + ], + "Color": "red", + "SmtpApiActivated": true, + "RawEmailEnabled": false, + "DeliveryType": "Live", + "ServerLink": "https://postmarkapp.com/servers/1/streams", + "InboundAddress": "yourhash@inbound.postmarkapp.com", + "InboundHookUrl": "http://hooks.example.com/inbound", + "PostFirstOpenOnly": false, + "InboundDomain": "", + "InboundHash": "yourhash", + "InboundSpamThreshold": 5, + "TrackOpens": false, + "TrackLinks": "None", + "IncludeBounceContentInHook": true, + "EnableSmtpApiErrorHooks": false +}` + + tMux.HandleFunc(pat.Post("/servers"), func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(responseJSON)) + }) + + res, err := client.CreateServer(context.Background(), ServerCreateRequest{ + Name: "Staging Testing", + Color: "red", + SMTPAPIActivated: true, + RawEmailEnabled: false, + InboundHookURL: "http://hooks.example.com/inbound", + PostFirstOpenOnly: false, + InboundDomain: "", + InboundSpamThreshold: 5, + TrackOpens: false, + TrackLinks: "None", + IncludeBounceContentInHook: true, + EnableSMTPAPIErrorHooks: false, + }) + if err != nil { + t.Fatalf("CreateServer: %s", err.Error()) + } + + if res.Name != "Staging Testing" { + t.Fatalf("CreateServer: wrong name!") + } +} + func TestEditServer(t *testing.T) { responseJSON := `{ "ID": 1, @@ -70,7 +198,7 @@ func TestEditServer(t *testing.T) { _, _ = w.Write([]byte(responseJSON)) }) - res, err := client.EditServer(context.Background(), "1234", ServerEditRequest{ + res, err := client.EditServer(context.Background(), 1234, ServerEditRequest{ Name: "Production Testing", }) if err != nil { @@ -81,3 +209,31 @@ func TestEditServer(t *testing.T) { t.Fatalf("EditServer: wrong name!: %s", res.Name) } } + +func TestDeleteServer(t *testing.T) { + responseJSON := `{ + "ErrorCode": 0, + "Message": "Server 1234 removed." + }` + + tMux.HandleFunc(pat.Delete("/servers/:serverID"), func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(responseJSON)) + }) + + // Success + err := client.DeleteServer(context.Background(), 1234) + if err != nil { + t.Fatalf("DeleteServer: %s", err.Error()) + } + + // Failure + responseJSON = `{ + "ErrorCode": 402, + "Message": "Invalid JSON" + }` + + err = client.DeleteServer(context.Background(), 1234) + if err == nil { + t.Fatalf("DeleteServer: should have failed") + } +}