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-typeis the one that matters: it describes a MIME document that is never transmitted, because this gem hands over fields rather than a rendered message. %w[ bcc cc content-transfer-encoding content-type date from message-id mime-version reply-to subject to ].freeze
Class Method Summary collapse
-
.addresses(message, field) ⇒ Array<String>
Render one address field as RFC strings, keeping display names.
-
.attachments(message) ⇒ Array<Mailkube::Attachment>
Convert the message's attachments into SDK attachments.
-
.body_for(message, mime_type) ⇒ String?
Extract one body part as decoded text.
-
.build(message) ⇒ Hash{Symbol => Object}
Convert a message into the keyword arguments for
client.emails.send. -
.custom_headers(message) ⇒ Hash{String => String}
Collect the headers the application set itself.
-
.part_for(message, mime_type) ⇒ Object?
Find the part carrying one MIME type, or nil.
-
.presence(value) ⇒ Object?
Treat an empty string, array or hash as absent.
-
.transcode(body, charset) ⇒ String
Reinterpret decoded bytes in the charset the part declared.
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.
60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/mailkube/rails/payload.rb', line 60 def self.addresses(, field) header = [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.
129 130 131 132 133 134 135 136 137 |
# File 'lib/mailkube/rails/payload.rb', line 129 def self.() ..map do || Mailkube::Attachment.new( filename: .filename.to_s, content: .body.decoded, content_type: .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.
81 82 83 84 85 86 87 88 89 90 |
# File 'lib/mailkube/rails/payload.rb', line 81 def self.body_for(, mime_type) part = part_for(, 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.
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() { from: addresses(, :from).first, to: addresses(, :to), subject: .subject.to_s, html: body_for(, "text/html"), text: body_for(, "text/plain"), cc: presence(addresses(, :cc)), bcc: presence(addresses(, :bcc)), reply_to: presence(addresses(, :reply_to)), headers: presence(custom_headers()), attachments: presence(()) }.compact end |
.custom_headers(message) ⇒ Hash{String => String}
Collect the headers the application set itself.
143 144 145 146 147 148 149 150 151 |
# File 'lib/mailkube/rails/payload.rb', line 143 def self.custom_headers() headers = {} #: Hash[String, String] .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.
114 115 116 117 118 119 120 |
# File 'lib/mailkube/rails/payload.rb', line 114 def self.part_for(, mime_type) return .all_parts.find { |part| part.mime_type == mime_type && !part. } if .multipart? return if .mime_type == mime_type return if mime_type == "text/plain" && .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": [].
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.
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 |