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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE.md +21 -0
- data/README.md +220 -0
- data/docs/docs.md +513 -0
- data/examples/attachments.rb +21 -0
- data/examples/errors.rb +18 -0
- data/examples/idempotency.rb +17 -0
- data/examples/pagination.rb +20 -0
- data/examples/send.rb +16 -0
- data/examples/template.rb +18 -0
- data/examples/webhooks.rb +12 -0
- data/lib/letmesendemail/client.rb +109 -0
- data/lib/letmesendemail/config.rb +80 -0
- data/lib/letmesendemail/errors.rb +60 -0
- data/lib/letmesendemail/http_transport.rb +129 -0
- data/lib/letmesendemail/models.rb +99 -0
- data/lib/letmesendemail/resources/base.rb +31 -0
- data/lib/letmesendemail/resources/contact_categories.rb +45 -0
- data/lib/letmesendemail/resources/contacts.rb +60 -0
- data/lib/letmesendemail/resources/domains.rb +28 -0
- data/lib/letmesendemail/resources/email_topics.rb +53 -0
- data/lib/letmesendemail/resources/emails.rb +76 -0
- data/lib/letmesendemail/version.rb +6 -0
- data/lib/letmesendemail/webhooks.rb +109 -0
- data/lib/letmesendemail.rb +15 -0
- metadata +82 -0
|
@@ -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,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: []
|