emailfuse 0.1.4 → 0.2.0

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.
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+
6
+ module EmailFuse
7
+ # The Webhooks module provides methods for managing webhooks via the EmailFuse API.
8
+ # Webhooks allow you to receive real-time notifications about email events.
9
+ #
10
+ # Default tolerance for timestamp validation (5 minutes)
11
+ WEBHOOK_TOLERANCE_SECONDS = 300
12
+ #
13
+ # @example Create a webhook
14
+ # EmailFuse::Webhooks.create(
15
+ # endpoint: "https://webhook.example.com/handler",
16
+ # events: ["email.sent", "email.delivered", "email.bounced"]
17
+ # )
18
+ #
19
+ # @example List all webhooks
20
+ # EmailFuse::Webhooks.list
21
+ #
22
+ # @example Retrieve a specific webhook
23
+ # EmailFuse::Webhooks.get("4dd369bc-aa82-4ff3-97de-514ae3000ee0")
24
+ #
25
+ # @example Update a webhook
26
+ # EmailFuse::Webhooks.update(
27
+ # webhook_id: "430eed87-632a-4ea6-90db-0aace67ec228",
28
+ # endpoint: "https://new-webhook.example.com/handler",
29
+ # events: ["email.sent", "email.delivered"],
30
+ # status: "enabled"
31
+ # )
32
+ #
33
+ # @example Delete a webhook
34
+ # EmailFuse::Webhooks.remove("4dd369bc-aa82-4ff3-97de-514ae3000ee0")
35
+ module Webhooks
36
+ class << self
37
+ # Create a new webhook to receive real-time notifications about email events
38
+ #
39
+ # @param params [Hash] The webhook parameters
40
+ # @option params [String] :endpoint The URL where webhook events will be sent (required)
41
+ # @option params [Array<String>] :events Array of event types to subscribe to (required)
42
+ #
43
+ # @return [Hash] The webhook object containing id, object type, and signing_secret
44
+ #
45
+ # @example
46
+ # EmailFuse::Webhooks.create(
47
+ # endpoint: "https://webhook.example.com/handler",
48
+ # events: ["email.sent", "email.delivered", "email.bounced"]
49
+ # )
50
+ def create(params = {})
51
+ path = "webhooks"
52
+ EmailFuse::Request.new(path, params, "post").perform
53
+ end
54
+
55
+ # Retrieve a list of webhooks for the authenticated user
56
+ #
57
+ # @param params [Hash] The pagination parameters
58
+ # @option params [Integer] :limit Number of webhooks to retrieve (max: 100, min: 1)
59
+ # @option params [String] :after The ID after which to retrieve more webhooks (for pagination)
60
+ # @option params [String] :before The ID before which to retrieve more webhooks (for pagination)
61
+ #
62
+ # @return [Hash] A paginated list of webhook objects
63
+ #
64
+ # @example
65
+ # EmailFuse::Webhooks.list
66
+ #
67
+ # @example With pagination
68
+ # EmailFuse::Webhooks.list(limit: 20, after: "4dd369bc-aa82-4ff3-97de-514ae3000ee0")
69
+ def list(params = {})
70
+ path = EmailFuse::PaginationHelper.build_paginated_path("webhooks", params)
71
+ EmailFuse::Request.new(path, {}, "get").perform
72
+ end
73
+
74
+ # Retrieve a single webhook for the authenticated user
75
+ #
76
+ # @param webhook_id [String] The webhook ID
77
+ #
78
+ # @return [Hash] The webhook object with full details
79
+ #
80
+ # @example
81
+ # EmailFuse::Webhooks.get("4dd369bc-aa82-4ff3-97de-514ae3000ee0")
82
+ def get(webhook_id = "")
83
+ path = "webhooks/#{webhook_id}"
84
+ EmailFuse::Request.new(path, {}, "get").perform
85
+ end
86
+
87
+ # Update an existing webhook configuration
88
+ #
89
+ # @param params [Hash] The webhook update parameters
90
+ # @option params [String] :webhook_id The webhook ID (required)
91
+ # @option params [String] :endpoint The URL where webhook events will be sent
92
+ # @option params [Array<String>] :events Array of event types to subscribe to
93
+ # @option params [String] :status The webhook status ("enabled" or "disabled")
94
+ #
95
+ # @return [Hash] The updated webhook object
96
+ #
97
+ # @example
98
+ # EmailFuse::Webhooks.update(
99
+ # webhook_id: "430eed87-632a-4ea6-90db-0aace67ec228",
100
+ # endpoint: "https://new-webhook.example.com/handler",
101
+ # events: ["email.sent", "email.delivered"],
102
+ # status: "enabled"
103
+ # )
104
+ def update(params = {})
105
+ webhook_id = params.delete(:webhook_id)
106
+ path = "webhooks/#{webhook_id}"
107
+ EmailFuse::Request.new(path, params, "patch").perform
108
+ end
109
+
110
+ # Remove an existing webhook
111
+ #
112
+ # @param webhook_id [String] The webhook ID
113
+ #
114
+ # @return [Hash] Confirmation object with id, object type, and deleted status
115
+ #
116
+ # @example
117
+ # EmailFuse::Webhooks.remove("4dd369bc-aa82-4ff3-97de-514ae3000ee0")
118
+ def remove(webhook_id = "")
119
+ path = "webhooks/#{webhook_id}"
120
+ EmailFuse::Request.new(path, {}, "delete").perform
121
+ end
122
+
123
+ # Verify a webhook payload using HMAC-SHA256 signature validation
124
+ # This validates that the webhook request came from EmailFuse and hasn't been tampered with
125
+ #
126
+ # @param params [Hash] The webhook verification parameters
127
+ # @option params [String] :payload The raw webhook payload body (required)
128
+ # @option params [Hash] :headers The webhook headers containing svix-id, svix-timestamp,
129
+ # and svix-signature (required)
130
+ # @option params [String] :webhook_secret The signing secret from webhook creation (required)
131
+ #
132
+ # @return [Boolean] true if verification succeeds
133
+ # @raise [StandardError] If verification fails or required parameters are missing
134
+ #
135
+ # @example
136
+ # EmailFuse::Webhooks.verify(
137
+ # payload: request.body.read,
138
+ # headers: {
139
+ # svix_id: "id_1234567890abcdefghijklmnopqrstuvwxyz",
140
+ # svix_timestamp: "1616161616",
141
+ # svix_signature: "v1,signature_here"
142
+ # },
143
+ # webhook_secret: "whsec_1234567890abcdez"
144
+ # )
145
+ def verify(params = {})
146
+ payload = params[:payload]
147
+ headers = params[:headers] || {}
148
+ webhook_secret = params[:webhook_secret]
149
+
150
+ validate_required_params(payload, headers, webhook_secret)
151
+ validate_timestamp(headers[:svix_timestamp])
152
+
153
+ signed_content = "#{headers[:svix_id]}.#{headers[:svix_timestamp]}.#{payload}"
154
+ decoded_secret = decode_secret(webhook_secret)
155
+ expected_signature = generate_signature(decoded_secret, signed_content)
156
+
157
+ verify_signature(headers[:svix_signature], expected_signature)
158
+ end
159
+
160
+ private
161
+
162
+ # Validate required parameters
163
+ def validate_required_params(payload, headers, webhook_secret)
164
+ validate_payload(payload)
165
+ validate_webhook_secret(webhook_secret)
166
+ validate_headers(headers)
167
+ end
168
+
169
+ # Validate payload is present
170
+ def validate_payload(payload)
171
+ raise "payload cannot be empty" if payload.nil? || payload.empty?
172
+ end
173
+
174
+ # Validate webhook secret is present
175
+ def validate_webhook_secret(webhook_secret)
176
+ raise "webhook_secret cannot be empty" if webhook_secret.nil? || webhook_secret.empty?
177
+ end
178
+
179
+ # Validate required headers are present
180
+ def validate_headers(headers)
181
+ raise "svix-id header is required" if headers[:svix_id].nil? || headers[:svix_id].empty?
182
+ raise "svix-timestamp header is required" if headers[:svix_timestamp].nil? || headers[:svix_timestamp].empty?
183
+ raise "svix-signature header is required" if headers[:svix_signature].nil? || headers[:svix_signature].empty?
184
+ end
185
+
186
+ # Validate timestamp to prevent replay attacks
187
+ def validate_timestamp(timestamp_header)
188
+ timestamp = timestamp_header.to_i
189
+ now = Time.now.to_i
190
+ diff = now - timestamp
191
+
192
+ return unless diff > WEBHOOK_TOLERANCE_SECONDS || diff < -WEBHOOK_TOLERANCE_SECONDS
193
+
194
+ raise "Timestamp outside tolerance window: difference of #{diff} seconds"
195
+ end
196
+
197
+ # Decode the signing secret (strip whsec_ prefix and base64 decode)
198
+ def decode_secret(webhook_secret)
199
+ secret = webhook_secret.sub(/^whsec_/, "")
200
+ Base64.strict_decode64(secret)
201
+ rescue ArgumentError => e
202
+ raise "Failed to decode webhook secret: #{e.message}"
203
+ end
204
+
205
+ # Verify signature using constant-time comparison
206
+ def verify_signature(signature_header, expected_signature)
207
+ signatures = signature_header.split(" ")
208
+ signatures.each do |sig|
209
+ parts = sig.split(",", 2)
210
+ next if parts.length != 2
211
+
212
+ received_signature = parts[1]
213
+ return true if secure_compare(expected_signature, received_signature)
214
+ end
215
+
216
+ raise "No matching signature found"
217
+ end
218
+
219
+ # Generate HMAC-SHA256 signature and return it as base64
220
+ def generate_signature(secret, content)
221
+ digest = OpenSSL::HMAC.digest("sha256", secret, content)
222
+ Base64.strict_encode64(digest)
223
+ end
224
+
225
+ # Constant-time string comparison to prevent timing attacks
226
+ #
227
+ # Note: We implement this manually for Ruby 2.7 compatibility.
228
+ # Ruby 3.0+ could use OpenSSL.fixed_length_secure_compare instead.
229
+ def secure_compare(str_a, str_b)
230
+ return false if str_a.nil? || str_b.nil? || str_a.bytesize != str_b.bytesize
231
+
232
+ bytes_a = str_a.unpack("C*")
233
+ result = 0
234
+ str_b.each_byte.with_index { |byte_b, i| result |= byte_b ^ bytes_a[i] }
235
+ result.zero?
236
+ end
237
+ end
238
+ end
239
+ end
data/lib/email_fuse.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Version
4
+ require "email_fuse/version"
5
+
6
+ # Utils
7
+ require "httparty"
8
+ require "json"
9
+ require "cgi"
10
+ require "email_fuse/errors"
11
+ require "email_fuse/response"
12
+ require "email_fuse/client"
13
+ require "email_fuse/request"
14
+ require "email_fuse/pagination_helper"
15
+
16
+ # API Operations
17
+ require "email_fuse/batch"
18
+ require "email_fuse/emails"
19
+ require "email_fuse/emails/receiving"
20
+ require "email_fuse/emails/attachments"
21
+ require "email_fuse/emails/receiving/attachments"
22
+ require "email_fuse/webhooks"
23
+
24
+ # Rails
25
+ require "email_fuse/railtie" if defined?(Rails) && defined?(ActionMailer)
26
+
27
+ # Main EmailFuse module
28
+ module EmailFuse
29
+ class << self
30
+ attr_accessor :api_key
31
+ attr_writer :base_url
32
+
33
+ def configure
34
+ yield self if block_given?
35
+ true
36
+ end
37
+ alias config configure
38
+
39
+ def base_url
40
+ @base_url ||= "https://api.emailfuse.net"
41
+ end
42
+ end
43
+ end
data/lib/emailfuse.rb CHANGED
@@ -1,32 +1,3 @@
1
- require "action_mailer"
1
+ # frozen_string_literal: true
2
2
 
3
- require "faraday"
4
- require "faraday/multipart"
5
-
6
- require "emailfuse/version"
7
-
8
- module Emailfuse
9
- autoload :Configuration, "emailfuse/configuration"
10
- autoload :Client, "emailfuse/client"
11
- autoload :Collection, "emailfuse/collection"
12
- autoload :Error, "emailfuse/error"
13
- autoload :Object, "emailfuse/object"
14
-
15
- autoload :Deliverer, "emailfuse/deliverer"
16
-
17
- class << self
18
- attr_writer :config
19
- end
20
-
21
- def self.configure
22
- yield(config) if block_given?
23
- end
24
-
25
- def self.config
26
- @config ||= Configuration.new
27
- end
28
-
29
- autoload :Email, "emailfuse/models/email"
30
- end
31
-
32
- require "emailfuse/railtie" if defined?(Rails::Railtie)
3
+ require "email_fuse"
metadata CHANGED
@@ -1,81 +1,68 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: emailfuse
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dean Perry
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-09-16 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
- name: actionmailer
13
+ name: httparty
15
14
  requirement: !ruby/object:Gem::Requirement
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '0'
18
+ version: 0.21.0
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '0'
25
+ version: 0.21.0
27
26
  - !ruby/object:Gem::Dependency
28
- name: faraday
27
+ name: rails
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
- - - "~>"
32
- - !ruby/object:Gem::Version
33
- version: '2.0'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - "~>"
39
- - !ruby/object:Gem::Version
40
- version: '2.0'
41
- - !ruby/object:Gem::Dependency
42
- name: faraday-multipart
43
- requirement: !ruby/object:Gem::Requirement
44
- requirements:
45
- - - "~>"
30
+ - - ">="
46
31
  - !ruby/object:Gem::Version
47
- version: '1.0'
48
- type: :runtime
32
+ version: '0'
33
+ type: :development
49
34
  prerelease: false
50
35
  version_requirements: !ruby/object:Gem::Requirement
51
36
  requirements:
52
- - - "~>"
37
+ - - ">="
53
38
  - !ruby/object:Gem::Version
54
- version: '1.0'
55
- description:
56
- email:
57
- - dean@voupe.com
39
+ version: '0'
40
+ email: dean@voupe.com
58
41
  executables: []
59
42
  extensions: []
60
43
  extra_rdoc_files: []
61
44
  files:
45
+ - CHANGELOG.md
62
46
  - README.md
63
- - Rakefile
47
+ - lib/email_fuse.rb
48
+ - lib/email_fuse/batch.rb
49
+ - lib/email_fuse/client.rb
50
+ - lib/email_fuse/emails.rb
51
+ - lib/email_fuse/emails/attachments.rb
52
+ - lib/email_fuse/emails/receiving.rb
53
+ - lib/email_fuse/emails/receiving/attachments.rb
54
+ - lib/email_fuse/errors.rb
55
+ - lib/email_fuse/mailer.rb
56
+ - lib/email_fuse/pagination_helper.rb
57
+ - lib/email_fuse/railtie.rb
58
+ - lib/email_fuse/request.rb
59
+ - lib/email_fuse/response.rb
60
+ - lib/email_fuse/version.rb
61
+ - lib/email_fuse/webhooks.rb
64
62
  - lib/emailfuse.rb
65
- - lib/emailfuse/client.rb
66
- - lib/emailfuse/collection.rb
67
- - lib/emailfuse/configuration.rb
68
- - lib/emailfuse/deliverer.rb
69
- - lib/emailfuse/error.rb
70
- - lib/emailfuse/models/email.rb
71
- - lib/emailfuse/object.rb
72
- - lib/emailfuse/railtie.rb
73
- - lib/emailfuse/version.rb
74
- homepage:
75
63
  licenses:
76
64
  - MIT
77
65
  metadata: {}
78
- post_install_message:
79
66
  rdoc_options: []
80
67
  require_paths:
81
68
  - lib
@@ -83,15 +70,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
83
70
  requirements:
84
71
  - - ">="
85
72
  - !ruby/object:Gem::Version
86
- version: '0'
73
+ version: '2.6'
87
74
  required_rubygems_version: !ruby/object:Gem::Requirement
88
75
  requirements:
89
76
  - - ">="
90
77
  - !ruby/object:Gem::Version
91
78
  version: '0'
92
79
  requirements: []
93
- rubygems_version: 3.5.11
94
- signing_key:
80
+ rubygems_version: 4.0.3
95
81
  specification_version: 4
96
- summary: Ruby library for the Emailfuse API including an Rails Action Mailer adapter
82
+ summary: The Ruby and Rails SDK for EmailFuse
97
83
  test_files: []
data/Rakefile DELETED
@@ -1,10 +0,0 @@
1
- require "bundler/gem_tasks"
2
- require "rake/testtask"
3
-
4
- Rake::TestTask.new(:test) do |t|
5
- t.libs << "test"
6
- t.libs << "lib"
7
- t.test_files = FileList["test/**/*_test.rb"]
8
- end
9
-
10
- task default: :test
@@ -1,69 +0,0 @@
1
- module Emailfuse
2
- class Client
3
- class << self
4
- def url
5
- Emailfuse.config.host || "https://app.emailfuse.net"
6
- end
7
-
8
- def connection
9
- @connection ||= Faraday.new("#{url}/api/v1") do |conn|
10
- conn.request :authorization, :Bearer, Emailfuse.config.token
11
-
12
- conn.headers = {
13
- "User-Agent" => "emailfuse/v#{VERSION} (github.com/voupe/emailfuse-gem)"
14
- }
15
-
16
- conn.request :multipart
17
- conn.request :json
18
-
19
- conn.response :json
20
- end
21
- end
22
-
23
- def get_request(url, params: {}, headers: {})
24
- handle_response connection.get(url, params, headers)
25
- end
26
-
27
- def post_request(url, body: {}, headers: {})
28
- handle_response connection.post(url, body, headers)
29
- end
30
-
31
- def patch_request(url, body:, headers: {})
32
- handle_response connection.patch(url, body, headers)
33
- end
34
-
35
- def delete_request(url, headers: {})
36
- handle_response connection.delete(url, headers)
37
- end
38
-
39
- def handle_response(response)
40
- case response.status
41
- when 400
42
- raise Error, "Error 400: Your request was malformed."
43
- when 401
44
- raise Error, "Error 401: You did not supply valid authentication credentials."
45
- when 403
46
- raise Error, "Error 403: You are not allowed to perform that action."
47
- when 404
48
- raise Error, "Error 404: No results were found for your request."
49
- when 409
50
- raise Error, "Error 409: Your request was a conflict."
51
- when 429
52
- raise Error, "Error 429: Your request exceeded the API rate limit."
53
- when 422
54
- raise Error, "Error 422: Unprocessable Entity."
55
- when 500
56
- raise Error, "Error 500: We were unable to perform the request due to server-side problems."
57
- when 503
58
- raise Error, "Error 503: You have been rate limited for sending more than 20 requests per second."
59
- when 501
60
- raise Error, "Error 501: This resource has not been implemented."
61
- when 204
62
- return true
63
- end
64
-
65
- response
66
- end
67
- end
68
- end
69
- end
@@ -1,27 +0,0 @@
1
- module Emailfuse
2
- class Collection
3
- attr_reader :data, :total
4
-
5
- def self.from_response(response, type:, key: nil)
6
- body = response.body
7
-
8
- if key.is_a?(String)
9
- data = body["data"][key].map { |attrs| type.new(attrs) }
10
- total = body["data"]["total"]
11
- else
12
- data = body["data"].map { |attrs| type.new(attrs) }
13
- total = body["data"].count
14
- end
15
-
16
- new(
17
- data: data,
18
- total: total
19
- )
20
- end
21
-
22
- def initialize(data:, total:)
23
- @data = data
24
- @total = total
25
- end
26
- end
27
- end
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Emailfuse
4
- class Configuration
5
- attr_accessor :host, :token
6
-
7
- def initialize
8
- end
9
- end
10
- end
@@ -1,72 +0,0 @@
1
- module Emailfuse
2
- class Deliverer
3
- class Attachment < StringIO
4
- attr_reader :original_filename, :content_type, :path
5
-
6
- def initialize (attachment, *rest)
7
- @path = ""
8
- @original_filename = attachment.filename
9
- @content_type = attachment.content_type.split(";")[0]
10
- super attachment.body.decoded
11
- end
12
- end
13
-
14
- attr_accessor :settings
15
-
16
- def initialize(settings)
17
- self.settings = settings
18
- end
19
-
20
- def api_token
21
- self.settings[:token] || Emailfuse.config.token
22
- end
23
-
24
- def deliver!(rails_message)
25
- attributes = {
26
- from: rails_message[:from],
27
- to: rails_message[:to].formatted,
28
- subject: rails_message.subject,
29
- html: extract_html(rails_message),
30
- text: extract_text(rails_message)
31
- }
32
-
33
- [ :reply_to, :cc ].each do |key|
34
- attributes[key] = rails_message[key].formatted if rails_message[key]
35
- end
36
-
37
- unless rails_message.attachments.empty?
38
- attributes[:attachments] = []
39
- rails_message.attachments.each do |attachment|
40
- attributes[:attachments] << Attachment.new(attachment, encoding: "ascii-8bit")
41
- end
42
- end
43
-
44
- Email.create(**attributes)
45
- end
46
-
47
- private
48
-
49
- # @see http://stackoverflow.com/questions/4868205/rails-mail-getting-the-body-as-plain-text
50
- def extract_html(rails_message)
51
- if rails_message.html_part
52
- rails_message.html_part.body.decoded
53
- else
54
- rails_message.content_type =~ /text\/html/ ? rails_message.body.decoded : nil
55
- end
56
- end
57
-
58
- def extract_text(rails_message)
59
- if rails_message.multipart?
60
- rails_message.text_part ? rails_message.text_part.body.decoded : nil
61
- else
62
- rails_message.content_type =~ /text\/plain/ ? rails_message.body.decoded : nil
63
- end
64
- end
65
-
66
- def email_fuse_client
67
- @email_fuse_client ||= Client.new(api_token, host)
68
- end
69
- end
70
- end
71
-
72
- ActionMailer::Base.add_delivery_method :email_fuse, Emailfuse::Deliverer
@@ -1,4 +0,0 @@
1
- module Emailfuse
2
- class Error < StandardError
3
- end
4
- end
@@ -1,21 +0,0 @@
1
- module Emailfuse
2
- class Email < Object
3
- class << self
4
- def create(to:, from:, subject:, html: nil, text: nil, **attributes)
5
- raise ArgumentError, "You must provide either html or text" if html.nil? && text.nil?
6
-
7
- attrs = attributes.merge!(
8
- to: to,
9
- from: from,
10
- subject: subject,
11
- html: html,
12
- text: text
13
- )
14
-
15
- response = Client.post_request("emails", body: attrs)
16
-
17
- Email.new(response.body) if response.success?
18
- end
19
- end
20
- end
21
- end
@@ -1,19 +0,0 @@
1
- require "ostruct"
2
-
3
- module Emailfuse
4
- class Object < OpenStruct
5
- def initialize(attributes)
6
- super to_ostruct(attributes)
7
- end
8
-
9
- def to_ostruct(obj)
10
- if obj.is_a?(Hash)
11
- OpenStruct.new(obj.map { |key, val| [ key, to_ostruct(val) ] }.to_h)
12
- elsif obj.is_a?(Array)
13
- obj.map { |o| to_ostruct(o) }
14
- else # Assumed to be a primitive value
15
- obj
16
- end
17
- end
18
- end
19
- end