haskimail-rails 1.0.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 +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +267 -0
- data/lib/generators/haskimail/install/install_generator.rb +32 -0
- data/lib/generators/haskimail/install/templates/haskimail.rb +27 -0
- data/lib/haskimail/attachment.rb +84 -0
- data/lib/haskimail/clients.rb +78 -0
- data/lib/haskimail/configuration.rb +89 -0
- data/lib/haskimail/connection.rb +268 -0
- data/lib/haskimail/errors.rb +130 -0
- data/lib/haskimail/paginator.rb +48 -0
- data/lib/haskimail/rails/delivery_method.rb +56 -0
- data/lib/haskimail/rails/mail_converter.rb +130 -0
- data/lib/haskimail/rails/mailer_helpers.rb +42 -0
- data/lib/haskimail/rails/message_options.rb +88 -0
- data/lib/haskimail/rails/railtie.rb +28 -0
- data/lib/haskimail/resources/account_templates.rb +53 -0
- data/lib/haskimail/resources/base.rb +75 -0
- data/lib/haskimail/resources/bounces.rb +43 -0
- data/lib/haskimail/resources/domains.rb +42 -0
- data/lib/haskimail/resources/emails.rb +149 -0
- data/lib/haskimail/resources/inbound_rules.rb +33 -0
- data/lib/haskimail/resources/message_streams.rb +46 -0
- data/lib/haskimail/resources/messages.rb +135 -0
- data/lib/haskimail/resources/servers.rb +89 -0
- data/lib/haskimail/resources/stats.rb +89 -0
- data/lib/haskimail/resources/suppressions.rb +39 -0
- data/lib/haskimail/resources/templates.rb +68 -0
- data/lib/haskimail/resources/webhooks.rb +80 -0
- data/lib/haskimail/response_handler.rb +68 -0
- data/lib/haskimail/secret.rb +40 -0
- data/lib/haskimail/serializer.rb +75 -0
- data/lib/haskimail/util.rb +110 -0
- data/lib/haskimail/version.rb +5 -0
- data/lib/haskimail/webhooks/endpoint.rb +146 -0
- data/lib/haskimail/webhooks/event.rb +103 -0
- data/lib/haskimail/webhooks/inbound_message.rb +100 -0
- data/lib/haskimail/webhooks.rb +87 -0
- data/lib/haskimail-rails.rb +19 -0
- data/lib/haskimail.rb +58 -0
- metadata +132 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
module Haskimail
|
|
8
|
+
# HTTP transport with JSON (de)serialization, error mapping and retries with
|
|
9
|
+
# exponential backoff and jitter. Thread-safe and fork-safe.
|
|
10
|
+
#
|
|
11
|
+
# Retry policy (designed so that an email is never sent twice):
|
|
12
|
+
# * the TCP/TLS connection could not be opened (DNS, refused, open timeout):
|
|
13
|
+
# nothing was sent, so any request is retried;
|
|
14
|
+
# * the request may have reached the server (read timeout, reset, 5xx, 429):
|
|
15
|
+
# only idempotent requests are retried — GET, PUT and explicitly marked
|
|
16
|
+
# calls. POST (sending!) and DELETE are never retried in this case.
|
|
17
|
+
class Connection
|
|
18
|
+
IDEMPOTENT_METHODS = %w[GET HEAD OPTIONS PUT].freeze
|
|
19
|
+
|
|
20
|
+
# Errors from opening the connection (Net::HTTP#start): no request byte was sent.
|
|
21
|
+
CONNECT_ERRORS = [
|
|
22
|
+
Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EADDRNOTAVAIL,
|
|
23
|
+
Errno::ECONNRESET, Errno::ETIMEDOUT, SocketError, Net::OpenTimeout
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
# Errors while the request was in flight: it may already have been processed.
|
|
27
|
+
TRANSPORT_ERRORS = [
|
|
28
|
+
Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::EADDRNOTAVAIL,
|
|
29
|
+
Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED, Errno::ETIMEDOUT, SocketError,
|
|
30
|
+
EOFError, IOError, Net::ReadTimeout, Net::WriteTimeout, Net::HTTPBadResponse, Timeout::Error
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
THREAD_KEY = :__haskimail_http_connections
|
|
34
|
+
|
|
35
|
+
# A pooled socket idle for longer than this is reopened before use, so that
|
|
36
|
+
# reconnecting happens in the (retry-safe) connect phase instead of inside
|
|
37
|
+
# Net::HTTP#request. Must stay below Net::HTTP#keep_alive_timeout.
|
|
38
|
+
MAX_IDLE_SECONDS = 1.5
|
|
39
|
+
KEEP_ALIVE_TIMEOUT = 2
|
|
40
|
+
|
|
41
|
+
# Internal marker: the connection could not be opened (see #open_connection).
|
|
42
|
+
class ConnectFailure < StandardError; end
|
|
43
|
+
private_constant :ConnectFailure
|
|
44
|
+
|
|
45
|
+
attr_reader :config
|
|
46
|
+
|
|
47
|
+
def initialize(token:, token_header:, config:)
|
|
48
|
+
@token = Secret.new(Util.validate_token!(token, token_header))
|
|
49
|
+
@token_header = token_header
|
|
50
|
+
@config = config
|
|
51
|
+
@base_uri = config.base_uri
|
|
52
|
+
@pool_key = [@base_uri.host, @base_uri.port, config.open_timeout, config.read_timeout,
|
|
53
|
+
config.write_timeout].freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def get(path, query: nil)
|
|
57
|
+
request(:get, path, query: query)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def post(path, body = nil, idempotent: false)
|
|
61
|
+
request(:post, path, body: body, idempotent: idempotent)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def put(path, body = nil, idempotent: true)
|
|
65
|
+
request(:put, path, body: body, idempotent: idempotent)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def patch(path, body = nil)
|
|
69
|
+
request(:patch, path, body: body, idempotent: true)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def delete(path)
|
|
73
|
+
request(:delete, path, idempotent: false)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Hash, Array, nil] the parsed JSON body (keys as returned by the API)
|
|
77
|
+
def request(method, path, query: nil, body: nil, idempotent: nil)
|
|
78
|
+
verb = method.to_s.upcase
|
|
79
|
+
idempotent = IDEMPOTENT_METHODS.include?(verb) if idempotent.nil?
|
|
80
|
+
uri = build_uri(path, query)
|
|
81
|
+
payload = body.nil? ? nil : JSON.generate(body)
|
|
82
|
+
with_retries(idempotent) { |attempt| send_once(verb, uri, payload, attempt) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def inspect
|
|
86
|
+
"#<#{self.class.name} base_url=#{@base_uri} #{@token_header}=#{Util.redact(@token.reveal).inspect}>"
|
|
87
|
+
end
|
|
88
|
+
alias to_s inspect
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def with_retries(idempotent)
|
|
93
|
+
attempt = 0
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
attempt += 1
|
|
97
|
+
yield attempt
|
|
98
|
+
rescue ConnectFailure => e
|
|
99
|
+
retry if retry_after_pause?(attempt)
|
|
100
|
+
raise wrap_network_error(e.cause)
|
|
101
|
+
rescue *TRANSPORT_ERRORS => e
|
|
102
|
+
discard_connection
|
|
103
|
+
retry if idempotent && retry_after_pause?(attempt)
|
|
104
|
+
raise wrap_network_error(e)
|
|
105
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
106
|
+
discard_connection
|
|
107
|
+
# A dropped TLS session may surface as SSLError; certificate problems are never retried.
|
|
108
|
+
retry if idempotent && !e.message.match?(/certificate|verify/i) && retry_after_pause?(attempt)
|
|
109
|
+
raise ConnectionError, "TLS error while talking to Haskimail: #{e.message}"
|
|
110
|
+
rescue ApiError => e
|
|
111
|
+
retry if idempotent && e.retryable? && retry_after_pause?(attempt, e.headers["retry-after"])
|
|
112
|
+
raise
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def build_uri(path, query)
|
|
117
|
+
uri = @base_uri.dup
|
|
118
|
+
uri.path = "#{@base_uri.path.chomp('/')}#{path}"
|
|
119
|
+
params = query&.compact
|
|
120
|
+
uri.query = URI.encode_www_form(params) if params && !params.empty?
|
|
121
|
+
uri
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def send_once(verb, uri, payload, attempt)
|
|
125
|
+
http = open_connection
|
|
126
|
+
response = instrument(verb, uri, attempt) { perform(http, verb, uri, payload) }
|
|
127
|
+
touch_connection
|
|
128
|
+
ResponseHandler.call(response)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def perform(http, verb, uri, payload)
|
|
132
|
+
request = Net::HTTPGenericRequest.new(verb, !payload.nil?, verb != "HEAD", uri.request_uri)
|
|
133
|
+
default_headers(payload).each { |name, value| request[name] = value }
|
|
134
|
+
request.body = payload if payload
|
|
135
|
+
http.request(request)
|
|
136
|
+
ensure
|
|
137
|
+
close_quietly(http) unless config.persistent_connections
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Opening the socket is the only phase that is guaranteed to happen before
|
|
141
|
+
# the request is written, so only its failures are marked as safe to retry.
|
|
142
|
+
def open_connection
|
|
143
|
+
config.persistent_connections ? pooled_http : build_http.tap(&:start)
|
|
144
|
+
rescue *CONNECT_ERRORS
|
|
145
|
+
raise ConnectFailure
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def default_headers(payload)
|
|
149
|
+
headers = {
|
|
150
|
+
"Accept" => "application/json",
|
|
151
|
+
"User-Agent" => user_agent,
|
|
152
|
+
@token_header => @token.reveal
|
|
153
|
+
}
|
|
154
|
+
headers["Content-Type"] = "application/json; charset=utf-8" if payload
|
|
155
|
+
headers
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def user_agent
|
|
159
|
+
@user_agent ||= [
|
|
160
|
+
"haskimail-rails/#{VERSION}",
|
|
161
|
+
"ruby/#{RUBY_VERSION}",
|
|
162
|
+
"(#{RUBY_PLATFORM})",
|
|
163
|
+
config.user_agent_suffix
|
|
164
|
+
].compact.join(" ")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def build_http
|
|
168
|
+
http = Net::HTTP.new(@base_uri.host, @base_uri.port)
|
|
169
|
+
http.use_ssl = true
|
|
170
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
171
|
+
http.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
172
|
+
http.open_timeout = config.open_timeout
|
|
173
|
+
http.read_timeout = config.read_timeout
|
|
174
|
+
http.write_timeout = config.write_timeout
|
|
175
|
+
http.keep_alive_timeout = KEEP_ALIVE_TIMEOUT
|
|
176
|
+
# Net::HTTP silently re-sends idempotent requests (including PUT and
|
|
177
|
+
# DELETE) once after a timeout or reset. Retries are decided here instead.
|
|
178
|
+
http.max_retries = 0
|
|
179
|
+
http
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# One keep-alive connection per thread (more precisely per fiber, since
|
|
183
|
+
# Thread#[] is fiber-local — so concurrent fibers never share a socket) and
|
|
184
|
+
# per endpoint/timeouts. The PID check makes it fork-safe: a Puma/Unicorn
|
|
185
|
+
# worker never reuses its parent's TLS socket. For fiber-per-request servers
|
|
186
|
+
# (Falcon) set persistent_connections = false.
|
|
187
|
+
def pooled_http
|
|
188
|
+
pool = (Thread.current[THREAD_KEY] ||= {})
|
|
189
|
+
pid, http, last_used = pool[@pool_key]
|
|
190
|
+
return http if pid == Process.pid && http&.started? && monotonic_now - last_used < MAX_IDLE_SECONDS
|
|
191
|
+
|
|
192
|
+
close_quietly(http) if pid == Process.pid
|
|
193
|
+
pool.delete(@pool_key)
|
|
194
|
+
http = build_http
|
|
195
|
+
http.start
|
|
196
|
+
pool[@pool_key] = [Process.pid, http, monotonic_now]
|
|
197
|
+
http
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def touch_connection
|
|
201
|
+
entry = config.persistent_connections && Thread.current[THREAD_KEY]&.[](@pool_key)
|
|
202
|
+
entry[2] = monotonic_now if entry
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def discard_connection
|
|
206
|
+
return unless config.persistent_connections
|
|
207
|
+
|
|
208
|
+
pid, http = Thread.current[THREAD_KEY]&.delete(@pool_key)
|
|
209
|
+
close_quietly(http) if pid == Process.pid
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def monotonic_now
|
|
213
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def close_quietly(http)
|
|
217
|
+
http.finish if http&.started?
|
|
218
|
+
rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def retry_after_pause?(attempt, retry_after = nil)
|
|
223
|
+
return false if attempt > config.max_retries.to_i
|
|
224
|
+
|
|
225
|
+
pause(retry_delay(attempt, retry_after))
|
|
226
|
+
true
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def retry_delay(attempt, retry_after)
|
|
230
|
+
max = config.retry_max_delay.to_f
|
|
231
|
+
server_hint = Float(retry_after, exception: false)
|
|
232
|
+
return server_hint.clamp(0.0, max) if server_hint
|
|
233
|
+
|
|
234
|
+
exponential = [config.retry_base_delay.to_f * (2**(attempt - 1)), max].min
|
|
235
|
+
(exponential / 2) + (rand * exponential / 2) # "equal jitter"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def pause(seconds)
|
|
239
|
+
sleep(seconds) if seconds.positive?
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def wrap_network_error(error)
|
|
243
|
+
klass = error.is_a?(Timeout::Error) || error.is_a?(Errno::ETIMEDOUT) ? TimeoutError : ConnectionError
|
|
244
|
+
klass.new("Could not reach Haskimail (#{error.class}: #{error.message})")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def instrument(verb, uri, attempt)
|
|
248
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
249
|
+
payload = { method: verb, path: uri.path, attempt: attempt }
|
|
250
|
+
response = if defined?(ActiveSupport::Notifications)
|
|
251
|
+
ActiveSupport::Notifications.instrument("request.haskimail", payload) do
|
|
252
|
+
yield.tap { |res| payload[:status] = res.code.to_i }
|
|
253
|
+
end
|
|
254
|
+
else
|
|
255
|
+
yield
|
|
256
|
+
end
|
|
257
|
+
log(verb, uri, response.code, started)
|
|
258
|
+
response
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def log(verb, uri, code, started)
|
|
262
|
+
return unless config.logger
|
|
263
|
+
|
|
264
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
265
|
+
config.logger.info("[Haskimail] #{verb} #{uri.path} -> #{code} (#{elapsed}ms)")
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Haskimail
|
|
4
|
+
# Base class for every error raised by this library.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Missing or invalid client configuration (no token, non-HTTPS base URL, ...).
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Network-level failure: the API could not be reached or the connection broke.
|
|
11
|
+
class ConnectionError < Error; end
|
|
12
|
+
|
|
13
|
+
# The connection or the response timed out.
|
|
14
|
+
class TimeoutError < ConnectionError; end
|
|
15
|
+
|
|
16
|
+
# The API answered with something that is not valid JSON.
|
|
17
|
+
class InvalidResponseError < Error
|
|
18
|
+
attr_reader :status
|
|
19
|
+
|
|
20
|
+
def initialize(message = nil, status: nil)
|
|
21
|
+
super(message)
|
|
22
|
+
@status = status
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Numeric `ErrorCode` values documented at https://haskimail.ru/developer/api/overview.
|
|
27
|
+
module ErrorCodes
|
|
28
|
+
INVALID_API_TOKEN = 10
|
|
29
|
+
NOT_FOUND = 12
|
|
30
|
+
INTERNAL_ERROR = 100
|
|
31
|
+
MISSING_REQUIRED_FIELDS = 3
|
|
32
|
+
SENDING_NOT_ALLOWED = 4
|
|
33
|
+
INVALID_EMAIL = 6
|
|
34
|
+
ACTION_FORBIDDEN = 15
|
|
35
|
+
ATTACHMENT_TOO_LARGE = 26
|
|
36
|
+
ATTACHMENT_TYPE_NOT_ALLOWED = 33
|
|
37
|
+
SENDING_UNAVAILABLE = 35
|
|
38
|
+
TEMPORARY_SEND_ERROR = 36
|
|
39
|
+
INVALID_HEADERS = 40
|
|
40
|
+
SENDER_NOT_CONFIRMED = 52
|
|
41
|
+
INVALID_BATCH = 300
|
|
42
|
+
MODERATION_SENDER_NOT_CONFIRMED = 400
|
|
43
|
+
MODERATION_RECIPIENT_NOT_CONFIRMED = 412
|
|
44
|
+
MESSAGE_NOT_FOUND = 701
|
|
45
|
+
TEMPLATE_SERVER_MISMATCH = 510
|
|
46
|
+
DOMAIN_ALREADY_TAKEN = 512
|
|
47
|
+
INBOUND_DOMAIN_WITHOUT_MX = 610
|
|
48
|
+
TEMPLATE_NOT_FOUND = 1101
|
|
49
|
+
TEMPLATE_VALIDATION_INPUT_INVALID = 1120
|
|
50
|
+
MESSAGE_STREAM_INVALID = 1226
|
|
51
|
+
INBOUND_STREAM_NOT_ARCHIVABLE = 1229
|
|
52
|
+
INBOUND_STREAM_NOT_SENDABLE = 1236
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The API answered with a non-success HTTP status or a non-zero `ErrorCode`.
|
|
56
|
+
class ApiError < Error
|
|
57
|
+
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
58
|
+
|
|
59
|
+
attr_reader :status, :error_code, :body, :headers
|
|
60
|
+
|
|
61
|
+
def initialize(message = nil, status: nil, error_code: nil, body: nil, headers: {})
|
|
62
|
+
@status = status
|
|
63
|
+
@error_code = error_code
|
|
64
|
+
@body = body
|
|
65
|
+
@headers = headers || {}
|
|
66
|
+
super(message || default_message)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Builds the most specific error subclass for an HTTP status.
|
|
70
|
+
def self.for(status:, error_code: nil, message: nil, body: nil, headers: {})
|
|
71
|
+
klass = STATUS_CLASSES.fetch(status) do
|
|
72
|
+
status.to_i >= 500 ? ServerError : ApiError
|
|
73
|
+
end
|
|
74
|
+
klass.new(message, status: status, error_code: error_code, body: body, headers: headers)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def retryable?
|
|
78
|
+
RETRYABLE_STATUSES.include?(status)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @example error.code?(Haskimail::ErrorCodes::TEMPLATE_NOT_FOUND)
|
|
82
|
+
def code?(code)
|
|
83
|
+
!error_code.nil? && error_code == code
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def default_message
|
|
89
|
+
parts = []
|
|
90
|
+
parts << "HTTP #{status}" if status
|
|
91
|
+
parts << "ErrorCode #{error_code}" if error_code
|
|
92
|
+
parts.empty? ? "Haskimail API error" : "Haskimail API error (#{parts.join(', ')})"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# HTTP 401: the token is missing, wrong, or not valid for this method.
|
|
97
|
+
class UnauthorizedError < ApiError; end
|
|
98
|
+
# HTTP 403: the request IP is not on the account allowlist.
|
|
99
|
+
class ForbiddenError < ApiError; end
|
|
100
|
+
# HTTP 404: unknown method or ID.
|
|
101
|
+
class NotFoundError < ApiError; end
|
|
102
|
+
# HTTP 413: the batch is larger than 50 MB.
|
|
103
|
+
class PayloadTooLargeError < ApiError; end
|
|
104
|
+
# HTTP 422: validation error, see #error_code.
|
|
105
|
+
class UnprocessableEntityError < ApiError; end
|
|
106
|
+
# HTTP 429: too many requests.
|
|
107
|
+
class RateLimitError < ApiError; end
|
|
108
|
+
# HTTP 5xx.
|
|
109
|
+
class ServerError < ApiError; end
|
|
110
|
+
# HTTP 500.
|
|
111
|
+
class InternalServerError < ServerError; end
|
|
112
|
+
# HTTP 503: temporarily unavailable.
|
|
113
|
+
class ServiceUnavailableError < ServerError; end
|
|
114
|
+
|
|
115
|
+
class ApiError
|
|
116
|
+
STATUS_CLASSES = {
|
|
117
|
+
401 => UnauthorizedError,
|
|
118
|
+
403 => ForbiddenError,
|
|
119
|
+
404 => NotFoundError,
|
|
120
|
+
413 => PayloadTooLargeError,
|
|
121
|
+
422 => UnprocessableEntityError,
|
|
122
|
+
429 => RateLimitError,
|
|
123
|
+
500 => InternalServerError,
|
|
124
|
+
503 => ServiceUnavailableError
|
|
125
|
+
}.freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Raised when a webhook payload fails signature or authentication checks.
|
|
129
|
+
class WebhookVerificationError < Error; end
|
|
130
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Haskimail
|
|
4
|
+
# Lazily walks count/offset pages. Requests are made only while iterating.
|
|
5
|
+
#
|
|
6
|
+
# client.messages.each_outbound(tag: "orders").first(1000)
|
|
7
|
+
# client.bounces.each(type: "HardBounce").each { |bounce| ... }
|
|
8
|
+
class Paginator
|
|
9
|
+
include Enumerable
|
|
10
|
+
|
|
11
|
+
# @param items_key [Symbol] collection key in the (serialized) page response
|
|
12
|
+
# @param page_size [Integer]
|
|
13
|
+
# @param max_window [Integer, nil] API limit for count + offset
|
|
14
|
+
# @yieldparam count [Integer]
|
|
15
|
+
# @yieldparam offset [Integer]
|
|
16
|
+
# @yieldreturn [Hash] serialized page with :total_count and items_key
|
|
17
|
+
def initialize(items_key:, page_size:, max_window: nil, &fetch_page)
|
|
18
|
+
raise ArgumentError, "page_size must be a positive Integer" unless page_size.is_a?(Integer) && page_size.positive?
|
|
19
|
+
|
|
20
|
+
@items_key = items_key
|
|
21
|
+
@page_size = page_size
|
|
22
|
+
@max_window = max_window
|
|
23
|
+
@fetch_page = fetch_page
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def each(&block)
|
|
27
|
+
return enum_for(:each) unless block_given?
|
|
28
|
+
|
|
29
|
+
offset = 0
|
|
30
|
+
loop do
|
|
31
|
+
count = @page_size
|
|
32
|
+
count = [count, @max_window - offset].min if @max_window
|
|
33
|
+
break if count <= 0
|
|
34
|
+
|
|
35
|
+
page = @fetch_page.call(count, offset)
|
|
36
|
+
items = Array(page && page[@items_key])
|
|
37
|
+
items.each(&block)
|
|
38
|
+
|
|
39
|
+
offset += items.size
|
|
40
|
+
total = page && page[:total_count]
|
|
41
|
+
# Trust total_count when present (the server may cap the page size);
|
|
42
|
+
# otherwise a short page is the last one.
|
|
43
|
+
break if items.empty? || (total ? offset >= total : items.size < count)
|
|
44
|
+
end
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Haskimail
|
|
4
|
+
module Rails
|
|
5
|
+
# ActionMailer delivery method.
|
|
6
|
+
#
|
|
7
|
+
# # config/environments/production.rb
|
|
8
|
+
# config.action_mailer.delivery_method = :haskimail
|
|
9
|
+
# config.action_mailer.haskimail_settings = {
|
|
10
|
+
# api_token: Rails.application.credentials.dig(:haskimail, :server_token)
|
|
11
|
+
# }
|
|
12
|
+
#
|
|
13
|
+
# Settings: :api_token (or :server_token), :return_response, and any
|
|
14
|
+
# Haskimail::Configuration option (:default_message_stream, :read_timeout, ...).
|
|
15
|
+
# Without a token the global Haskimail configuration is used.
|
|
16
|
+
class DeliveryMethod
|
|
17
|
+
TOKEN_KEYS = %i[api_token server_token].freeze
|
|
18
|
+
|
|
19
|
+
attr_accessor :settings
|
|
20
|
+
|
|
21
|
+
def initialize(settings = {})
|
|
22
|
+
@settings = (settings || {}).to_h.transform_keys(&:to_sym)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Mail::Message, Hash] the message, or the API response with return_response: true
|
|
26
|
+
def deliver!(mail)
|
|
27
|
+
payload = MailConverter.call(mail)
|
|
28
|
+
client = build_client
|
|
29
|
+
response = mail.haskimail.template? ? client.deliver_with_template(payload) : client.deliver(payload)
|
|
30
|
+
mail.haskimail.response = response
|
|
31
|
+
settings[:return_response] ? response : mail
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def inspect
|
|
35
|
+
"#<#{self.class.name} settings=#{settings.except(*TOKEN_KEYS).inspect}>"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Safe for logs and error trackers: tokens are redacted.
|
|
39
|
+
def as_json(*)
|
|
40
|
+
{ "settings" => settings.to_h { |k, v| [k.to_s, TOKEN_KEYS.include?(k) ? Secret::REDACTED : v] } }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_json(*args)
|
|
44
|
+
as_json.to_json(*args)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def build_client
|
|
50
|
+
token = settings[:api_token] || settings[:server_token]
|
|
51
|
+
options = settings.except(*TOKEN_KEYS, :return_response)
|
|
52
|
+
ServerClient.new(token, **options)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Haskimail
|
|
7
|
+
module Rails
|
|
8
|
+
# Converts a Mail::Message (as built by ActionMailer) into an API payload.
|
|
9
|
+
module MailConverter
|
|
10
|
+
# Headers mapped to dedicated API fields or managed by Haskimail itself.
|
|
11
|
+
# Message-ID is reserved too: ActionMailer generates one for every message
|
|
12
|
+
# and Haskimail assigns its own MessageID.
|
|
13
|
+
RESERVED_HEADERS = %w[
|
|
14
|
+
from to cc bcc reply-to subject date sender return-path received mime-version message-id
|
|
15
|
+
content-type content-transfer-encoding content-disposition content-id
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# User data that is sent even when empty.
|
|
19
|
+
KEEP_WHEN_EMPTY = %w[TemplateModel].freeze
|
|
20
|
+
|
|
21
|
+
# SMTP-compatible headers (https://haskimail.ru/developer/user-guide/send-email-with-smtp),
|
|
22
|
+
# honoured here too so mailers work the same over SMTP and the HTTP API.
|
|
23
|
+
HM_TAG = "x-hm-tag"
|
|
24
|
+
HM_METADATA = "x-hm-metadata"
|
|
25
|
+
HM_TRACK_OPENS = "x-hm-trackopens"
|
|
26
|
+
HM_NO_TRACK_OPENS = "x-hm-no-track-opens"
|
|
27
|
+
HM_NO_TRACK_CLICK = "x-hm-no-track-click"
|
|
28
|
+
# Converted into API fields; other X-HM-* headers (X-HM-Domain,
|
|
29
|
+
# X-HM-MessageID) are passed through to Haskimail unchanged.
|
|
30
|
+
CONSUMED_HM_HEADERS = [HM_TAG, HM_METADATA, HM_TRACK_OPENS, HM_NO_TRACK_OPENS, HM_NO_TRACK_CLICK].freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# @param mail [Mail::Message]
|
|
35
|
+
# @return [Hash] API payload with String keys
|
|
36
|
+
def call(mail)
|
|
37
|
+
options = mail.respond_to?(:haskimail) ? mail.haskimail : MessageOptions.new
|
|
38
|
+
payload = {
|
|
39
|
+
"From" => address_list(mail, :from).first,
|
|
40
|
+
"To" => join(address_list(mail, :to)),
|
|
41
|
+
"Cc" => join(address_list(mail, :cc)),
|
|
42
|
+
"Bcc" => join(address_list(mail, :bcc)),
|
|
43
|
+
"ReplyTo" => join(address_list(mail, :reply_to)),
|
|
44
|
+
"Subject" => mail.subject,
|
|
45
|
+
"Headers" => custom_headers(mail),
|
|
46
|
+
"Attachments" => attachments(mail)
|
|
47
|
+
}
|
|
48
|
+
payload.merge!(bodies(mail)) unless options.template?
|
|
49
|
+
payload.merge!(header_options(mail))
|
|
50
|
+
payload.merge!(options.to_api)
|
|
51
|
+
payload.reject { |key, value| Util.blank?(value) && value != false && !KEEP_WHEN_EMPTY.include?(key) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def address_list(mail, field)
|
|
55
|
+
header = mail[field]
|
|
56
|
+
return [] unless header
|
|
57
|
+
|
|
58
|
+
Array(header.respond_to?(:formatted) ? header.formatted : header.value).compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def join(list)
|
|
62
|
+
list.empty? ? nil : list.join(", ")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def bodies(mail)
|
|
66
|
+
if mail.multipart?
|
|
67
|
+
{ "HtmlBody" => decode(mail.html_part), "TextBody" => decode(mail.text_part) }
|
|
68
|
+
elsif mail.mime_type == "text/html"
|
|
69
|
+
{ "HtmlBody" => decode(mail) }
|
|
70
|
+
else
|
|
71
|
+
{ "TextBody" => decode(mail) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def decode(part)
|
|
76
|
+
return nil unless part
|
|
77
|
+
|
|
78
|
+
part.decoded.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def custom_headers(mail)
|
|
82
|
+
mail.header.fields.filter_map do |field|
|
|
83
|
+
name = field.name.to_s
|
|
84
|
+
key = name.downcase
|
|
85
|
+
next if RESERVED_HEADERS.include?(key) || CONSUMED_HM_HEADERS.include?(key)
|
|
86
|
+
|
|
87
|
+
{ "Name" => name, "Value" => field.value.to_s }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def attachments(mail)
|
|
92
|
+
mail.attachments.map do |attachment|
|
|
93
|
+
Haskimail::Attachment.assert_allowed!(attachment.filename)
|
|
94
|
+
item = {
|
|
95
|
+
"Name" => attachment.filename,
|
|
96
|
+
"Content" => Base64.strict_encode64(attachment.decoded),
|
|
97
|
+
"ContentType" => attachment.mime_type
|
|
98
|
+
}
|
|
99
|
+
# Bare Content-ID: the HTML references it as "cid:<id>" (Rails' attachment.url).
|
|
100
|
+
item["ContentID"] = Haskimail::Attachment.normalize_content_id(attachment.content_id) if attachment.inline?
|
|
101
|
+
item
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Values from X-HM-* headers; explicit `message.haskimail` settings win.
|
|
106
|
+
def header_options(mail)
|
|
107
|
+
result = {}
|
|
108
|
+
result["Tag"] = header_value(mail, HM_TAG)
|
|
109
|
+
if (raw = header_value(mail, HM_METADATA))
|
|
110
|
+
metadata = JSON.parse(raw)
|
|
111
|
+
raise ArgumentError, "X-HM-Metadata must be a JSON object" unless metadata.is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
result["Metadata"] = metadata
|
|
114
|
+
end
|
|
115
|
+
track_opens = header_value(mail, HM_TRACK_OPENS)
|
|
116
|
+
result["TrackOpens"] = track_opens.casecmp?("true") if track_opens
|
|
117
|
+
result["TrackOpens"] = false if header_value(mail, HM_NO_TRACK_OPENS)
|
|
118
|
+
result["TrackLinks"] = false if header_value(mail, HM_NO_TRACK_CLICK)
|
|
119
|
+
result.compact
|
|
120
|
+
rescue JSON::ParserError
|
|
121
|
+
raise ArgumentError, "X-HM-Metadata must be valid JSON"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def header_value(mail, name)
|
|
125
|
+
field = mail.header.fields.find { |candidate| candidate.name.to_s.casecmp?(name) }
|
|
126
|
+
field&.value&.to_s
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Haskimail
|
|
4
|
+
module Rails
|
|
5
|
+
# Helpers available in every ActionMailer::Base subclass.
|
|
6
|
+
#
|
|
7
|
+
# class OrderMailer < ApplicationMailer
|
|
8
|
+
# def created(order)
|
|
9
|
+
# haskimail tag: "orders", metadata: { order_id: order.id }, message_stream: "2081"
|
|
10
|
+
# mail to: order.email, subject: "Заказ №#{order.number} оформлен"
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# def welcome(user)
|
|
14
|
+
# haskimail_template "welcome", model: { "{{name}}" => user.name }, to: user.email
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
module MailerHelpers
|
|
18
|
+
# Sets Haskimail options (tag, metadata, message_stream, track_opens,
|
|
19
|
+
# track_links, template, template_model) for the message being built.
|
|
20
|
+
def haskimail(**options)
|
|
21
|
+
message.haskimail.assign(options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Sends a Haskimail template instead of a rendered view. The subject comes
|
|
25
|
+
# from the template unless `subject:` is given.
|
|
26
|
+
# @param template [Integer, String] template ID or alias
|
|
27
|
+
# @param model [Hash] placeholders, e.g. { "{{name}}" => "Иван" }
|
|
28
|
+
# @param headers [Hash] regular ActionMailer headers (to:, from:, cc:, subject:, ...)
|
|
29
|
+
def haskimail_template(template, model: {}, **headers)
|
|
30
|
+
if (stray = headers.keys.grep_v(Symbol)).any?
|
|
31
|
+
raise ArgumentError, "Unexpected header(s) #{stray.inspect}: pass the template model as model: { ... }"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
haskimail(template: template, template_model: model)
|
|
35
|
+
# A blank subject stops ActionMailer from adding its default i18n subject.
|
|
36
|
+
mail({ subject: "" }.merge(headers)) do |format|
|
|
37
|
+
format.text { render plain: "" }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|