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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +267 -0
  5. data/lib/generators/haskimail/install/install_generator.rb +32 -0
  6. data/lib/generators/haskimail/install/templates/haskimail.rb +27 -0
  7. data/lib/haskimail/attachment.rb +84 -0
  8. data/lib/haskimail/clients.rb +78 -0
  9. data/lib/haskimail/configuration.rb +89 -0
  10. data/lib/haskimail/connection.rb +268 -0
  11. data/lib/haskimail/errors.rb +130 -0
  12. data/lib/haskimail/paginator.rb +48 -0
  13. data/lib/haskimail/rails/delivery_method.rb +56 -0
  14. data/lib/haskimail/rails/mail_converter.rb +130 -0
  15. data/lib/haskimail/rails/mailer_helpers.rb +42 -0
  16. data/lib/haskimail/rails/message_options.rb +88 -0
  17. data/lib/haskimail/rails/railtie.rb +28 -0
  18. data/lib/haskimail/resources/account_templates.rb +53 -0
  19. data/lib/haskimail/resources/base.rb +75 -0
  20. data/lib/haskimail/resources/bounces.rb +43 -0
  21. data/lib/haskimail/resources/domains.rb +42 -0
  22. data/lib/haskimail/resources/emails.rb +149 -0
  23. data/lib/haskimail/resources/inbound_rules.rb +33 -0
  24. data/lib/haskimail/resources/message_streams.rb +46 -0
  25. data/lib/haskimail/resources/messages.rb +135 -0
  26. data/lib/haskimail/resources/servers.rb +89 -0
  27. data/lib/haskimail/resources/stats.rb +89 -0
  28. data/lib/haskimail/resources/suppressions.rb +39 -0
  29. data/lib/haskimail/resources/templates.rb +68 -0
  30. data/lib/haskimail/resources/webhooks.rb +80 -0
  31. data/lib/haskimail/response_handler.rb +68 -0
  32. data/lib/haskimail/secret.rb +40 -0
  33. data/lib/haskimail/serializer.rb +75 -0
  34. data/lib/haskimail/util.rb +110 -0
  35. data/lib/haskimail/version.rb +5 -0
  36. data/lib/haskimail/webhooks/endpoint.rb +146 -0
  37. data/lib/haskimail/webhooks/event.rb +103 -0
  38. data/lib/haskimail/webhooks/inbound_message.rb +100 -0
  39. data/lib/haskimail/webhooks.rb +87 -0
  40. data/lib/haskimail-rails.rb +19 -0
  41. data/lib/haskimail.rb +58 -0
  42. 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