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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +323 -0
- data/lib/posthaste/actionmailer.rb +68 -0
- data/lib/posthaste/delivery_method.rb +106 -0
- data/lib/posthaste/errors.rb +311 -0
- data/lib/posthaste/http_client.rb +256 -0
- data/lib/posthaste/message_mapper.rb +494 -0
- data/lib/posthaste/railtie.rb +31 -0
- data/lib/posthaste/redaction.rb +62 -0
- data/lib/posthaste/result.rb +61 -0
- data/lib/posthaste/version.rb +8 -0
- data/lib/posthaste-rails.rb +6 -0
- metadata +77 -0
|
@@ -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'
|