posthaste-rails 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,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require_relative 'redaction'
6
+
7
+ module Posthaste
8
+ # Every `error.type` the API emits, plus the ones this gem synthesises.
9
+ #
10
+ # Kept as data rather than as a set of classes so a caller can check whether a
11
+ # type is one this version knows about. An unrecognised type is NOT a bug: the
12
+ # API is allowed to add refusal reasons, and one that arrives here unknown
13
+ # becomes the class its HTTP status implies instead of being mistaken for a
14
+ # documented one.
15
+ KNOWN_ERROR_TYPES = %w[
16
+ unauthorized unauthenticated forbidden csrf_failed email_unverified
17
+ invalid_request not_found conflict address_taken
18
+ batch_too_large fanout_too_large unknown_template invalid_template
19
+ template_in_use unknown_stream reserved_slug slug_taken invalid_address
20
+ domain_not_found domain_not_verified suppressed
21
+ rate_limited daily_limit_reached monthly_limit_reached platform_paused
22
+ bulk_send_refused
23
+ attachments_too_many attachments_too_large attachment_type_blocked
24
+ attachment_invalid
25
+ schedule_too_far not_scheduled
26
+ content_blocked
27
+ domain_limit_reached domain_in_use token_required
28
+ cloudflare_token_invalid cloudflare_zone_not_found cloudflare_write_failed
29
+ suppression_protected suppression_platform suppression_hard_bounce
30
+ not_configured provider_error already_subscribed
31
+ internal
32
+ unknown_error connection_error timeout
33
+ configuration_error unmappable_message
34
+ ].freeze
35
+
36
+ # Why a recipient stopped receiving mail. Read off a SuppressedError.
37
+ SuppressionInfo = Struct.new(:address, :reason, keyword_init: true)
38
+
39
+ # The root of everything this gem raises.
40
+ #
41
+ # Rescue `Posthaste::Error` to catch every refusal, or one of the subclasses
42
+ # below to catch a KIND of refusal. Branch on `#type`, never on `#message`:
43
+ # the type is the stable contract and the sentence is not.
44
+ class Error < StandardError
45
+ attr_reader :type, :status, :body, :request_id, :retry_after_seconds
46
+
47
+ def initialize(message, type: 'unknown_error', status: 0, body: nil, request_id: nil,
48
+ retry_after_seconds: nil, api_key: nil)
49
+ # Redacted at CONSTRUCTION, not at print time. An exception message is
50
+ # copied into logs, error reporters and tickets by machinery this gem
51
+ # never sees, so the only safe moment is before the string exists.
52
+ super(Redaction.redact(message, api_key))
53
+ @type = type
54
+ @status = status
55
+ @body = body
56
+ @request_id = request_id
57
+ @retry_after_seconds = retry_after_seconds
58
+ end
59
+
60
+ # The four 429s mean four different things and nothing about the status
61
+ # says which. These two predicates are the difference between waiting a
62
+ # few seconds and hammering a wall until the calendar moves.
63
+ def rate_limited?
64
+ type == 'rate_limited' || type == 'platform_paused'
65
+ end
66
+
67
+ def quota_exhausted?
68
+ type == 'daily_limit_reached' || type == 'monthly_limit_reached'
69
+ end
70
+
71
+ # Worth repeating the same request later. NOT a promise that repeating it
72
+ # is safe — that depends on whether the request can duplicate an effect,
73
+ # which is what an idempotency key settles.
74
+ def transient?
75
+ rate_limited? || status == 408 || status == 429 || status >= 500
76
+ end
77
+
78
+ def error_field(name)
79
+ envelope = body.is_a?(Hash) ? body['error'] : nil
80
+ envelope.is_a?(Hash) ? envelope[name.to_s] : nil
81
+ end
82
+
83
+ def inspect
84
+ "#<#{self.class.name} type=#{type.inspect} status=#{status} " \
85
+ "message=#{message.inspect}>"
86
+ end
87
+ end
88
+
89
+ # Something is wrong with how the delivery method was configured — most often
90
+ # a missing API key. Raised before any request is attempted.
91
+ class ConfigurationError < Error; end
92
+
93
+ # The `Mail::Message` cannot be expressed as a Posthaste send.
94
+ #
95
+ # Raised BEFORE the request, naming the field, because the alternative — a
96
+ # quiet omission — is a team that migrates and does not notice their Bcc
97
+ # stopped arriving. Nothing is dropped in silence.
98
+ class MappingError < Error; end
99
+
100
+ # A header the platform owns, set by the application. Names the first-class
101
+ # field to use instead, because "you may not set this" without "set that" is
102
+ # a dead end for somebody mid-migration.
103
+ class UnsupportedHeaderError < MappingError; end
104
+
105
+ # The request never produced a response. `status` is 0.
106
+ class ConnectionError < Error; end
107
+
108
+ # The request opened and the server never answered in time.
109
+ class TimeoutError < ConnectionError; end
110
+
111
+ # The server answered, and the answer was a refusal.
112
+ class APIStatusError < Error; end
113
+
114
+ # 401 — the key is missing, malformed, revoked, or from another account.
115
+ class AuthenticationError < APIStatusError; end
116
+
117
+ # 403 — a real key that does not hold `emails:send`.
118
+ class PermissionDeniedError < APIStatusError; end
119
+
120
+ # 400 — the body is wrong. `error.fields` often says where.
121
+ class InvalidRequestError < APIStatusError; end
122
+
123
+ # 404 — no such id, on this account.
124
+ class NotFoundError < APIStatusError; end
125
+
126
+ # 409 — it already exists. Frequently the correct answer to a retry.
127
+ class ConflictError < APIStatusError; end
128
+
129
+ # 422 — well formed, and we will not act on it.
130
+ #
131
+ # Permanent by construction: the same bytes get the same answer, so nothing
132
+ # under this class is ever retried automatically.
133
+ class UnprocessableError < APIStatusError; end
134
+
135
+ # The recipient is on the suppression list. Read `#suppression`.
136
+ #
137
+ # Never work around this. Sending to a suppressed address is how a sending IP
138
+ # gets blocklisted, and the block lands on every other customer sharing it —
139
+ # which is why the API refuses rather than warns.
140
+ class SuppressedError < UnprocessableError
141
+ def suppression
142
+ address = error_field(:address)
143
+ reason = error_field(:reason)
144
+ return nil unless address.is_a?(String)
145
+
146
+ SuppressionInfo.new(address: address, reason: reason.is_a?(String) ? reason : nil)
147
+ end
148
+ end
149
+
150
+ # The `from` domain has not had its DKIM record verified. Publish the record
151
+ # and verify it; there is no flag that skips this.
152
+ class DomainNotVerifiedError < UnprocessableError; end
153
+
154
+ # The pre-send lint refused the CONTENT.
155
+ class ContentBlockedError < UnprocessableError
156
+ # Which check refused it, e.g. `dangerous_link` or `empty_body`.
157
+ def check
158
+ value = error_field(:check)
159
+ value.is_a?(String) ? value : nil
160
+ end
161
+
162
+ # The whole lint report, warnings included, so one fix pass can address
163
+ # everything rather than playing whack-a-mole one refusal at a time.
164
+ def findings
165
+ value = error_field(:findings)
166
+ value.is_a?(Array) ? value : []
167
+ end
168
+ end
169
+
170
+ # Too many files, too many bytes, a blocked type, or unreadable content.
171
+ class AttachmentError < UnprocessableError; end
172
+
173
+ # `scheduledAt` is out of range, or the message already left.
174
+ class ScheduleError < UnprocessableError; end
175
+
176
+ # The named template is unknown, invalid, or still in use.
177
+ class TemplateError < UnprocessableError; end
178
+
179
+ # The named message stream does not exist on this account.
180
+ class StreamError < UnprocessableError; end
181
+
182
+ # A transient throttle. Honour `retry_after_seconds` and come back.
183
+ #
184
+ # Covers `rate_limited` (this key's request rate) and `platform_paused` (the
185
+ # platform's own daily send ceiling). Both clear on their own.
186
+ class RateLimited < APIStatusError; end
187
+
188
+ # The account's allowance is spent — `Retry-After` is hours or days.
189
+ #
190
+ # A SIBLING of RateLimited, never a subclass. Retrying this in process burns
191
+ # your own request budget on calls that are all going to be refused until the
192
+ # calendar moves. Queue it, or alert.
193
+ class QuotaExhausted < APIStatusError; end
194
+
195
+ # 5xx. Safe to retry, if the request is one that repeating cannot duplicate.
196
+ class ServerError < APIStatusError; end
197
+
198
+ # Mapping a refusal onto a class.
199
+ module ErrorMapping
200
+ # By TYPE first, because the type is the stable contract and the status is
201
+ # not specific enough. Only types that say more than their status does are
202
+ # listed; everything else falls through to BY_STATUS, which is the honest
203
+ # answer for a refusal reason this version has never seen.
204
+ BY_TYPE = {
205
+ 'unauthorized' => AuthenticationError,
206
+ 'unauthenticated' => AuthenticationError,
207
+ 'csrf_failed' => AuthenticationError,
208
+ 'email_unverified' => AuthenticationError,
209
+ 'forbidden' => PermissionDeniedError,
210
+ 'invalid_request' => InvalidRequestError,
211
+ 'not_found' => NotFoundError,
212
+ 'conflict' => ConflictError,
213
+ 'address_taken' => ConflictError,
214
+ 'suppressed' => SuppressedError,
215
+ 'suppression_protected' => UnprocessableError,
216
+ 'suppression_platform' => UnprocessableError,
217
+ 'suppression_hard_bounce' => UnprocessableError,
218
+ 'domain_not_verified' => DomainNotVerifiedError,
219
+ 'content_blocked' => ContentBlockedError,
220
+ 'attachments_too_many' => AttachmentError,
221
+ 'attachments_too_large' => AttachmentError,
222
+ 'attachment_type_blocked' => AttachmentError,
223
+ 'attachment_invalid' => AttachmentError,
224
+ 'schedule_too_far' => ScheduleError,
225
+ 'not_scheduled' => ScheduleError,
226
+ 'unknown_template' => TemplateError,
227
+ 'invalid_template' => TemplateError,
228
+ 'template_in_use' => TemplateError,
229
+ 'unknown_stream' => StreamError,
230
+ # The four 429s. Two transient, two not.
231
+ 'rate_limited' => RateLimited,
232
+ 'platform_paused' => RateLimited,
233
+ 'daily_limit_reached' => QuotaExhausted,
234
+ 'monthly_limit_reached' => QuotaExhausted,
235
+ 'internal' => ServerError
236
+ # `connection_error` and `timeout` are deliberately ABSENT. They are
237
+ # synthesised by the transport, which constructs the exception directly,
238
+ # so an entry here would only ever fire on a response that carried one of
239
+ # those strings as an HTTP-level refusal — and mapping a real 408 onto
240
+ # ConnectionError ("never reached the server") would be a lie about what
241
+ # happened.
242
+ }.freeze
243
+
244
+ BY_STATUS = {
245
+ 400 => InvalidRequestError,
246
+ 401 => AuthenticationError,
247
+ 403 => PermissionDeniedError,
248
+ 404 => NotFoundError,
249
+ 409 => ConflictError,
250
+ 422 => UnprocessableError,
251
+ # An unrecognised 429 is treated as transient rather than as spent quota.
252
+ # That direction is the safe one: a wrongly-retried throttle costs a few
253
+ # seconds, while a wrongly-abandoned one drops mail that would have gone.
254
+ 429 => RateLimited
255
+ }.freeze
256
+
257
+ module_function
258
+
259
+ def class_for(error_type, status)
260
+ known = BY_TYPE[error_type]
261
+ return known if known
262
+ return ServerError if status >= 500
263
+
264
+ BY_STATUS.fetch(status, APIStatusError)
265
+ end
266
+
267
+ # Build the exception for one refused response.
268
+ def from_response(status, raw_body, retry_after_header, request_id: nil, api_key: nil)
269
+ body = begin
270
+ parsed = raw_body.to_s.empty? ? nil : JSON.parse(raw_body)
271
+ parsed.is_a?(Hash) ? parsed : nil
272
+ rescue JSON::ParserError
273
+ nil
274
+ end
275
+
276
+ envelope = body.is_a?(Hash) && body['error'].is_a?(Hash) ? body['error'] : {}
277
+ type = envelope['type'].is_a?(String) ? envelope['type'] : 'unknown_error'
278
+ message = envelope['message'].is_a?(String) ? envelope['message'] : "HTTP #{status}"
279
+
280
+ class_for(type, status).new(
281
+ message,
282
+ type: type,
283
+ status: status,
284
+ body: body,
285
+ request_id: request_id,
286
+ retry_after_seconds: parse_retry_after(retry_after_header, envelope),
287
+ api_key: api_key
288
+ )
289
+ end
290
+
291
+ # `Retry-After` is seconds or an HTTP-date. The body may also carry it as
292
+ # `retryAfterSeconds`, and the body wins when both are present because it
293
+ # is the value the limiter actually computed.
294
+ def parse_retry_after(header, envelope = {})
295
+ from_body = envelope['retryAfterSeconds']
296
+ return from_body.to_f if from_body.is_a?(Numeric)
297
+
298
+ return nil if header.nil? || header.to_s.strip.empty?
299
+
300
+ text = header.to_s.strip
301
+ return text.to_f if /\A\d+(\.\d+)?\z/.match?(text)
302
+
303
+ begin
304
+ seconds = Time.httpdate(text) - Time.now
305
+ seconds.positive? ? seconds : 0.0
306
+ rescue ArgumentError
307
+ nil
308
+ end
309
+ end
310
+ end
311
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ require_relative 'errors'
8
+ require_relative 'redaction'
9
+ require_relative 'version'
10
+
11
+ module Posthaste
12
+ # The default host. Override it with `base_url:` when self-hosting.
13
+ DEFAULT_BASE_URL = 'https://api.posthastemail.dev'
14
+
15
+ # One HTTP response, in the shape the retry loop wants.
16
+ Response = Struct.new(:status, :body, :headers, keyword_init: true) do
17
+ # HTTP header names are not case-sensitive and the sources these come from
18
+ # disagree about casing: Net::HTTP preserves what the server sent, a
19
+ # hand-written test double does whatever its author typed.
20
+ def header(name)
21
+ wanted = name.to_s.downcase
22
+ pair = (headers || {}).find { |k, _| k.to_s.downcase == wanted }
23
+ pair && pair[1]
24
+ end
25
+ end
26
+
27
+ # The seam every test stubs.
28
+ #
29
+ # A transport takes a fully-built request and returns a Response. It knows
30
+ # nothing about retries, authentication or error typing — all of which live in
31
+ # HttpClient — so a test double is four lines and still exercises the whole
32
+ # policy above it.
33
+ class NetHttpTransport
34
+ def initialize(open_timeout: 10, read_timeout: 30)
35
+ @open_timeout = open_timeout
36
+ @read_timeout = read_timeout
37
+ end
38
+
39
+ def call(method, url, headers, body)
40
+ uri = URI.parse(url)
41
+ request = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
42
+ headers.each { |name, value| request[name] = value }
43
+ request.body = body if body
44
+
45
+ http = Net::HTTP.new(uri.host, uri.port)
46
+ http.use_ssl = uri.scheme == 'https'
47
+ http.open_timeout = @open_timeout
48
+ http.read_timeout = @read_timeout
49
+ # Redirects are NOT followed. A 3xx would send the bearer token to
50
+ # whatever host the Location names, so it arrives at the caller as an
51
+ # unhandled answer instead.
52
+ response = http.request(request)
53
+
54
+ Response.new(
55
+ status: response.code.to_i,
56
+ body: response.body.to_s,
57
+ headers: response.each_header.to_h
58
+ )
59
+ end
60
+
61
+ # No key here, but the timeouts are the only state and printing them is
62
+ # useful. Defined anyway so the class never inherits a default `#inspect`
63
+ # if somebody adds an ivar later.
64
+ def inspect
65
+ "#<Posthaste::NetHttpTransport open_timeout=#{@open_timeout} read_timeout=#{@read_timeout}>"
66
+ end
67
+ end
68
+
69
+ # Authentication, the retry policy, and turning a refusal into an exception.
70
+ #
71
+ # The retry rules are ported from the Python and TypeScript SDKs rather than
72
+ # reinvented, so a Rails app and a Python worker hitting the same API behave
73
+ # the same way under the same throttle.
74
+ class HttpClient
75
+ DEFAULT_MAX_RETRIES = 2
76
+ DEFAULT_MAX_RETRY_DELAY = 60.0
77
+
78
+ # First backoff step, in seconds. Doubles per attempt, capped at 8s, with
79
+ # full jitter applied on top.
80
+ BASE_BACKOFF = 0.5
81
+ MAX_BACKOFF = 8.0
82
+
83
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, transport: nil,
84
+ max_retries: DEFAULT_MAX_RETRIES, max_retry_delay: DEFAULT_MAX_RETRY_DELAY,
85
+ open_timeout: 10, read_timeout: 30, user_agent: nil,
86
+ sleeper: nil, randomizer: nil)
87
+ @api_key = api_key.to_s
88
+ @base_url = base_url.to_s.sub(%r{/+\z}, '')
89
+ @transport = transport || NetHttpTransport.new(open_timeout: open_timeout,
90
+ read_timeout: read_timeout)
91
+ @max_retries = max_retries
92
+ @max_retry_delay = max_retry_delay
93
+ @user_agent = user_agent || self.class.default_user_agent
94
+ # Injected so the retry tests do not actually wait, and so the jitter is
95
+ # deterministic under test without the policy itself knowing it is a test.
96
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
97
+ @randomizer = randomizer || -> { Kernel.rand }
98
+ end
99
+
100
+ def self.default_user_agent
101
+ mailer = defined?(::ActionMailer::VERSION::STRING) ? ::ActionMailer::VERSION::STRING : 'none'
102
+ "posthaste-rails/#{Posthaste::VERSION} ruby/#{RUBY_VERSION} actionmailer/#{mailer}"
103
+ end
104
+
105
+ # `POST /v1/emails`.
106
+ #
107
+ # Two success statuses, and they mean different things:
108
+ # 202 {"status":"queued"} — accepted, a new message exists.
109
+ # 200 {"status":"duplicate"} — an idempotency replay. No new message was
110
+ # created; the id is the original's.
111
+ #
112
+ # `idempotent:` is the whole retry rule. Without an idempotency key a retry
113
+ # after a lost response sends the email TWICE, so a send is only repeated
114
+ # automatically when the caller supplied one.
115
+ def send_email(payload, idempotent:)
116
+ decode(perform('post', '/v1/emails', payload, idempotent: idempotent))
117
+ end
118
+
119
+ # Never prints the key. See Posthaste::Redaction for why this matters.
120
+ def inspect
121
+ "#<Posthaste::HttpClient base_url=#{@base_url.inspect} " \
122
+ "api_key=#{Redaction.describe_key(@api_key).inspect} max_retries=#{@max_retries}>"
123
+ end
124
+ alias to_s inspect
125
+
126
+ private
127
+
128
+ def decode(response)
129
+ return {} if response.body.to_s.empty?
130
+
131
+ parsed = JSON.parse(response.body)
132
+ parsed.is_a?(Hash) ? parsed : {}
133
+ rescue JSON::ParserError => e
134
+ raise ServerError.new(
135
+ "the API answered #{response.status} with a body that is not JSON: #{e.message}",
136
+ type: 'internal', status: response.status, api_key: @api_key
137
+ )
138
+ end
139
+
140
+ def perform(method, path, payload, idempotent:)
141
+ url = "#{@base_url}#{path}"
142
+ headers = build_headers(body: !payload.nil?)
143
+ body = payload.nil? ? nil : JSON.generate(payload)
144
+
145
+ attempt = 0
146
+ loop do
147
+ begin
148
+ response = @transport.call(method, url, headers, body)
149
+ rescue StandardError => e
150
+ error = transport_error(e)
151
+ # A connection that never produced a response is safe to repeat only
152
+ # under the same rule as everything else: the request has to be one
153
+ # that repeating cannot duplicate.
154
+ if idempotent && attempt < @max_retries
155
+ @sleeper.call(jittered_backoff(attempt))
156
+ attempt += 1
157
+ next
158
+ end
159
+ raise error
160
+ end
161
+
162
+ # `< 300`, not `< 400`. The transport refuses to follow redirects, so a
163
+ # 3xx arrives here as an unhandled answer rather than as a success —
164
+ # reporting it beats handing back an empty body as though it worked.
165
+ return response if response.status < 300
166
+
167
+ error = ErrorMapping.from_response(
168
+ response.status,
169
+ response.body,
170
+ response.header('retry-after'),
171
+ request_id: response.header('x-request-id'),
172
+ api_key: @api_key
173
+ )
174
+
175
+ wait = retry_delay_for(error, idempotent: idempotent, attempt: attempt)
176
+ raise error if wait.nil?
177
+
178
+ @sleeper.call(wait)
179
+ attempt += 1
180
+ end
181
+ end
182
+
183
+ # How long to wait before repeating, or nil for "do not".
184
+ #
185
+ # The decision branches on `error.type` and NOT on the status, which is the
186
+ # whole point. All FOUR of these are 429 and they mean four different
187
+ # things:
188
+ #
189
+ # rate_limited the per-key request limiter. Transient, measured
190
+ # in seconds — exactly what a retry is for.
191
+ # platform_paused the platform-wide daily send ceiling. Also
192
+ # transient, and NOT a statement about this account.
193
+ # daily_limit_reached the account's warmup cap. `Retry-After` is the
194
+ # seconds until midnight UTC.
195
+ # monthly_limit_reached the plan allowance. `Retry-After` can be weeks.
196
+ #
197
+ # A retry loop written against the status treats the last two as a hiccup
198
+ # and hammers a wall it cannot get through until the calendar moves.
199
+ def retry_delay_for(error, idempotent:, attempt:)
200
+ return nil if attempt >= @max_retries
201
+ return nil unless idempotent
202
+ # Quota exhaustion. Never in process, whatever Retry-After says.
203
+ return nil if error.quota_exhausted?
204
+ return nil unless error.transient?
205
+
206
+ requested = error.retry_after_seconds
207
+ unless requested.nil?
208
+ # Honour it — unless honouring it would mean blocking for longer than a
209
+ # caller could reasonably want, in which case hand the error back and
210
+ # let them schedule.
211
+ return nil if requested > @max_retry_delay
212
+
213
+ return [requested, 0.0].max
214
+ end
215
+
216
+ jittered_backoff(attempt)
217
+ end
218
+
219
+ # Exponential with FULL jitter.
220
+ #
221
+ # Without jitter every client that failed on the same upstream blip retries
222
+ # in the same millisecond, and the recovery attempt is itself a thundering
223
+ # herd against a service that has only just come back.
224
+ def jittered_backoff(attempt)
225
+ ceiling = [MAX_BACKOFF, BASE_BACKOFF * (2.0**attempt)].min
226
+ @randomizer.call * ceiling
227
+ end
228
+
229
+ def build_headers(body:)
230
+ headers = {
231
+ 'accept' => 'application/json',
232
+ 'user-agent' => @user_agent
233
+ }
234
+ headers['content-type'] = 'application/json' if body
235
+ # Last, so nothing above can replace it.
236
+ headers['authorization'] = "Bearer #{@api_key}"
237
+ headers
238
+ end
239
+
240
+ def transport_error(cause)
241
+ timeout = cause.is_a?(Timeout::Error) ||
242
+ cause.class.name.include?('Timeout') ||
243
+ cause.is_a?(Net::OpenTimeout) ||
244
+ cause.is_a?(Net::ReadTimeout)
245
+ klass = timeout ? Posthaste::TimeoutError : Posthaste::ConnectionError
246
+ # The cause's message can quote the request line, which can quote the
247
+ # Authorization header, so it goes through `redact` like everything else.
248
+ klass.new(
249
+ "#{cause.class}: #{cause.message}",
250
+ type: timeout ? 'timeout' : 'connection_error',
251
+ status: 0,
252
+ api_key: @api_key
253
+ )
254
+ end
255
+ end
256
+ end