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,494 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+
6
+ require_relative 'errors'
7
+
8
+ module Posthaste
9
+ # Something this gem had to change to express the message. Reported on
10
+ # `mail.posthaste_result.warnings` and passed to the `on_warning` hook, so you
11
+ # find out on the first send rather than in a support ticket.
12
+ MappingWarning = Struct.new(:field, :code, :message, keyword_init: true) do
13
+ def to_s
14
+ "#{field}: #{message}"
15
+ end
16
+ end
17
+
18
+ # Turning a `Mail::Message` into the body of `POST /v1/emails`.
19
+ #
20
+ # ActionMailer hands the delivery method a fully-composed `Mail::Message`,
21
+ # which is a rich RFC 5322 object; the send API takes FIELDS. Every part of
22
+ # the message therefore lands in one of four buckets, and the governing rule
23
+ # is that NOTHING IS DROPPED IN SILENCE — a team that migrates and does not
24
+ # notice their Bcc stopped arriving is the outcome this design exists to
25
+ # prevent. So:
26
+ #
27
+ # mapped it becomes a field, unchanged
28
+ # mapped, noted it becomes a field with something changed, and a
29
+ # MappingWarning says what
30
+ # refused a synchronous MappingError naming the field, before any
31
+ # request is made
32
+ # generated the platform composes it (Date, Message-ID, MIME structure,
33
+ # DKIM-Signature) and the Mail object's copy is an artefact of
34
+ # Mail, not authored intent, so it is discarded quietly
35
+ class MessageMapper
36
+ # Header names the platform owns, ported from `packages/mail/src/mime.ts`'s
37
+ # RESERVED_HEADER_NAMES.
38
+ #
39
+ # Sending one of these is a 400 from the API's own `reservedHeader`
40
+ # refinement, so catching it here only changes WHEN the caller finds out —
41
+ # synchronously, naming the first-class field to use instead, rather than
42
+ # after a round trip with a message about a "refinement".
43
+ #
44
+ # WHY EACH GROUP IS RESERVED (the reasons are the point): identity and
45
+ # routing headers are DKIM-signed by the platform, so a customer copy either
46
+ # duplicates a signed header — some receivers reject two `From`s — or drifts
47
+ # from the signed value and fails DMARC. Delivery-controlling headers
48
+ # (`Bcc`, `Cc`, `Sender`, `Return-Path`) would silently fan a message out or
49
+ # mislead bounce handling. Trust and trace headers (`DKIM-Signature`,
50
+ # `Received`, `Authentication-Results`, `ARC-*`) are the audit trail a
51
+ # receiver reads, and forging them is forging it. `Feedback-ID` is the key
52
+ # Google aggregates complaint rates by, so a customer who could set it could
53
+ # file their complaints under somebody else's identifier.
54
+ RESERVED_HEADER_NAMES = %w[
55
+ from to cc bcc sender subject date message-id mime-version content-type
56
+ content-transfer-encoding content-disposition return-path dkim-signature
57
+ received authentication-results list-unsubscribe list-unsubscribe-post
58
+ feedback-id
59
+ ].freeze
60
+
61
+ # Reserved names this mapper reads into a first-class field. Their value is
62
+ # carried, not lost, so they never reach the `headers` map and never warn.
63
+ CONSUMED = %w[from to cc bcc subject reply-to list-unsubscribe].freeze
64
+
65
+ # Reserved names that are an artefact of composing the message in Ruby.
66
+ #
67
+ # `Date` and `Message-ID` are in here on EVIDENCE, not on assumption: Mail
68
+ # puts both on every message before the delivery method sees it, so warning
69
+ # about them would fire on every single send and train people to ignore
70
+ # warnings — which is precisely how the ones that matter get missed. The
71
+ # MIME headers are structure the platform rebuilds, and the bytes the
72
+ # recipient's client decodes are identical either way.
73
+ #
74
+ # All of them are listed in the README under what the platform composes.
75
+ GENERATED = %w[
76
+ date message-id mime-version content-type content-transfer-encoding
77
+ content-disposition list-unsubscribe-post
78
+ ].freeze
79
+
80
+ # The first-class field to reach for instead, named in the error, because
81
+ # "you may not set this" without "set that" is a dead end for somebody
82
+ # mid-migration.
83
+ ALTERNATIVE = {
84
+ 'sender' => 'the mailer\'s `from:`',
85
+ 'return-path' => 'nothing — bounces are routed by the platform\'s own VERP return path',
86
+ 'dkim-signature' => 'nothing — the platform signs every message with your verified key',
87
+ 'received' => 'nothing — the trace is written by the receiving hops',
88
+ 'authentication-results' => 'nothing — this is written by the receiver, not the sender',
89
+ 'feedback-id' => '`X-Posthaste-Tags`, which is what the log and analytics filter on'
90
+ }.freeze
91
+
92
+ # Fields the send API has that ActionMailer has no word for.
93
+ #
94
+ # An `X-Posthaste-…` header is the one extension point ActionMailer offers
95
+ # without changing a single `mail()` call's shape: `mail()` passes any
96
+ # unrecognised key straight through as a header, so
97
+ # `mail(to: …, 'X-Posthaste-Stream' => 'transactional')` works today in
98
+ # every Rails version. They are CONSUMED here and never forwarded.
99
+ CONTROL_PREFIX = 'x-posthaste-'
100
+ CONTROL_HEADERS = {
101
+ 'x-posthaste-stream' => :stream,
102
+ 'x-posthaste-tags' => :tags,
103
+ 'x-posthaste-metadata' => :metadata,
104
+ 'x-posthaste-idempotency-key' => :idempotency_key,
105
+ 'x-posthaste-template' => :template,
106
+ 'x-posthaste-template-version' => :template_version,
107
+ 'x-posthaste-variables' => :variables,
108
+ 'x-posthaste-scheduled-at' => :scheduled_at
109
+ }.freeze
110
+
111
+ attr_reader :warnings
112
+
113
+ # `defaults` are the settings-level values applied to every message:
114
+ # `stream`, `tags`, `metadata`. A control header on an individual message
115
+ # overrides them.
116
+ def initialize(defaults = {})
117
+ @defaults = defaults
118
+ @warnings = []
119
+ end
120
+
121
+ # => [payload_hash, warnings]
122
+ def call(mail)
123
+ @warnings = []
124
+ control = extract_control(mail)
125
+
126
+ payload = {}
127
+ payload[:from] = map_from(mail)
128
+ payload[:to] = map_recipients(mail, :to)
129
+
130
+ cc = map_recipients(mail, :cc)
131
+ payload[:cc] = cc unless cc.empty?
132
+ bcc = map_recipients(mail, :bcc)
133
+ payload[:bcc] = bcc unless bcc.empty?
134
+
135
+ subject = mail.subject
136
+ payload[:subject] = subject if subject && !subject.empty?
137
+
138
+ reply_to = map_reply_to(mail)
139
+ payload[:replyTo] = reply_to if reply_to
140
+
141
+ list_unsubscribe = single_header(mail, 'List-Unsubscribe')
142
+ payload[:listUnsubscribe] = list_unsubscribe if list_unsubscribe
143
+
144
+ apply_body(payload, mail, control)
145
+ attachments = map_attachments(mail)
146
+ payload[:attachments] = attachments unless attachments.empty?
147
+
148
+ headers = map_headers(mail)
149
+ payload[:headers] = headers unless headers.empty?
150
+
151
+ apply_control(payload, control)
152
+
153
+ if payload[:to].empty?
154
+ raise MappingError.new(
155
+ 'the message has no `to` recipient, so there is nobody to send it to',
156
+ type: 'unmappable_message'
157
+ )
158
+ end
159
+
160
+ [payload, @warnings]
161
+ end
162
+
163
+ private
164
+
165
+ def warn(field, code, message)
166
+ @warnings << MappingWarning.new(field: field, code: code, message: message)
167
+ end
168
+
169
+ # `from` keeps the display name — the API accepts a full `Name <a@b>` — but
170
+ # there can only be one of it.
171
+ def map_from(mail)
172
+ field = mail[:from]
173
+ formatted = field ? Array(field.formatted) : []
174
+
175
+ if formatted.empty?
176
+ raise MappingError.new(
177
+ 'the message has no `from` address. Set one on the mailer, or as ' \
178
+ '`default from:` on ActionMailer::Base.',
179
+ type: 'unmappable_message'
180
+ )
181
+ end
182
+
183
+ if formatted.length > 1
184
+ # Nearly always an UNQUOTED COMMA in a display name — `from: "Acme,
185
+ # Inc. <billing@acme.test>"` is two addresses to any RFC 5322 parser,
186
+ # `Acme` and `"Inc." <billing@acme.test>`. Refusing names the trap,
187
+ # because silently taking the first would send from a domain-less
188
+ # address and fail far away from the cause.
189
+ raise MappingError.new(
190
+ "`from` resolved to #{formatted.length} addresses (#{formatted.join('; ')}) and a " \
191
+ 'message has exactly one sender. If the display name contains a comma it must be ' \
192
+ 'quoted: from: %q("Acme, Inc." <billing@acme.test>).',
193
+ type: 'unmappable_message'
194
+ )
195
+ end
196
+
197
+ formatted.first
198
+ end
199
+
200
+ # Recipients are addressed by BARE ADDRESS.
201
+ #
202
+ # This is the one place the mapping loses something a recipient could have
203
+ # seen, and it is only how their own address is labelled in their own client
204
+ # — which most clients override from the address book anyway. Refusing
205
+ # instead would break the migration for nearly every application that has
206
+ # ever set a recipient name, so it is a warning.
207
+ def map_recipients(mail, field_name)
208
+ field = mail[field_name]
209
+ return [] unless field
210
+
211
+ addresses = Array(mail.public_send(field_name))
212
+ named = Array(field.formatted).count { |f| f.include?('<') }
213
+ if named.positive?
214
+ warn(field_name.to_s, 'display_name_dropped',
215
+ "#{named} display name(s) on `#{field_name}` were dropped; the API addresses " \
216
+ 'recipients by bare address. The addresses themselves are unchanged.')
217
+ end
218
+ addresses.compact
219
+ end
220
+
221
+ def map_reply_to(mail)
222
+ field = mail[:reply_to]
223
+ return nil unless field
224
+
225
+ formatted = Array(field.formatted)
226
+ return nil if formatted.empty?
227
+
228
+ formatted.join(', ')
229
+ end
230
+
231
+ def single_header(mail, name)
232
+ field = mail[name]
233
+ return nil unless field
234
+
235
+ value = field.respond_to?(:value) ? field.value : field.to_s
236
+ value.to_s.empty? ? nil : value.to_s
237
+ end
238
+
239
+ # `text` and `html`, from wherever the message put them.
240
+ #
241
+ # `Mail::Message#text_part` / `#html_part` walk `all_parts` and skip
242
+ # attachments, so `multipart/mixed[ multipart/alternative[text,html], pdf ]`
243
+ # — the shape ActionMailer builds for a mailer with two templates and an
244
+ # attachment — resolves correctly. A non-multipart message has neither, and
245
+ # its own Content-Type decides which field the body is.
246
+ def apply_body(payload, mail, control)
247
+ text_part = mail.text_part
248
+ html_part = mail.html_part
249
+ refuse_unmappable_parts(mail, text_part, html_part)
250
+
251
+ text = part_body(text_part, mail)
252
+ html = part_body(html_part, mail)
253
+
254
+ if text.nil? && html.nil? && !mail.multipart?
255
+ body = decode_text(mail, mail)
256
+ unless body.nil? || body.empty?
257
+ if mail.mime_type == 'text/html'
258
+ html = body
259
+ else
260
+ text = body
261
+ end
262
+ end
263
+ end
264
+
265
+ if control[:template]
266
+ # A stored template supplies the content. Sending both would make the
267
+ # API choose, and which one it chose would be a surprise either way.
268
+ if text || html
269
+ warn('body', 'template_supersedes_body',
270
+ 'X-Posthaste-Template is set, so the stored template supplies the content and ' \
271
+ 'the rendered view was not sent. Remove one of the two.')
272
+ end
273
+ return
274
+ end
275
+
276
+ payload[:text] = text if text
277
+ payload[:html] = html if html
278
+ end
279
+
280
+ def part_body(part, mail)
281
+ return nil unless part
282
+
283
+ decode_text(part, mail)
284
+ end
285
+
286
+ # A part that is neither body nor attachment is REFUSED.
287
+ #
288
+ # This one is not theoretical, and it is the sharpest edge of "fields, not
289
+ # a MIME document". Two shapes fall through every other check:
290
+ #
291
+ # * a `text/calendar` part — the standard way Rails apps send meeting
292
+ # invitations — which is not `text_part`, not `html_part`, and not an
293
+ # attachment;
294
+ # * an attachment part with no filename, which `Mail::Message#attachments`
295
+ # does not return at all, because Mail identifies an attachment BY its
296
+ # filename.
297
+ #
298
+ # Both are invisible to `mail.attachments`, so without this the message
299
+ # would send, the API would accept it, and the part would simply not exist
300
+ # on the delivered mail. That is the silent loss this whole mapper is built
301
+ # to make impossible. The SMTP relay accepts a complete RFC 5322 message and
302
+ # is the right answer for these.
303
+ def refuse_unmappable_parts(mail, text_part, html_part)
304
+ return unless mail.multipart?
305
+
306
+ known = [text_part, html_part].compact
307
+ mail.all_parts.each do |part|
308
+ next if part.multipart?
309
+ next if part.attachment?
310
+ next if known.any? { |k| k.equal?(part) }
311
+
312
+ raise MappingError.new(
313
+ "the message has a #{part.mime_type || 'typeless'} part that is neither the text " \
314
+ 'body, the html body, nor a named attachment, and the send API has no field for it. ' \
315
+ 'Give it a filename to send it as an attachment, or use the SMTP relay, which ' \
316
+ 'accepts a complete MIME message.',
317
+ type: 'unmappable_message'
318
+ )
319
+ end
320
+ end
321
+
322
+ # A `Mail::Part`'s `decoded` undoes the transfer encoding but hands back a
323
+ # String tagged with whatever encoding Ruby guessed. JSON generation needs
324
+ # valid UTF-8, so the charset the part declares is applied and the result is
325
+ # transcoded — never `force_encoding('UTF-8')` alone, which mislabels
326
+ # Latin-1 bytes as UTF-8 and produces invalid JSON at generate time.
327
+ def decode_text(part, mail)
328
+ raw = part.body.decoded
329
+ return nil if raw.nil?
330
+
331
+ charset = part.charset || mail.charset || 'UTF-8'
332
+ begin
333
+ raw.dup.force_encoding(charset).encode('UTF-8')
334
+ rescue ArgumentError, EncodingError
335
+ # An undeclared or wrong charset. Scrubbing beats raising: the message
336
+ # is still delivered and the damage is confined to the bytes that were
337
+ # already unreadable.
338
+ warn('body', 'charset_scrubbed',
339
+ "the body declared charset #{charset.inspect} but does not decode as it; " \
340
+ 'unreadable bytes were replaced.')
341
+ raw.dup.force_encoding('UTF-8').scrub
342
+ end
343
+ end
344
+
345
+ def map_attachments(mail)
346
+ mail.attachments.map.with_index do |attachment, index|
347
+ filename = attachment.filename
348
+ if filename.nil? || filename.to_s.empty?
349
+ raise MappingError.new(
350
+ "attachment ##{index + 1} has no filename, and the API requires one. Give it a " \
351
+ "name: `attachments['report.pdf'] = …`.",
352
+ type: 'unmappable_message'
353
+ )
354
+ end
355
+
356
+ entry = {
357
+ filename: filename.to_s,
358
+ contentType: attachment.mime_type || 'application/octet-stream',
359
+ content: Base64.strict_encode64(attachment.body.decoded.to_s),
360
+ disposition: attachment.inline? ? 'inline' : 'attachment'
361
+ }
362
+ # Only for inline parts. `Mail::Part#cid` MANUFACTURES a Content-ID for
363
+ # any attachment that lacks one, so sending it unconditionally would
364
+ # attach a meaningless cid to every ordinary file.
365
+ entry[:cid] = attachment.cid if attachment.inline? && attachment.cid
366
+ entry
367
+ end
368
+ end
369
+
370
+ # Everything left over.
371
+ def map_headers(mail)
372
+ headers = {}
373
+ seen = {}
374
+
375
+ mail.header.fields.each do |field|
376
+ name = field.name.to_s
377
+ lower = name.downcase
378
+
379
+ next if CONSUMED.include?(lower)
380
+ next if lower.start_with?(CONTROL_PREFIX)
381
+ next if GENERATED.include?(lower)
382
+
383
+ if RESERVED_HEADER_NAMES.include?(lower) || lower.start_with?('arc-')
384
+ alternative = ALTERNATIVE[lower] || 'an `X-` header of your own'
385
+ raise UnsupportedHeaderError.new(
386
+ "`#{name}` is a header the platform owns and signs; setting it would either " \
387
+ "duplicate a DKIM-signed header or make the signature disagree with the message. " \
388
+ "Use #{alternative} instead.",
389
+ type: 'unmappable_message'
390
+ )
391
+ end
392
+
393
+ value = field.respond_to?(:value) ? field.value.to_s : field.to_s
394
+ if seen.key?(lower)
395
+ # The API's `headers` is one value per name. Keeping one of the two
396
+ # would be a silent choice about which of them the recipient sees.
397
+ raise MappingError.new(
398
+ "`#{name}` is set more than once (#{seen[lower].inspect} and #{value.inspect}) " \
399
+ 'and the API carries one value per header name. Combine them, or drop one.',
400
+ type: 'unmappable_message'
401
+ )
402
+ end
403
+
404
+ seen[lower] = value
405
+ headers[name] = value
406
+ end
407
+
408
+ headers
409
+ end
410
+
411
+ # Read the `X-Posthaste-…` headers off the message and remove them, so they
412
+ # can never reach the wire as ordinary custom headers.
413
+ def extract_control(mail)
414
+ found = {}
415
+
416
+ mail.header.fields.select { |f| f.name.to_s.downcase.start_with?(CONTROL_PREFIX) }
417
+ .each do |field|
418
+ name = field.name.to_s
419
+ key = CONTROL_HEADERS[name.downcase]
420
+ unless key
421
+ # A typo in a control header would otherwise be forwarded as an
422
+ # inert custom header — the send would succeed and quietly ignore the
423
+ # stream, tags or idempotency key it was told to use.
424
+ raise MappingError.new(
425
+ "`#{name}` is not a Posthaste control header. Known ones are: " \
426
+ "#{CONTROL_HEADERS.keys.sort.join(', ')}.",
427
+ type: 'unmappable_message'
428
+ )
429
+ end
430
+
431
+ found[key] = field.respond_to?(:value) ? field.value.to_s : field.to_s
432
+ end
433
+
434
+ found
435
+ end
436
+
437
+ def apply_control(payload, control)
438
+ stream = control[:stream] || @defaults[:stream]
439
+ payload[:stream] = stream.to_s if stream
440
+
441
+ tags = control.key?(:tags) ? split_list(control[:tags]) : Array(@defaults[:tags])
442
+ payload[:tags] = tags.map(&:to_s) unless tags.empty?
443
+
444
+ metadata = control.key?(:metadata) ? json_object(control[:metadata], 'X-Posthaste-Metadata') : @defaults[:metadata]
445
+ payload[:metadata] = stringify(metadata) if metadata && !metadata.empty?
446
+
447
+ idempotency_key = control[:idempotency_key]
448
+ payload[:idempotencyKey] = idempotency_key.to_s if idempotency_key
449
+
450
+ payload[:template] = control[:template].to_s if control[:template]
451
+
452
+ if control[:template_version]
453
+ version = Integer(control[:template_version], exception: false)
454
+ unless version
455
+ raise MappingError.new(
456
+ 'X-Posthaste-Template-Version must be an integer, got ' \
457
+ "#{control[:template_version].inspect}.",
458
+ type: 'unmappable_message'
459
+ )
460
+ end
461
+ payload[:templateVersion] = version
462
+ end
463
+
464
+ if control[:variables]
465
+ payload[:variables] = stringify(json_object(control[:variables], 'X-Posthaste-Variables'))
466
+ end
467
+
468
+ payload[:scheduledAt] = control[:scheduled_at].to_s if control[:scheduled_at]
469
+ end
470
+
471
+ def split_list(value)
472
+ value.to_s.split(',').map(&:strip).reject(&:empty?)
473
+ end
474
+
475
+ def json_object(value, header_name)
476
+ parsed = JSON.parse(value.to_s)
477
+ unless parsed.is_a?(Hash)
478
+ raise MappingError.new("#{header_name} must be a JSON object.", type: 'unmappable_message')
479
+ end
480
+
481
+ parsed
482
+ rescue JSON::ParserError => e
483
+ raise MappingError.new("#{header_name} is not valid JSON: #{e.message}",
484
+ type: 'unmappable_message')
485
+ end
486
+
487
+ # The API takes `Record<string, string>` for both metadata and variables. A
488
+ # number or a boolean here is an ordinary thing for a Rails app to write and
489
+ # would be a 400 from the server; coercing costs nothing and reads the same.
490
+ def stringify(hash)
491
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v.to_s }
492
+ end
493
+ end
494
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module Posthaste
6
+ # What the Railtie is for — and it is one specific thing.
7
+ #
8
+ # `config.action_mailer.posthaste_settings = { … }` only works if
9
+ # `add_delivery_method(:posthaste, …)` has already defined that accessor when
10
+ # Rails applies the `config.action_mailer.*` options. Rails applies them in
11
+ # its own `action_mailer.set_configs` initializer.
12
+ #
13
+ # The `ActiveSupport.on_load(:action_mailer)` hook in `actionmailer.rb`
14
+ # normally wins that race on its own, because the gem is required by
15
+ # `Bundler.require` before any initializer runs and load hooks fire in
16
+ # registration order. It does NOT win when the gem is listed as
17
+ # `require: false` and required later from `config/initializers/…`, which is
18
+ # a completely ordinary thing to do — and the failure mode is a
19
+ # `NoMethodError: undefined method 'posthaste_settings='` from
20
+ # `production.rb`, pointing at the configuration rather than at the ordering.
21
+ #
22
+ # `before: 'action_mailer.set_configs'` removes the race in both cases.
23
+ class Railtie < ::Rails::Railtie
24
+ initializer 'posthaste.add_delivery_method', before: 'action_mailer.set_configs' do
25
+ ActiveSupport.on_load(:action_mailer) do
26
+ Posthaste.register_delivery_method!(self)
27
+ Posthaste.extend_mail_message!
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Posthaste
4
+ # Keeping the API key out of everything that can be printed.
5
+ #
6
+ # A key is a bearer credential: whoever holds it can send mail from every
7
+ # verified domain on the account. The places it escapes from are not the ones
8
+ # people guard — nobody logs the key on purpose. In Ruby it escapes through
9
+ # the DEFAULT `#inspect`, which prints every instance variable, and a lot of
10
+ # things call `#inspect` on your behalf:
11
+ #
12
+ # * `p object`, and the console echoing a return value
13
+ # * a `binding.irb` session pasted into a ticket
14
+ # * Rails' exception page, which inspects the delivery method
15
+ # * an error reporter (Sentry, Bugsnag, Honeybadger) capturing locals and
16
+ # shipping them to a third party
17
+ #
18
+ # So every object in this gem that can reach the key defines its own
19
+ # `#inspect`, and every string built for a human goes through `redact` first.
20
+ module Redaction
21
+ REDACTED = '***redacted***'
22
+
23
+ # `ph_live_…` / `ph_test_…`. The prefix is not secret — it is printed in the
24
+ # dashboard beside every key — and it is the one piece that helps somebody
25
+ # staring at a 401 work out that they pasted the test key into production.
26
+ PREFIX = /\Aph_(?:live|test)_/
27
+
28
+ # Anything key-shaped, wherever it turns up. Used for text this gem did not
29
+ # build itself — a server message that echoed the credential back, a
30
+ # transport error that quoted the request line — where there is no key to
31
+ # compare against.
32
+ KEY_SHAPED = /ph_(?:live|test)_[A-Za-z0-9_-]{8,}/
33
+
34
+ module_function
35
+
36
+ # A label for a key that cannot be turned back into the key.
37
+ #
38
+ # Deliberately NOT the usual "last four characters" convention. Four
39
+ # characters of a token this size is not enough to authenticate with, but it
40
+ # is enough to confirm a guess, and the thing it is normally used for —
41
+ # telling two keys apart — is served just as well by the environment prefix
42
+ # without narrowing the search space at all.
43
+ def describe_key(api_key)
44
+ return '(none)' if api_key.nil? || api_key.to_s.empty?
45
+
46
+ match = PREFIX.match(api_key.to_s)
47
+ "#{match ? match[0] : ''}#{REDACTED}"
48
+ end
49
+
50
+ # Remove the key from a string meant for a human.
51
+ #
52
+ # Two passes on purpose. The first removes this client's own key, which
53
+ # catches it however short or oddly formatted it is. The second removes
54
+ # anything key-shaped, which catches a DIFFERENT account's key echoed back
55
+ # by a server or quoted by a library underneath us.
56
+ def redact(text, api_key = nil)
57
+ out = text.to_s
58
+ out = out.gsub(api_key.to_s, REDACTED) if api_key && !api_key.to_s.empty?
59
+ out.gsub(KEY_SHAPED, REDACTED)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Posthaste
4
+ # What the API said about one send.
5
+ #
6
+ # Reachable as `mail.posthaste_result` after `deliver_now`, because
7
+ # ActionMailer's own return value is the `Mail::Message` and there is nowhere
8
+ # else to put a message id that a caller needs in order to look the delivery
9
+ # up later.
10
+ class Result
11
+ attr_reader :id, :status, :group_id, :scheduled_at, :emails, :suppressed,
12
+ :warnings, :mapping_warnings, :raw
13
+
14
+ def initialize(body, mapping_warnings: [])
15
+ @raw = body
16
+ @id = body['id']
17
+ @status = body['status']
18
+ @group_id = body['groupId']
19
+ @scheduled_at = body['scheduledAt']
20
+ # Per-recipient outcome, present only when there was more than one.
21
+ @emails = body['emails'] || []
22
+ # Recipients skipped because they are on the suppression list. Repeated
23
+ # here as well as inside `emails` deliberately: a recipient we did not
24
+ # send to is the one outcome that must be impossible to miss.
25
+ @suppressed = body['suppressed'] || []
26
+ # The platform's pre-send lint findings, warnings included.
27
+ @warnings = body['warnings'] || []
28
+ # What this gem had to change to express the message.
29
+ @mapping_warnings = mapping_warnings
30
+ end
31
+
32
+ # An idempotency replay: no new message was created and `id` is the
33
+ # original's.
34
+ #
35
+ # Read off the BODY, not off a 200-versus-202, because the body is the
36
+ # authoritative answer and survives a proxy that normalises the status.
37
+ def duplicate?
38
+ status == 'duplicate'
39
+ end
40
+
41
+ def scheduled?
42
+ status == 'scheduled'
43
+ end
44
+
45
+ def inspect
46
+ "#<Posthaste::Result id=#{id.inspect} status=#{status.inspect} " \
47
+ "suppressed=#{suppressed.length} warnings=#{warnings.length}>"
48
+ end
49
+ end
50
+
51
+ # `Mail::Message#posthaste_result`.
52
+ #
53
+ # The only method this gem adds to somebody else's class, and it is additive:
54
+ # it reads an instance variable this gem set and returns nil otherwise, so a
55
+ # message delivered by any other delivery method is unaffected.
56
+ module MessageExtensions
57
+ def posthaste_result
58
+ defined?(@posthaste_result) ? @posthaste_result : nil
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Posthaste
4
+ # Kept in step with the gemspec, which reads it from here rather than
5
+ # restating it — a version stated twice is a version that disagrees with
6
+ # itself the first time somebody bumps one of them.
7
+ VERSION = '0.1.0'
8
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler requires the file named after the gem. This is that file; the code
4
+ # lives in `posthaste/actionmailer.rb`, because what it registers is an
5
+ # ActionMailer delivery method and the path should say so.
6
+ require_relative 'posthaste/actionmailer'