Module: Mailkube::Rails::Payload

Defined in:
lib/mailkube/rails/payload.rb,
sig/mailkube/rails.rbs

Overview

The one place a Mail::Message becomes SDK send arguments.

Every entry point calls Payload.build; nothing else maps a message. A gem with two entry points and two mappings has two behaviours, and only one of them is the one the tests cover.

Scope is deliberately what ActionMailer's message type natively expresses: sender, recipients, subject, both bodies, attachments and custom headers. Tags, topics, templates, scheduling and idempotency keys are SDK features with no Mail::Message slot, so inventing a side channel for them here would create a second surface to document and test. Reach them by calling the SDK directly; the README says so.

Constant Summary collapse

RESERVED_HEADERS =

Header names ActionMailer derives from the message itself.

These are re-created by the API from the structured fields this mapping already sends, so forwarding them as "custom" headers would contradict what the API builds. content-type is the one that matters: it describes a MIME document that is never transmitted, because this gem hands over fields rather than a rendered message.

Returns:

  • (Array[String])
%w[
  bcc cc content-transfer-encoding content-type date from
  message-id mime-version reply-to subject to
].freeze

Class Method Summary collapse

Class Method Details

.addresses(message, field) ⇒ Array<String>

Render one address field as RFC strings, keeping display names.

Read through message[field], not through message.to_addrs. The *_addrs readers return BARE addresses, so a display name is already gone by the time this sees them and no amount of re-parsing brings it back. Mail::Field#addrs returns the parsed address objects, and .format renders each one as the application wrote it.

*_addrs is also incomplete: from_addrs, to_addrs, cc_addrs and bcc_addrs exist but there is no reply_to_addrs, so that one reaches method_missing and raises. Going through the field object treats all five identically and cannot develop that asymmetry.

Parameters:

  • message (Mail::Message)

    the message.

  • field (Symbol)

    the header field name.

  • (Object)
  • (Symbol)

Returns:

  • (Array<String>)

    the formatted addresses, empty when the field is absent.



60
61
62
63
64
65
66
67
68
69
70
# File 'lib/mailkube/rails/payload.rb', line 60

def self.addresses(message, field)
  header = message[field]
  return [] if header.nil?

  # The annotation is what makes this checkable. `header` is untyped (the framework side is
  # deliberately shallow in sig/vendor/), so without it the element type erases to `bot` and
  # Steep rejects `&:format` while RuboCop insists on it — the two gates deadlock. Naming the
  # type resolves both, and it is the one place this gem asserts what `Mail` hands back.
  addrs = header.addrs #: Array[::Mail::Address]
  addrs.map(&:format)
end

.attachments(message) ⇒ Array<Mailkube::Attachment>

Convert the message's attachments into SDK attachments.

.body.decoded hands over the original bytes; the SDK base64-encodes them for the wire. Encoding here would send them twice over.

Parameters:

  • message (Mail::Message)

    the message.

  • (Object)

Returns:

  • (Array<Mailkube::Attachment>)

    the attachments.



129
130
131
132
133
134
135
136
137
# File 'lib/mailkube/rails/payload.rb', line 129

def self.attachments(message)
  message.attachments.map do |attachment|
    Mailkube::Attachment.new(
      filename: attachment.filename.to_s,
      content: attachment.body.decoded,
      content_type: attachment.mime_type
    )
  end
end

.body_for(message, mime_type) ⇒ String?

Extract one body part as decoded text.

.decoded, never .raw_source: a quoted-printable or base64 body would otherwise ship its transfer encoding to the API verbatim, and the recipient would read =3D where an equals sign belongs.

Parameters:

  • message (Mail::Message)

    the message.

  • mime_type (String)

    the part's MIME type.

  • (Object)
  • (String)

Returns:

  • (String, nil)

    the decoded body, or nil when the message has no such part.



81
82
83
84
85
86
87
88
89
90
# File 'lib/mailkube/rails/payload.rb', line 81

def self.body_for(message, mime_type)
  part = part_for(message, mime_type)
  return nil if part.nil?

  # `.decoded` undoes the transfer encoding but hands back ASCII-8BIT bytes, with the part's
  # charset recorded separately. Passing those to JSON either raises on invalid byte sequences
  # or ships mojibake, so the bytes are reunited with their declared charset here. `charset`
  # can be absent on a bare message, and UTF-8 is the only sane assumption when it is.
  presence(transcode(part.body.decoded, part.charset))
end

.build(message) ⇒ Hash{Symbol => Object}

Convert a message into the keyword arguments for client.emails.send.

Parameters:

  • message (Mail::Message)

    the message ActionMailer built.

  • (Object)

Returns:

  • (Hash{Symbol => Object})

    the SDK send keywords.



31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/mailkube/rails/payload.rb', line 31

def self.build(message)
  {
    from: addresses(message, :from).first,
    to: addresses(message, :to),
    subject: message.subject.to_s,
    html: body_for(message, "text/html"),
    text: body_for(message, "text/plain"),
    cc: presence(addresses(message, :cc)),
    bcc: presence(addresses(message, :bcc)),
    reply_to: presence(addresses(message, :reply_to)),
    headers: presence(custom_headers(message)),
    attachments: presence(attachments(message))
  }.compact
end

.custom_headers(message) ⇒ Hash{String => String}

Collect the headers the application set itself.

Parameters:

  • message (Mail::Message)

    the message.

  • (Object)

Returns:

  • (Hash{String => String})

    the custom headers.



143
144
145
146
147
148
149
150
151
# File 'lib/mailkube/rails/payload.rb', line 143

def self.custom_headers(message)
  headers = {} #: Hash[String, String]
  message.header.fields.each do |field|
    next if RESERVED_HEADERS.include?(field.name.to_s.downcase)

    headers[field.name.to_s] = field.value.to_s
  end
  headers
end

.part_for(message, mime_type) ⇒ Object?

Find the part carrying one MIME type, or nil.

Split out of body_for so the branch has somewhere to return untyped from: written inline, the if/elsif has no else arm and Steep infers bot for the result, making the decode below unreachable in its eyes.

Parameters:

  • message (Mail::Message)

    the message.

  • mime_type (String)

    the part's MIME type.

  • (Object)
  • (String)

Returns:

  • (Object, nil)

    the matching part, or nil.



114
115
116
117
118
119
120
# File 'lib/mailkube/rails/payload.rb', line 114

def self.part_for(message, mime_type)
  return message.all_parts.find { |part| part.mime_type == mime_type && !part.attachment? } if message.multipart?
  return message if message.mime_type == mime_type
  return message if mime_type == "text/plain" && message.mime_type.nil?

  nil
end

.presence(value) ⇒ Object?

Treat an empty string, array or hash as absent.

An unset field is omitted from the send rather than passed as an empty value: the SDK drops nils before serializing, so this is what keeps an empty cc off the wire instead of sending "cc": [].

Parameters:

  • value (Object, nil)

    the mapped value.

  • (Object)

Returns:

  • (Object, nil)

    the value, or nil when it is empty.



161
162
163
164
165
166
# File 'lib/mailkube/rails/payload.rb', line 161

def self.presence(value)
  return nil if value.nil?
  return nil if value.respond_to?(:empty?) && value.empty?

  value
end

.transcode(body, charset) ⇒ String

Reinterpret decoded bytes in the charset the part declared.

Parameters:

  • body (String)

    the decoded bytes.

  • charset (String, nil)

    the part's declared charset.

  • (String)
  • (String, nil)

Returns:

  • (String)

    the body as UTF-8 text.



97
98
99
100
101
102
103
# File 'lib/mailkube/rails/payload.rb', line 97

def self.transcode(body, charset)
  body.dup.force_encoding(charset || "UTF-8").encode("UTF-8")
rescue ArgumentError, EncodingError
  # An unknown or lying charset must not stop the send. The bytes go out as they arrived,
  # which is what any non-transcoding mapping would have done anyway.
  body
end