letmesendemail 0.1.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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LetMeSendEmail
4
+ module Resources
5
+ # Emails API operations.
6
+ class Emails < Base
7
+ # Sends a regular email.
8
+ # @return [Models::Model]
9
+ def send(from:, to:, subject:, html: nil, text: nil, type: nil,
10
+ event_name: nil, email_topic_id: nil, reply_to: nil,
11
+ cc: nil, bcc: nil, headers: nil, attachments: nil,
12
+ idempotency_key: nil)
13
+ body = { from: from, to: to, subject: subject }
14
+ body[:html] = html if html
15
+ body[:text] = text if text
16
+ body[:type] = type if type
17
+ body[:event_name] = event_name if event_name
18
+ body[:email_topic_id] = email_topic_id if email_topic_id
19
+ body[:reply_to] = reply_to if reply_to
20
+ body[:cc] = cc if cc
21
+ body[:bcc] = bcc if bcc
22
+ body[:headers] = headers if headers
23
+ body[:attachments] = attachments if attachments
24
+
25
+ extra = idempotency_key ? { 'Idempotency-Key' => idempotency_key } : {}
26
+
27
+ @client.request(:post, '/emails', body: body, extra_headers: extra)
28
+ end
29
+
30
+ # Sends an email using a template.
31
+ # @return [Models::Model]
32
+ def send_with_template(from:, to:, template_id:, subject: nil,
33
+ template_variables: nil, type: nil,
34
+ event_name: nil, email_topic_id: nil,
35
+ reply_to: nil, cc: nil, bcc: nil,
36
+ headers: nil, attachments: nil,
37
+ idempotency_key: nil)
38
+ body = { from: from, to: to, template_id: template_id }
39
+ body[:subject] = subject if subject
40
+ body[:template_variables] = template_variables if template_variables
41
+ body[:type] = type if type
42
+ body[:event_name] = event_name if event_name
43
+ body[:email_topic_id] = email_topic_id if email_topic_id
44
+ body[:reply_to] = reply_to if reply_to
45
+ body[:cc] = cc if cc
46
+ body[:bcc] = bcc if bcc
47
+ body[:headers] = headers if headers
48
+ body[:attachments] = attachments if attachments
49
+
50
+ extra = idempotency_key ? { 'Idempotency-Key' => idempotency_key } : {}
51
+
52
+ @client.request(:post, '/emails', body: body, extra_headers: extra)
53
+ end
54
+
55
+ # Verifies an email address.
56
+ # @param email [String]
57
+ # @return [Models::Model]
58
+ def verify(email)
59
+ @client.request(:post, '/emails/verify', body: { email: email })
60
+ end
61
+
62
+ # Lists one cursor page of emails.
63
+ # @return [Models::Model]
64
+ def list(per_page: nil, after: nil, before: nil)
65
+ @client.request(:get, list_path('emails', per_page: per_page, after: after, before: before))
66
+ end
67
+
68
+ # Retrieves detailed information for an email.
69
+ # @param id [String]
70
+ # @return [Models::Model]
71
+ def get(id)
72
+ @client.request(:get, path_for('emails', id))
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LetMeSendEmail
4
+ # Current gem version, used in package metadata and the SDK User-Agent.
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+ require 'base64'
5
+ require 'json'
6
+
7
+ module LetMeSendEmail
8
+ # Verifies signatures on raw letmesend.email webhook requests.
9
+ module Webhooks
10
+ REQUIRED_HEADERS = %w[webhook-id webhook-log-id webhook-timestamp webhook-signature].freeze
11
+ DEFAULT_TOLERANCE = 300
12
+
13
+ module_function
14
+
15
+ # Verifies a webhook signature before parsing the JSON payload.
16
+ # @param payload [String] exact raw request body
17
+ # @param headers [Hash] request headers
18
+ # @param secret [String] Base64 secret, optionally prefixed with +whsec_+
19
+ # @param tolerance [Integer] allowed timestamp skew in seconds
20
+ # @return [Hash] verified JSON object
21
+ # @raise [WebhookVerificationError, WebhookSigningError]
22
+ def verify(payload, headers, secret, tolerance: DEFAULT_TOLERANCE)
23
+ validate_inputs!(payload, headers, secret, tolerance)
24
+ extracted = extract_headers(headers)
25
+ validate_timestamp!(extracted['webhook-timestamp'], tolerance)
26
+ expected = expected_signature(payload, extracted, secret)
27
+ validate_signature!(extracted['webhook-signature'], expected)
28
+ parse_payload(payload)
29
+ end
30
+
31
+ # Normalizes Rack/CGI and ordinary HTTP header hashes.
32
+ # @param headers [Hash]
33
+ # @return [Hash]
34
+ def resolve_headers(headers)
35
+ headers.each_with_object({}) do |(key, value), result|
36
+ normalized = key.to_s.downcase.sub(/\Ahttp_/, '').tr('_', '-')
37
+ result[normalized] = value.to_s
38
+ end
39
+ end
40
+
41
+ def validate_inputs!(payload, headers, secret, tolerance)
42
+ raise WebhookVerificationError, 'Webhook payload must be a string.' unless payload.is_a?(String)
43
+ raise WebhookVerificationError, 'Webhook headers must be a hash.' unless headers.is_a?(Hash)
44
+ unless tolerance.is_a?(Integer) && tolerance >= 0
45
+ raise WebhookVerificationError, 'Webhook tolerance must be zero or greater.'
46
+ end
47
+ return if secret.is_a?(String) && !secret.empty? && secret == secret.strip
48
+
49
+ raise WebhookSigningError, 'Webhook secret must be a non-empty string.'
50
+ end
51
+ private_class_method :validate_inputs!
52
+
53
+ def extract_headers(headers)
54
+ resolved = resolve_headers(headers)
55
+ REQUIRED_HEADERS.to_h do |header|
56
+ value = resolved[header]
57
+ raise WebhookVerificationError, "Missing required webhook header: #{header}." if value.nil? || value.empty?
58
+
59
+ [header, value]
60
+ end
61
+ end
62
+ private_class_method :extract_headers
63
+
64
+ def validate_timestamp!(timestamp_string, tolerance)
65
+ raise WebhookVerificationError, 'Webhook timestamp is not numeric.' unless timestamp_string.match?(/\A\d+\z/)
66
+
67
+ timestamp = timestamp_string.to_i
68
+ now = Time.now.to_i
69
+ raise WebhookVerificationError, 'Webhook timestamp must be a positive integer.' unless timestamp.positive?
70
+ raise WebhookVerificationError, 'Webhook timestamp is too old.' if timestamp < now - tolerance
71
+ return unless timestamp > now + tolerance
72
+
73
+ raise WebhookVerificationError, 'Webhook timestamp is too far in the future.'
74
+ end
75
+ private_class_method :validate_timestamp!
76
+
77
+ def expected_signature(payload, headers, secret)
78
+ raw_secret = secret.start_with?('whsec_') ? secret[6..] : secret
79
+ decoded_secret = Base64.strict_decode64(raw_secret)
80
+ raise WebhookSigningError, 'Webhook secret could not be decoded.' if decoded_secret.empty?
81
+
82
+ signed = "#{headers['webhook-id']}.#{headers['webhook-log-id']}.#{headers['webhook-timestamp']}.#{payload}"
83
+ Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', decoded_secret, signed))
84
+ rescue ArgumentError
85
+ raise WebhookSigningError, 'Webhook secret could not be decoded.'
86
+ end
87
+ private_class_method :expected_signature
88
+
89
+ def validate_signature!(signature_header, expected)
90
+ matched = signature_header.split.any? do |entry|
91
+ version, candidate = entry.split(',', 2)
92
+ version == 'v1' && candidate&.bytesize == expected.bytesize &&
93
+ OpenSSL.fixed_length_secure_compare(candidate, expected)
94
+ end
95
+ raise WebhookVerificationError, 'No matching webhook signature found.' unless matched
96
+ end
97
+ private_class_method :validate_signature!
98
+
99
+ def parse_payload(payload)
100
+ parsed = JSON.parse(payload)
101
+ raise WebhookVerificationError, 'Webhook payload must be a JSON object.' unless parsed.is_a?(Hash)
102
+
103
+ parsed
104
+ rescue JSON::ParserError
105
+ raise WebhookVerificationError, 'Webhook payload is not valid JSON.'
106
+ end
107
+ private_class_method :parse_payload
108
+ end
109
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'letmesendemail/version'
4
+ require_relative 'letmesendemail/config'
5
+ require_relative 'letmesendemail/errors'
6
+ require_relative 'letmesendemail/models'
7
+ require_relative 'letmesendemail/http_transport'
8
+ require_relative 'letmesendemail/client'
9
+ require_relative 'letmesendemail/webhooks'
10
+ require_relative 'letmesendemail/resources/base'
11
+ require_relative 'letmesendemail/resources/emails'
12
+ require_relative 'letmesendemail/resources/domains'
13
+ require_relative 'letmesendemail/resources/contacts'
14
+ require_relative 'letmesendemail/resources/contact_categories'
15
+ require_relative 'letmesendemail/resources/email_topics'
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: letmesendemail
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - letmesendemail
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ description: Official Ruby SDK for the letmesend.email API.
27
+ executables: []
28
+ extensions: []
29
+ extra_rdoc_files: []
30
+ files:
31
+ - CHANGELOG.md
32
+ - LICENSE.md
33
+ - README.md
34
+ - docs/docs.md
35
+ - examples/attachments.rb
36
+ - examples/errors.rb
37
+ - examples/idempotency.rb
38
+ - examples/pagination.rb
39
+ - examples/send.rb
40
+ - examples/template.rb
41
+ - examples/webhooks.rb
42
+ - lib/letmesendemail.rb
43
+ - lib/letmesendemail/client.rb
44
+ - lib/letmesendemail/config.rb
45
+ - lib/letmesendemail/errors.rb
46
+ - lib/letmesendemail/http_transport.rb
47
+ - lib/letmesendemail/models.rb
48
+ - lib/letmesendemail/resources/base.rb
49
+ - lib/letmesendemail/resources/contact_categories.rb
50
+ - lib/letmesendemail/resources/contacts.rb
51
+ - lib/letmesendemail/resources/domains.rb
52
+ - lib/letmesendemail/resources/email_topics.rb
53
+ - lib/letmesendemail/resources/emails.rb
54
+ - lib/letmesendemail/version.rb
55
+ - lib/letmesendemail/webhooks.rb
56
+ homepage: https://letmesend.email/
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ homepage_uri: https://letmesend.email/
61
+ source_code_uri: https://github.com/letmesendemail/letmesendemail-ruby
62
+ changelog_uri: https://github.com/letmesendemail/letmesendemail-ruby/blob/master/CHANGELOG.md
63
+ allowed_push_host: https://rubygems.org
64
+ rubygems_mfa_required: 'true'
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '3.1'
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 4.0.11
80
+ specification_version: 4
81
+ summary: letmesend.email Ruby SDK.
82
+ test_files: []