Class: Posthaste::MessageMapper

Inherits:
Object
  • Object
show all
Defined in:
lib/posthaste/message_mapper.rb

Overview

Turning a Mail::Message into the body of POST /v1/emails.

ActionMailer hands the delivery method a fully-composed Mail::Message, which is a rich RFC 5322 object; the send API takes FIELDS. Every part of the message therefore lands in one of four buckets, and the governing rule is that NOTHING IS DROPPED IN SILENCE — a team that migrates and does not notice their Bcc stopped arriving is the outcome this design exists to prevent. So:

mapped        it becomes a field, unchanged
mapped, noted it becomes a field with something changed, and a
            MappingWarning says what
refused       a synchronous MappingError naming the field, before any
            request is made
generated     the platform composes it (Date, Message-ID, MIME structure,
            DKIM-Signature) and the Mail object's copy is an artefact of
            Mail, not authored intent, so it is discarded quietly

Constant Summary collapse

RESERVED_HEADER_NAMES =

Header names the platform owns, ported from packages/mail/src/mime.ts's RESERVED_HEADER_NAMES.

Sending one of these is a 400 from the API's own reservedHeader refinement, so catching it here only changes WHEN the caller finds out — synchronously, naming the first-class field to use instead, rather than after a round trip with a message about a "refinement".

WHY EACH GROUP IS RESERVED (the reasons are the point): identity and routing headers are DKIM-signed by the platform, so a customer copy either duplicates a signed header — some receivers reject two Froms — or drifts from the signed value and fails DMARC. Delivery-controlling headers (Bcc, Cc, Sender, Return-Path) would silently fan a message out or mislead bounce handling. Trust and trace headers (DKIM-Signature, Received, Authentication-Results, ARC-*) are the audit trail a receiver reads, and forging them is forging it. Feedback-ID is the key Google aggregates complaint rates by, so a customer who could set it could file their complaints under somebody else's identifier.

%w[
  from to cc bcc sender subject date message-id mime-version content-type
  content-transfer-encoding content-disposition return-path dkim-signature
  received authentication-results list-unsubscribe list-unsubscribe-post
  feedback-id
].freeze
CONSUMED =

Reserved names this mapper reads into a first-class field. Their value is carried, not lost, so they never reach the headers map and never warn.

%w[from to cc bcc subject reply-to list-unsubscribe].freeze
GENERATED =

Reserved names that are an artefact of composing the message in Ruby.

Date and Message-ID are in here on EVIDENCE, not on assumption: Mail puts both on every message before the delivery method sees it, so warning about them would fire on every single send and train people to ignore warnings — which is precisely how the ones that matter get missed. The MIME headers are structure the platform rebuilds, and the bytes the recipient's client decodes are identical either way.

All of them are listed in the README under what the platform composes.

%w[
  date message-id mime-version content-type content-transfer-encoding
  content-disposition list-unsubscribe-post
].freeze
ALTERNATIVE =

The first-class field to reach for instead, named in the error, because "you may not set this" without "set that" is a dead end for somebody mid-migration.

{
  'sender' => 'the mailer\'s `from:`',
  'return-path' => 'nothing — bounces are routed by the platform\'s own VERP return path',
  'dkim-signature' => 'nothing — the platform signs every message with your verified key',
  'received' => 'nothing — the trace is written by the receiving hops',
  'authentication-results' => 'nothing — this is written by the receiver, not the sender',
  'feedback-id' => '`X-Posthaste-Tags`, which is what the log and analytics filter on'
}.freeze
CONTROL_PREFIX =

Fields the send API has that ActionMailer has no word for.

An X-Posthaste-… header is the one extension point ActionMailer offers without changing a single mail() call's shape: mail() passes any unrecognised key straight through as a header, so mail(to: …, 'X-Posthaste-Stream' => 'transactional') works today in every Rails version. They are CONSUMED here and never forwarded.

'x-posthaste-'
CONTROL_HEADERS =
{
  'x-posthaste-stream' => :stream,
  'x-posthaste-tags' => :tags,
  'x-posthaste-metadata' => :metadata,
  'x-posthaste-idempotency-key' => :idempotency_key,
  'x-posthaste-template' => :template,
  'x-posthaste-template-version' => :template_version,
  'x-posthaste-variables' => :variables,
  'x-posthaste-scheduled-at' => :scheduled_at
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(defaults = {}) ⇒ MessageMapper

defaults are the settings-level values applied to every message: stream, tags, metadata. A control header on an individual message overrides them.



116
117
118
119
# File 'lib/posthaste/message_mapper.rb', line 116

def initialize(defaults = {})
  @defaults = defaults
  @warnings = []
end

Instance Attribute Details

#warningsObject (readonly)

Returns the value of attribute warnings.



111
112
113
# File 'lib/posthaste/message_mapper.rb', line 111

def warnings
  @warnings
end

Instance Method Details

#call(mail) ⇒ Object

=> [payload_hash, warnings]



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/posthaste/message_mapper.rb', line 122

def call(mail)
  @warnings = []
  control = extract_control(mail)

  payload = {}
  payload[:from] = map_from(mail)
  payload[:to] = map_recipients(mail, :to)

  cc = map_recipients(mail, :cc)
  payload[:cc] = cc unless cc.empty?
  bcc = map_recipients(mail, :bcc)
  payload[:bcc] = bcc unless bcc.empty?

  subject = mail.subject
  payload[:subject] = subject if subject && !subject.empty?

  reply_to = map_reply_to(mail)
  payload[:replyTo] = reply_to if reply_to

  list_unsubscribe = single_header(mail, 'List-Unsubscribe')
  payload[:listUnsubscribe] = list_unsubscribe if list_unsubscribe

  apply_body(payload, mail, control)
  attachments = map_attachments(mail)
  payload[:attachments] = attachments unless attachments.empty?

  headers = map_headers(mail)
  payload[:headers] = headers unless headers.empty?

  apply_control(payload, control)

  if payload[:to].empty?
    raise MappingError.new(
      'the message has no `to` recipient, so there is nobody to send it to',
      type: 'unmappable_message'
    )
  end

  [payload, @warnings]
end