helo-email-sdk 1.0.0.pre.beta.2 → 1.0.0.pre.beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +125 -0
  3. data/lib/helo/apis/statistics.rb +3 -3
  4. data/lib/helo/core/client.rb +11 -10
  5. data/lib/helo/enums/webhook_event.rb +5 -1
  6. data/lib/helo/models/accepted_webhook_payload.rb +1 -1
  7. data/lib/helo/models/bounced_details.rb +11 -0
  8. data/lib/helo/models/bounced_webhook_payload.rb +2 -2
  9. data/lib/helo/models/clicked_details.rb +14 -0
  10. data/lib/helo/models/clicked_webhook_payload.rb +2 -2
  11. data/lib/helo/models/client_details.rb +10 -0
  12. data/lib/helo/models/complained_details.rb +9 -0
  13. data/lib/helo/models/complained_webhook_payload.rb +2 -2
  14. data/lib/helo/models/delivered_details.rb +9 -0
  15. data/lib/helo/models/delivered_webhook_payload.rb +2 -2
  16. data/lib/helo/models/delivery_webhook_payload_common.rb +15 -0
  17. data/lib/helo/models/device_details.rb +11 -0
  18. data/lib/helo/models/domain_key_verification_failed_payload.rb +13 -0
  19. data/lib/helo/models/domain_key_verified_payload.rb +13 -0
  20. data/lib/helo/models/domain_payload_common.rb +12 -0
  21. data/lib/helo/models/engagement_details.rb +13 -0
  22. data/lib/helo/models/message.rb +2 -0
  23. data/lib/helo/models/message_details_response.rb +1 -0
  24. data/lib/helo/models/message_statistics.rb +14 -0
  25. data/lib/helo/models/opened_details.rb +13 -0
  26. data/lib/helo/models/opened_webhook_payload.rb +2 -2
  27. data/lib/helo/models/processed_webhook_payload.rb +1 -1
  28. data/lib/helo/models/recipient_event_fields.rb +0 -1
  29. data/lib/helo/models/resubscribed_details.rb +9 -0
  30. data/lib/helo/models/resubscribed_webhook_payload.rb +2 -2
  31. data/lib/helo/models/return_path_domain_verification_failed_payload.rb +13 -0
  32. data/lib/helo/models/return_path_domain_verified_payload.rb +13 -0
  33. data/lib/helo/models/unsubscribed_details.rb +9 -0
  34. data/lib/helo/models/unsubscribed_webhook_payload.rb +2 -2
  35. data/lib/helo/models/webhook_payload_common.rb +0 -6
  36. data/lib/helo/version.rb +1 -1
  37. data/lib/helo/webhook_signatures.rb +155 -0
  38. data/lib/helo.rb +1 -0
  39. metadata +20 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 612d57ecd6a1b1917fe6d79997e1e444bee7b26d2d7dc851a7727509f4f29e67
4
- data.tar.gz: 28bc563518998af787122744b916eab205993fff3d492ee00fd4b196e864f989
3
+ metadata.gz: cbf62a1f7adb7ad672a9ddf17441cc9cda10e7c199775a4bf3a94785e2f3ba3d
4
+ data.tar.gz: 434a84d00c0104fb81211d0c105293f2dc1271b59a8872003b761d151247249d
5
5
  SHA512:
6
- metadata.gz: 0e7b810a2624aaec9cfcb9818b90bdcbf5771bd70f566aab9b29f7d36e811003cd28895038fce168d2e50ddc476032b52c70f01ea08aca31debce6963e3e8ac9
7
- data.tar.gz: 847cf7cf9fe2e78f5d1ff75e246fbe68d88d1eab506b18588af26a1f00d490a866dd6f75dbb569dbd8251cce9c28a051197b1728ff941d4c36a4f53c39fbece4
6
+ metadata.gz: 5bd2ba8cd121447ead38cbd6916ccdbbd5c755afd4035d0c550fab2c82220ec2f56d80f2ebe5bc23a9352acd21bd7f2f68867a0c3feddfeca322071c51c8bb4a
7
+ data.tar.gz: 1f3bb34bbcfafc14d5d7d2ec10e9c74a9bc924db20b240e639d30f42423cd70e48f9e9fda9b0736ebc683061cbd9082faa681952131449e01645330e89b82b96
data/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # helo-email-sdk
2
+
3
+ Helo API
4
+
5
+ ## Installation
6
+
7
+ Add the gem to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "helo-email-sdk"
11
+ ```
12
+
13
+ Then run:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ ## Configuration
20
+
21
+ Configure the SDK once, at boot:
22
+
23
+ ```ruby
24
+ require "helo-email-sdk"
25
+
26
+ Helo.configure do |config|
27
+ config.api_key = ENV.fetch("HELO_API_KEY")
28
+ config.base_url = "https://api.helohq.com" # optional, this is the default
29
+ end
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ Each API is a class with class-level methods. Responses come back as model objects with
35
+ typed attribute readers.
36
+
37
+ ```ruby
38
+ result = Helo::Channels.list
39
+ ```
40
+
41
+ Methods that take parameters accept a hash:
42
+
43
+ ```ruby
44
+ result = Helo::Channels.list(limit: 10)
45
+ ```
46
+
47
+ See the [API docs](#apis) for every method, with a runnable example each.
48
+
49
+ ### Errors
50
+
51
+ Failed requests raise `Helo::APIError`:
52
+
53
+ ```ruby
54
+ begin
55
+ Helo::Channels.list
56
+ rescue Helo::APIError => e
57
+ e.code # HTTP status
58
+ e.detail # human-readable detail from the API
59
+ e.errors # field-level validation errors, when present
60
+ end
61
+ ```
62
+
63
+ ## Webhook signature verification
64
+
65
+ Webhook deliveries are signed with the endpoint's signing key. Verify every delivery before
66
+ acting on it, against the **raw** request body — parsing and re-serializing the JSON changes
67
+ the bytes and the signature will not match.
68
+
69
+ ```ruby
70
+ class WebhooksController < ApplicationController
71
+ skip_before_action :verify_authenticity_token
72
+
73
+ def create
74
+ Helo::WebhookSignatures.verify!(
75
+ request.headers["X-Helo-Webhook-Signature"],
76
+ request.raw_post, # raw body, exactly as received
77
+ ENV.fetch("HELO_WEBHOOK_SIGNING_KEY")
78
+ )
79
+
80
+ event = JSON.parse(request.raw_post)
81
+ # ... handle the event, then acknowledge quickly
82
+ head :no_content
83
+ rescue Helo::WebhookSignatures::Error
84
+ head :bad_request
85
+ end
86
+ end
87
+ ```
88
+
89
+ `verify!` returns `true` when the signature is valid and raises otherwise. Each rejection has
90
+ its own class, so a stale delivery can be treated differently from a genuinely bad one:
91
+
92
+ | Exception | Meaning |
93
+ | --- | --- |
94
+ | `MalformedHeaderError` | The header was not in the expected format |
95
+ | `UnsupportedVersionError` | The delivery used a signing scheme this SDK version cannot verify — upgrade the gem |
96
+ | `TimestampSkewError` | Correctly signed, but too old to accept — possible replay, or clock drift |
97
+ | `SignatureMismatchError` | Wrong signing key, or the body was modified in transit |
98
+
99
+ All four inherit from `Helo::WebhookSignatures::Error`, so `rescue` that one class
100
+ to catch any rejection. If you only want a boolean, use `valid?` instead:
101
+
102
+ ```ruby
103
+ if Helo::WebhookSignatures.valid?(signature_header, raw_body, signing_key)
104
+ # ...
105
+ end
106
+ ```
107
+
108
+ The signature header may carry several versions at once (`t=...,v1=...,v2=...`) while a new
109
+ signing scheme is being rolled out. This SDK verifies against the newest version it supports
110
+ (`SUPPORTED_VERSIONS`) and ignores elements it does not recognize, so a rollout will not break
111
+ this integration.
112
+
113
+ To compute a signature yourself — signing a fixture in tests, for example — use
114
+ `Helo::WebhookSignatures.generate(payload, signing_key, timestamp)`.
115
+
116
+ ## APIs
117
+
118
+ - [Helo::Channels](docs/Channels.md)
119
+ - [Helo::Activity](docs/Activity.md)
120
+ - [Helo::Domains](docs/Domains.md)
121
+ - [Helo::Sending](docs/Sending.md)
122
+ - [Helo::Broadcasts](docs/Broadcasts.md)
123
+ - [Helo::Statistics](docs/Statistics.md)
124
+ - [Helo::Suppressions](docs/Suppressions.md)
125
+ - [Helo::Webhooks](docs/Webhooks.md)
@@ -4,19 +4,19 @@ module Helo
4
4
  class Statistics < API
5
5
  def retrieve_hourly(request_data = {})
6
6
  request = StatisticsRetrieveHourlyRequest.new(request_data)
7
- response = @client.request(:get, "/activity/statistics/hourly", params: request.to_params)
7
+ response = @client.request(:get, "/statistics/hourly", params: request.to_params)
8
8
  StatisticsHourlyResponse.from_hash(response.body)
9
9
  end
10
10
 
11
11
  def retrieve_daily(request_data = {})
12
12
  request = StatisticsRetrieveDailyRequest.new(request_data)
13
- response = @client.request(:get, "/activity/statistics/daily", params: request.to_params)
13
+ response = @client.request(:get, "/statistics/daily", params: request.to_params)
14
14
  StatisticsDailyResponse.from_hash(response.body)
15
15
  end
16
16
 
17
17
  def retrieve_totals(request_data = {})
18
18
  request = StatisticsRetrieveTotalsRequest.new(request_data)
19
- response = @client.request(:get, "/activity/statistics/totals", params: request.to_params)
19
+ response = @client.request(:get, "/statistics/totals", params: request.to_params)
20
20
  StatisticsTotalsResponse.from_hash(response.body)
21
21
  end
22
22
 
@@ -10,15 +10,6 @@ module Helo::Core
10
10
  raise ArgumentError, "base_url is not configured" if configuration.base_url.to_s.empty?
11
11
 
12
12
  @configuration = configuration
13
- @connection = Faraday.new(url: configuration.base_url) do |f|
14
- f.request :json
15
- f.response :json
16
- f.adapter Faraday.default_adapter
17
- f.options.params_encoder = Faraday::FlatParamsEncoder
18
-
19
- # Allow custom Faraday configuration
20
- configuration.faraday_configuration_block&.call(f)
21
- end
22
13
  end
23
14
 
24
15
  def request(method, path, params: {}, body: nil, headers: {})
@@ -43,7 +34,17 @@ module Helo::Core
43
34
 
44
35
  private
45
36
 
46
- attr_reader :connection
37
+ def connection
38
+ @_connection ||= Faraday.new(url: configuration.base_url) do |f|
39
+ f.request :json
40
+ f.response :json
41
+ f.adapter Faraday.default_adapter
42
+ f.options.params_encoder = Faraday::FlatParamsEncoder
43
+
44
+ # Allow custom Faraday configuration
45
+ configuration.faraday_configuration_block&.call(f)
46
+ end
47
+ end
47
48
 
48
49
  def current_api_key
49
50
  token = configuration.api_key
@@ -11,9 +11,13 @@ module Helo
11
11
  COMPLAINED = "complained"
12
12
  UNSUBSCRIBED = "unsubscribed"
13
13
  RESUBSCRIBED = "resubscribed"
14
+ DOMAIN_KEY_VERIFIED = "domain-key-verified"
15
+ DOMAIN_KEY_VERIFICATION_FAILED = "domain-key-verification-failed"
16
+ RETURN_PATH_DOMAIN_VERIFIED = "return-path-domain-verified"
17
+ RETURN_PATH_DOMAIN_VERIFICATION_FAILED = "return-path-domain-verification-failed"
14
18
 
15
19
  def self.all
16
- [ACCEPTED, PROCESSED, BOUNCED, DELIVERED, OPENED, CLICKED, COMPLAINED, UNSUBSCRIBED, RESUBSCRIBED].freeze
20
+ [ACCEPTED, PROCESSED, BOUNCED, DELIVERED, OPENED, CLICKED, COMPLAINED, UNSUBSCRIBED, RESUBSCRIBED, DOMAIN_KEY_VERIFIED, DOMAIN_KEY_VERIFICATION_FAILED, RETURN_PATH_DOMAIN_VERIFIED, RETURN_PATH_DOMAIN_VERIFICATION_FAILED].freeze
17
21
  end
18
22
  end
19
23
  end
@@ -6,12 +6,12 @@ module Helo
6
6
 
7
7
  api_attribute :event_type, :string, key: "eventType"
8
8
  api_attribute :recipients, key: "recipients"
9
- api_attribute :timestamp, :datetime, key: "timestamp"
10
9
  api_attribute :message_id, :string, key: "messageId"
11
10
  api_attribute :channel_id, :string, key: "channelId"
12
11
  api_attribute :mail_type, :string, key: "mailType"
13
12
  api_attribute :subject, :string, key: "subject"
14
13
  api_attribute :tags, key: "tags"
15
14
  api_attribute :metadata, key: "metadata"
15
+ api_attribute :timestamp, :datetime, key: "timestamp"
16
16
  end
17
17
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class BouncedDetails
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :type, :string, key: "type"
8
+ api_attribute :sub_type, :string, key: "subType"
9
+ api_attribute :code, :string, key: "code"
10
+ end
11
+ end
@@ -5,14 +5,14 @@ module Helo
5
5
  include Helo::Core::Model
6
6
 
7
7
  api_attribute :event_type, :string, key: "eventType"
8
+ api_attribute :details, Helo::Core::ModelType.new("Helo::BouncedDetails"), key: "details"
8
9
  api_attribute :recipient, :string, key: "recipient"
9
- api_attribute :details, :string, key: "details"
10
- api_attribute :timestamp, :datetime, key: "timestamp"
11
10
  api_attribute :message_id, :string, key: "messageId"
12
11
  api_attribute :channel_id, :string, key: "channelId"
13
12
  api_attribute :mail_type, :string, key: "mailType"
14
13
  api_attribute :subject, :string, key: "subject"
15
14
  api_attribute :tags, key: "tags"
16
15
  api_attribute :metadata, key: "metadata"
16
+ api_attribute :timestamp, :datetime, key: "timestamp"
17
17
  end
18
18
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class ClickedDetails
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :link, :string, key: "link"
8
+ api_attribute :ip, :string, key: "ip"
9
+ api_attribute :country, :string, key: "country"
10
+ api_attribute :country_code, :string, key: "countryCode"
11
+ api_attribute :client, Helo::Core::ModelType.new("Helo::ClientDetails"), key: "client"
12
+ api_attribute :device, Helo::Core::ModelType.new("Helo::DeviceDetails"), key: "device"
13
+ end
14
+ end
@@ -5,14 +5,14 @@ module Helo
5
5
  include Helo::Core::Model
6
6
 
7
7
  api_attribute :event_type, :string, key: "eventType"
8
+ api_attribute :details, Helo::Core::ModelType.new("Helo::ClickedDetails"), key: "details"
8
9
  api_attribute :recipient, :string, key: "recipient"
9
- api_attribute :details, :string, key: "details"
10
- api_attribute :timestamp, :datetime, key: "timestamp"
11
10
  api_attribute :message_id, :string, key: "messageId"
12
11
  api_attribute :channel_id, :string, key: "channelId"
13
12
  api_attribute :mail_type, :string, key: "mailType"
14
13
  api_attribute :subject, :string, key: "subject"
15
14
  api_attribute :tags, key: "tags"
16
15
  api_attribute :metadata, key: "metadata"
16
+ api_attribute :timestamp, :datetime, key: "timestamp"
17
17
  end
18
18
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class ClientDetails
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :family, :string, key: "family"
8
+ api_attribute :version, :string, key: "version"
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class ComplainedDetails
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :type, :string, key: "type"
8
+ end
9
+ end
@@ -5,14 +5,14 @@ module Helo
5
5
  include Helo::Core::Model
6
6
 
7
7
  api_attribute :event_type, :string, key: "eventType"
8
+ api_attribute :details, Helo::Core::ModelType.new("Helo::ComplainedDetails"), key: "details"
8
9
  api_attribute :recipient, :string, key: "recipient"
9
- api_attribute :details, :string, key: "details"
10
- api_attribute :timestamp, :datetime, key: "timestamp"
11
10
  api_attribute :message_id, :string, key: "messageId"
12
11
  api_attribute :channel_id, :string, key: "channelId"
13
12
  api_attribute :mail_type, :string, key: "mailType"
14
13
  api_attribute :subject, :string, key: "subject"
15
14
  api_attribute :tags, key: "tags"
16
15
  api_attribute :metadata, key: "metadata"
16
+ api_attribute :timestamp, :datetime, key: "timestamp"
17
17
  end
18
18
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class DeliveredDetails
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :response, :string, key: "response"
8
+ end
9
+ end
@@ -5,14 +5,14 @@ module Helo
5
5
  include Helo::Core::Model
6
6
 
7
7
  api_attribute :event_type, :string, key: "eventType"
8
+ api_attribute :details, Helo::Core::ModelType.new("Helo::DeliveredDetails"), key: "details"
8
9
  api_attribute :recipient, :string, key: "recipient"
9
- api_attribute :details, :string, key: "details"
10
- api_attribute :timestamp, :datetime, key: "timestamp"
11
10
  api_attribute :message_id, :string, key: "messageId"
12
11
  api_attribute :channel_id, :string, key: "channelId"
13
12
  api_attribute :mail_type, :string, key: "mailType"
14
13
  api_attribute :subject, :string, key: "subject"
15
14
  api_attribute :tags, key: "tags"
16
15
  api_attribute :metadata, key: "metadata"
16
+ api_attribute :timestamp, :datetime, key: "timestamp"
17
17
  end
18
18
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class DeliveryWebhookPayloadCommon
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :message_id, :string, key: "messageId"
8
+ api_attribute :channel_id, :string, key: "channelId"
9
+ api_attribute :mail_type, :string, key: "mailType"
10
+ api_attribute :subject, :string, key: "subject"
11
+ api_attribute :tags, key: "tags"
12
+ api_attribute :metadata, key: "metadata"
13
+ api_attribute :timestamp, :datetime, key: "timestamp"
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class DeviceDetails
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :brand, :string, key: "brand"
8
+ api_attribute :family, :string, key: "family"
9
+ api_attribute :model, :string, key: "model"
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class DomainKeyVerificationFailedPayload
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :event_type, :string, key: "eventType"
8
+ api_attribute :domain_id, :string, key: "domainId"
9
+ api_attribute :domain_name, :string, key: "domainName"
10
+ api_attribute :dns_record_host, :string, key: "dnsRecordHost"
11
+ api_attribute :timestamp, :datetime, key: "timestamp"
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class DomainKeyVerifiedPayload
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :event_type, :string, key: "eventType"
8
+ api_attribute :domain_id, :string, key: "domainId"
9
+ api_attribute :domain_name, :string, key: "domainName"
10
+ api_attribute :dns_record_host, :string, key: "dnsRecordHost"
11
+ api_attribute :timestamp, :datetime, key: "timestamp"
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class DomainPayloadCommon
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :domain_id, :string, key: "domainId"
8
+ api_attribute :domain_name, :string, key: "domainName"
9
+ api_attribute :dns_record_host, :string, key: "dnsRecordHost"
10
+ api_attribute :timestamp, :datetime, key: "timestamp"
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class EngagementDetails
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :ip, :string, key: "ip"
8
+ api_attribute :country, :string, key: "country"
9
+ api_attribute :country_code, :string, key: "countryCode"
10
+ api_attribute :client, Helo::Core::ModelType.new("Helo::ClientDetails"), key: "client"
11
+ api_attribute :device, Helo::Core::ModelType.new("Helo::DeviceDetails"), key: "device"
12
+ end
13
+ end
@@ -13,5 +13,7 @@ module Helo
13
13
  api_attribute :status, :string, key: "status"
14
14
  api_attribute :subject, :string, key: "subject"
15
15
  api_attribute :recipients, key: "recipients"
16
+ api_attribute :tags, key: "tags"
17
+ api_attribute :statistics, Helo::Core::ModelType.new("Helo::MessageStatistics"), key: "statistics"
16
18
  end
17
19
  end
@@ -26,5 +26,6 @@ module Helo
26
26
  api_attribute :attachments, Helo::Core::ArrayType.new("Helo::MessageDetailsResponseAttachment"), key: "attachments"
27
27
  api_attribute :tracking, Helo::Core::ModelType.new("Helo::MessageDetailsResponseTracking"), key: "tracking"
28
28
  api_attribute :events, Helo::Core::ArrayType.new("Helo::MessageDetailsResponseEvent"), key: "events"
29
+ api_attribute :statistics, Helo::Core::ModelType.new("Helo::MessageStatistics"), key: "statistics"
29
30
  end
30
31
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class MessageStatistics
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :delivered, :integer, key: "delivered"
8
+ api_attribute :bounced, :integer, key: "bounced"
9
+ api_attribute :opened, :integer, key: "opened"
10
+ api_attribute :clicked, :integer, key: "clicked"
11
+ api_attribute :complained, :integer, key: "complained"
12
+ api_attribute :unsubscribed, :integer, key: "unsubscribed"
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class OpenedDetails
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :ip, :string, key: "ip"
8
+ api_attribute :country, :string, key: "country"
9
+ api_attribute :country_code, :string, key: "countryCode"
10
+ api_attribute :client, Helo::Core::ModelType.new("Helo::ClientDetails"), key: "client"
11
+ api_attribute :device, Helo::Core::ModelType.new("Helo::DeviceDetails"), key: "device"
12
+ end
13
+ end
@@ -5,14 +5,14 @@ module Helo
5
5
  include Helo::Core::Model
6
6
 
7
7
  api_attribute :event_type, :string, key: "eventType"
8
+ api_attribute :details, Helo::Core::ModelType.new("Helo::OpenedDetails"), key: "details"
8
9
  api_attribute :recipient, :string, key: "recipient"
9
- api_attribute :details, :string, key: "details"
10
- api_attribute :timestamp, :datetime, key: "timestamp"
11
10
  api_attribute :message_id, :string, key: "messageId"
12
11
  api_attribute :channel_id, :string, key: "channelId"
13
12
  api_attribute :mail_type, :string, key: "mailType"
14
13
  api_attribute :subject, :string, key: "subject"
15
14
  api_attribute :tags, key: "tags"
16
15
  api_attribute :metadata, key: "metadata"
16
+ api_attribute :timestamp, :datetime, key: "timestamp"
17
17
  end
18
18
  end
@@ -6,12 +6,12 @@ module Helo
6
6
 
7
7
  api_attribute :event_type, :string, key: "eventType"
8
8
  api_attribute :recipients, key: "recipients"
9
- api_attribute :timestamp, :datetime, key: "timestamp"
10
9
  api_attribute :message_id, :string, key: "messageId"
11
10
  api_attribute :channel_id, :string, key: "channelId"
12
11
  api_attribute :mail_type, :string, key: "mailType"
13
12
  api_attribute :subject, :string, key: "subject"
14
13
  api_attribute :tags, key: "tags"
15
14
  api_attribute :metadata, key: "metadata"
15
+ api_attribute :timestamp, :datetime, key: "timestamp"
16
16
  end
17
17
  end
@@ -5,6 +5,5 @@ module Helo
5
5
  include Helo::Core::Model
6
6
 
7
7
  api_attribute :recipient, :string, key: "recipient"
8
- api_attribute :details, :string, key: "details"
9
8
  end
10
9
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class ResubscribedDetails
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :ip, :string, key: "ip"
8
+ end
9
+ end
@@ -5,14 +5,14 @@ module Helo
5
5
  include Helo::Core::Model
6
6
 
7
7
  api_attribute :event_type, :string, key: "eventType"
8
+ api_attribute :details, Helo::Core::ModelType.new("Helo::ResubscribedDetails"), key: "details"
8
9
  api_attribute :recipient, :string, key: "recipient"
9
- api_attribute :details, :string, key: "details"
10
- api_attribute :timestamp, :datetime, key: "timestamp"
11
10
  api_attribute :message_id, :string, key: "messageId"
12
11
  api_attribute :channel_id, :string, key: "channelId"
13
12
  api_attribute :mail_type, :string, key: "mailType"
14
13
  api_attribute :subject, :string, key: "subject"
15
14
  api_attribute :tags, key: "tags"
16
15
  api_attribute :metadata, key: "metadata"
16
+ api_attribute :timestamp, :datetime, key: "timestamp"
17
17
  end
18
18
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class ReturnPathDomainVerificationFailedPayload
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :event_type, :string, key: "eventType"
8
+ api_attribute :domain_id, :string, key: "domainId"
9
+ api_attribute :domain_name, :string, key: "domainName"
10
+ api_attribute :dns_record_host, :string, key: "dnsRecordHost"
11
+ api_attribute :timestamp, :datetime, key: "timestamp"
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class ReturnPathDomainVerifiedPayload
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :event_type, :string, key: "eventType"
8
+ api_attribute :domain_id, :string, key: "domainId"
9
+ api_attribute :domain_name, :string, key: "domainName"
10
+ api_attribute :dns_record_host, :string, key: "dnsRecordHost"
11
+ api_attribute :timestamp, :datetime, key: "timestamp"
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Helo
4
+ class UnsubscribedDetails
5
+ include Helo::Core::Model
6
+
7
+ api_attribute :ip, :string, key: "ip"
8
+ end
9
+ end
@@ -5,14 +5,14 @@ module Helo
5
5
  include Helo::Core::Model
6
6
 
7
7
  api_attribute :event_type, :string, key: "eventType"
8
+ api_attribute :details, Helo::Core::ModelType.new("Helo::UnsubscribedDetails"), key: "details"
8
9
  api_attribute :recipient, :string, key: "recipient"
9
- api_attribute :details, :string, key: "details"
10
- api_attribute :timestamp, :datetime, key: "timestamp"
11
10
  api_attribute :message_id, :string, key: "messageId"
12
11
  api_attribute :channel_id, :string, key: "channelId"
13
12
  api_attribute :mail_type, :string, key: "mailType"
14
13
  api_attribute :subject, :string, key: "subject"
15
14
  api_attribute :tags, key: "tags"
16
15
  api_attribute :metadata, key: "metadata"
16
+ api_attribute :timestamp, :datetime, key: "timestamp"
17
17
  end
18
18
  end
@@ -5,11 +5,5 @@ module Helo
5
5
  include Helo::Core::Model
6
6
 
7
7
  api_attribute :timestamp, :datetime, key: "timestamp"
8
- api_attribute :message_id, :string, key: "messageId"
9
- api_attribute :channel_id, :string, key: "channelId"
10
- api_attribute :mail_type, :string, key: "mailType"
11
- api_attribute :subject, :string, key: "subject"
12
- api_attribute :tags, key: "tags"
13
- api_attribute :metadata, key: "metadata"
14
8
  end
15
9
  end
data/lib/helo/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Helo
4
- VERSION = "1.0.0-beta.2"
4
+ VERSION = "1.0.0-beta.7"
5
5
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Helo
6
+ # Helpers for verifying the signature on an incoming webhook request.
7
+ module WebhookSignatures
8
+ # Base class for every rejection reason, so callers that do not care why a
9
+ # webhook was rejected can rescue this one class.
10
+ class Error < StandardError; end
11
+
12
+ # The header was not in the documented
13
+ # t={timestamp},v{version}={signature} form.
14
+ class MalformedHeaderError < Error; end
15
+
16
+ # The header carried only signing schemes this SDK does not know how to
17
+ # verify. Upgrading the SDK is the fix; see SUPPORTED_VERSIONS.
18
+ class UnsupportedVersionError < Error; end
19
+
20
+ # The signature was correctly formed but its timestamp is too far from the
21
+ # current time, so it may be a replay.
22
+ class TimestampSkewError < Error; end
23
+
24
+ # The signature did not match the body, either because the body was tampered
25
+ # with or the signing key is wrong.
26
+ class SignatureMismatchError < Error; end
27
+
28
+ # Signing schemes this SDK can verify. The signature header may carry several
29
+ # versions at once (t=...,v1=...,v2=...) so that a new scheme can be rolled
30
+ # out while receivers upgrade; verification uses the newest version present
31
+ # that appears in this list, and ignores the rest.
32
+ SUPPORTED_VERSIONS = [1].freeze
33
+
34
+ TIMESTAMP_VALUE_REGEX = /\A\d+\z/
35
+ SIGNATURE_KEY_REGEX = /\Av(\d+)\z/
36
+ HEX_SIGNATURE_REGEX = /\A[a-f0-9]+\z/
37
+ MAX_TIMESTAMP_SKEW_SECONDS = 300 # 5 minutes
38
+
39
+ class << self
40
+ # Verify a webhook signature header against the raw request body.
41
+ #
42
+ # @param signature_header [String] value of the signature header sent with the webhook
43
+ # @param request_body [String] raw (unparsed) request body
44
+ # @param signing_key [String] signing key for the webhook endpoint
45
+ # @return [true] when the signature is valid
46
+ # @raise [Error] a subclass describing why the signature was rejected
47
+ def verify!(signature_header, request_body, signing_key)
48
+ timestamp, signatures = parse_header(signature_header)
49
+
50
+ version = newest_supported_version(signatures)
51
+ unless version
52
+ raise UnsupportedVersionError,
53
+ "Unsupported webhook signature version: header carries only " \
54
+ "#{signatures.keys.sort.map { |v| "v#{v}" }.join(', ')}"
55
+ end
56
+
57
+ skew = (Time.now.to_i - timestamp.to_i).abs
58
+ if skew > MAX_TIMESTAMP_SKEW_SECONDS
59
+ raise TimestampSkewError,
60
+ "Webhook signature timestamp outside tolerance: off by #{skew}s, " \
61
+ "tolerance is #{MAX_TIMESTAMP_SKEW_SECONDS}s"
62
+ end
63
+
64
+ computed = signature_for_version(version, request_body, signing_key, timestamp)
65
+ unless signatures.fetch(version).any? { |signature| secure_compare(computed, signature) }
66
+ raise SignatureMismatchError, "Webhook signature mismatch"
67
+ end
68
+
69
+ true
70
+ end
71
+
72
+ # Verify a webhook signature header, returning false instead of raising.
73
+ #
74
+ # @see #verify!
75
+ # @return [Boolean]
76
+ def valid?(signature_header, request_body, signing_key)
77
+ verify!(signature_header, request_body, signing_key)
78
+ rescue Error
79
+ false
80
+ end
81
+
82
+ # Compute the hex-encoded HMAC-SHA256 signature for a webhook payload,
83
+ # using the v1 signing scheme.
84
+ #
85
+ # @param payload [String] raw (unparsed) request body
86
+ # @param key [String] signing key for the webhook endpoint
87
+ # @param timestamp [String] unix timestamp in seconds, as sent in the signature header
88
+ # @return [String]
89
+ def generate(payload, key, timestamp)
90
+ OpenSSL::HMAC.hexdigest("SHA256", key, "#{timestamp}.#{payload}")
91
+ end
92
+
93
+ private
94
+
95
+ # Compute the signature for one signing scheme. This is the single place a
96
+ # new scheme needs to be added.
97
+ def signature_for_version(version, payload, key, timestamp)
98
+ case version
99
+ when 1 then generate(payload, key, timestamp)
100
+ end
101
+ end
102
+
103
+ # Split the header into its timestamp and its signatures keyed by version.
104
+ # Elements that are not recognized are ignored, so that a sender adding new
105
+ # elements does not break verification here.
106
+ def parse_header(signature_header)
107
+ timestamp = nil
108
+ signatures = Hash.new { |hash, key| hash[key] = [] }
109
+
110
+ signature_header.to_s.split(",").each do |element|
111
+ key, value = element.strip.split("=", 2)
112
+ next if value.nil?
113
+
114
+ if key == "t"
115
+ raise MalformedHeaderError, "Malformed webhook signature header" unless TIMESTAMP_VALUE_REGEX.match?(value)
116
+
117
+ timestamp = value
118
+ next
119
+ end
120
+
121
+ match = SIGNATURE_KEY_REGEX.match(key)
122
+ next unless match
123
+
124
+ version = match[1].to_i
125
+
126
+ # Only versions this SDK verifies have a signature format it can insist
127
+ # on; anything else is recorded but left unchecked.
128
+ if SUPPORTED_VERSIONS.include?(version) && !HEX_SIGNATURE_REGEX.match?(value)
129
+ raise MalformedHeaderError, "Malformed webhook signature header"
130
+ end
131
+
132
+ signatures[version] << value
133
+ end
134
+
135
+ if timestamp.nil? || signatures.empty?
136
+ raise MalformedHeaderError, "Malformed webhook signature header"
137
+ end
138
+
139
+ [timestamp, signatures]
140
+ end
141
+
142
+ # Pick the highest version present that this SDK can verify, so that once a
143
+ # sender emits a newer scheme the older one stops being honored here.
144
+ def newest_supported_version(signatures)
145
+ (signatures.keys & SUPPORTED_VERSIONS).max
146
+ end
147
+
148
+ def secure_compare(computed, given)
149
+ return false unless computed.bytesize == given.bytesize
150
+
151
+ OpenSSL.fixed_length_secure_compare(computed, given)
152
+ end
153
+ end
154
+ end
155
+ end
data/lib/helo.rb CHANGED
@@ -17,6 +17,7 @@ require_relative "helo/version"
17
17
  require_relative "helo/api_error"
18
18
  require_relative "helo/configuration"
19
19
  require_relative "helo/client"
20
+ require_relative "helo/webhook_signatures"
20
21
 
21
22
 
22
23
  module Helo
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: helo-email-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.pre.beta.2
4
+ version: 1.0.0.pre.beta.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Helo Email
@@ -86,6 +86,7 @@ executables: []
86
86
  extensions: []
87
87
  extra_rdoc_files: []
88
88
  files:
89
+ - README.md
89
90
  - lib/helo-email-sdk.rb
90
91
  - lib/helo.rb
91
92
  - lib/helo/api_error.rb
@@ -121,6 +122,7 @@ files:
121
122
  - lib/helo/models/activity_list_messages_request.rb
122
123
  - lib/helo/models/activity_mail_address.rb
123
124
  - lib/helo/models/attachment.rb
125
+ - lib/helo/models/bounced_details.rb
124
126
  - lib/helo/models/bounced_webhook_payload.rb
125
127
  - lib/helo/models/broadcast_content.rb
126
128
  - lib/helo/models/broadcast_content_attachment.rb
@@ -137,7 +139,10 @@ files:
137
139
  - lib/helo/models/channel_details_response.rb
138
140
  - lib/helo/models/channel_tracking.rb
139
141
  - lib/helo/models/channels_list_request.rb
142
+ - lib/helo/models/clicked_details.rb
140
143
  - lib/helo/models/clicked_webhook_payload.rb
144
+ - lib/helo/models/client_details.rb
145
+ - lib/helo/models/complained_details.rb
141
146
  - lib/helo/models/complained_webhook_payload.rb
142
147
  - lib/helo/models/create_channel_request.rb
143
148
  - lib/helo/models/create_channel_tracking.rb
@@ -145,14 +150,21 @@ files:
145
150
  - lib/helo/models/create_suppressions_request.rb
146
151
  - lib/helo/models/create_suppressions_response.rb
147
152
  - lib/helo/models/create_webhook_request.rb
153
+ - lib/helo/models/delivered_details.rb
148
154
  - lib/helo/models/delivered_webhook_payload.rb
149
155
  - lib/helo/models/delivery_stats.rb
156
+ - lib/helo/models/delivery_webhook_payload_common.rb
157
+ - lib/helo/models/device_details.rb
150
158
  - lib/helo/models/dns_record_response.rb
151
159
  - lib/helo/models/dns_records_response.rb
152
160
  - lib/helo/models/domain_channel_response.rb
161
+ - lib/helo/models/domain_key_verification_failed_payload.rb
162
+ - lib/helo/models/domain_key_verified_payload.rb
163
+ - lib/helo/models/domain_payload_common.rb
153
164
  - lib/helo/models/domain_response.rb
154
165
  - lib/helo/models/domain_with_dns_response.rb
155
166
  - lib/helo/models/domains_list_request.rb
167
+ - lib/helo/models/engagement_details.rb
156
168
  - lib/helo/models/error_response.rb
157
169
  - lib/helo/models/mail_address.rb
158
170
  - lib/helo/models/message.rb
@@ -160,6 +172,8 @@ files:
160
172
  - lib/helo/models/message_details_response_attachment.rb
161
173
  - lib/helo/models/message_details_response_event.rb
162
174
  - lib/helo/models/message_details_response_tracking.rb
175
+ - lib/helo/models/message_statistics.rb
176
+ - lib/helo/models/opened_details.rb
163
177
  - lib/helo/models/opened_webhook_payload.rb
164
178
  - lib/helo/models/paginated_events_response.rb
165
179
  - lib/helo/models/paginated_messages_response.rb
@@ -176,7 +190,10 @@ files:
176
190
  - lib/helo/models/remove_suppression_result.rb
177
191
  - lib/helo/models/remove_suppressions_request.rb
178
192
  - lib/helo/models/remove_suppressions_response.rb
193
+ - lib/helo/models/resubscribed_details.rb
179
194
  - lib/helo/models/resubscribed_webhook_payload.rb
195
+ - lib/helo/models/return_path_domain_verification_failed_payload.rb
196
+ - lib/helo/models/return_path_domain_verified_payload.rb
180
197
  - lib/helo/models/send_broadcast_request.rb
181
198
  - lib/helo/models/send_broadcast_request_message.rb
182
199
  - lib/helo/models/send_broadcast_request_template.rb
@@ -201,6 +218,7 @@ files:
201
218
  - lib/helo/models/suppression_response.rb
202
219
  - lib/helo/models/suppression_result.rb
203
220
  - lib/helo/models/suppressions_list_request.rb
221
+ - lib/helo/models/unsubscribed_details.rb
204
222
  - lib/helo/models/unsubscribed_webhook_payload.rb
205
223
  - lib/helo/models/update_channel_request.rb
206
224
  - lib/helo/models/update_channel_tracking.rb
@@ -213,6 +231,7 @@ files:
213
231
  - lib/helo/models/webhook_response.rb
214
232
  - lib/helo/models/webhooks_list_request.rb
215
233
  - lib/helo/version.rb
234
+ - lib/helo/webhook_signatures.rb
216
235
  homepage: https://rubygems.org/gems/helo-email-sdk
217
236
  licenses:
218
237
  - MIT