rails_mailersend 0.4.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2b230567dfd55a4b5874d501dd4c25ff67cea0fc2cb80972001c97f37e81bde1
4
+ data.tar.gz: c1cd9f9a8031b4457299aa5c5d38bb3e693efdbf2fed4284aa629f8f2df27a24
5
+ SHA512:
6
+ metadata.gz: e840f9652bfc94d57480738c8642b50be72af6dafc689f5e79c0ed41c1866b57ddc569c6d9de53d0bb3f854303ce7bb4c6b4f767886c8dc740f8bf615334e15f
7
+ data.tar.gz: 0375b493a5bec2b0782c224d7fe74951426fc132a08888d78464d5f7beda3345d43e21a8109420803a88a1bf4ed0a458c80f5d269406e95fa2cc5f91539eb098
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dan Loman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # rails_mailersend
2
+
3
+ MailerSend for Rails: an Action Mailer delivery method, and an optional Action
4
+ Mailbox ingress for inbound mail.
5
+
6
+ This existed as a copied `lib/action_mailer/mailersend_delivery.rb` in four apps.
7
+ Three of the copies were identical and one had drifted ahead with typed errors and
8
+ a nil-text-part fix — which is the usual shape of copied code, and the reason for
9
+ the gem.
10
+
11
+ ## Install
12
+
13
+ ```ruby
14
+ gem "rails_mailersend"
15
+ ```
16
+
17
+ The name is inverted because `mailersend_rails` on RubyGems is an unrelated gem by
18
+ another author, and RubyGems refuses a new name that differs from an existing one
19
+ only by its separators — so `mailersend-rails` is out too. Nothing inside moved
20
+ for it: the constant is `MailersendRails` in `lib/mailersend_rails.rb`, and
21
+ one-line `lib/rails_mailersend.rb` is there so Bundler's auto-require lands
22
+ somewhere real without a `require:` option in your Gemfile.
23
+
24
+ ```ruby
25
+ # config/environments/production.rb
26
+ config.action_mailer.delivery_method = :mailersend
27
+ ```
28
+
29
+ That's the whole outbound setup. Adding the gem registers the delivery method; no
30
+ initializer and no `require` of a file under `lib/`.
31
+
32
+ ## Configuration
33
+
34
+ The token is read from Rails credentials, falling back to the environment:
35
+
36
+ ```yaml
37
+ # rails credentials:edit
38
+ mailersend:
39
+ api_token: ms_...
40
+ inbound_secret: ... # only for inbound
41
+ ```
42
+
43
+ ```bash
44
+ MAILERSEND_API_TOKEN=ms_...
45
+ MAILERSEND_INBOUND_SECRET=...
46
+ ```
47
+
48
+ Or set it explicitly:
49
+
50
+ ```ruby
51
+ MailersendRails.configure do |config|
52
+ config.api_token = Vault.read("mailersend/token")
53
+ config.log_tag = "inbound" # prefixes the ingress log lines
54
+ config.header_prefix = "X-Acme-" # namespaces the headers the ingress stamps
55
+ end
56
+ ```
57
+
58
+ `header_prefix` defaults to `X-Mailersend-`, and is worth setting once to the
59
+ app's own house prefix. The names go into messages that are then stored, so
60
+ changing it later leaves every message already on disk answering to a name
61
+ nothing reads.
62
+
63
+ Delivery failures raise `MailersendRails::DeliveryMethod::DeliveryError` rather
64
+ than returning quietly, so the enqueuing job retries and the failure is visible.
65
+ For an app where sign-in is by magic link, a swallowed delivery error looks
66
+ exactly like a broken app to the person waiting.
67
+
68
+ ## Inbound mail
69
+
70
+ Optional, and only loads when the app has Action Mailbox. MailerSend posts the
71
+ complete RFC822 message, so unlike the Mailgun or Postmark ingresses there is
72
+ nothing to rebuild — this hands the message straight to Action Mailbox and lets
73
+ `ApplicationMailbox` routing decide what it is.
74
+
75
+ ```ruby
76
+ # app/controllers/inbound/mailersend_controller.rb
77
+ class Inbound::MailersendController < MailersendRails::Inbound::Controller
78
+ end
79
+
80
+ # config/routes.rb
81
+ post "inbound/mailersend" => "inbound/mailersend#create"
82
+ ```
83
+
84
+ Point a MailerSend inbound route at that URL and put the route's secret in
85
+ `mailersend.inbound_secret`.
86
+
87
+ Five things it handles that are easy to get wrong:
88
+
89
+ **The validation ping is answered before the secret is checked.** A route's secret
90
+ is generated when the route is saved, so there is no secret to configure until the
91
+ route exists — and the route cannot exist while the endpoint refuses the ping for
92
+ want of one. Answering first breaks the deadlock. It is safe because it does
93
+ nothing: no message read, nothing created, nothing disclosed.
94
+
95
+ **Envelope recipients are stamped onto the message as `X-Original-To`.** Routing
96
+ keys on the recipient, and the headers frequently do not carry it: a sender who
97
+ Bccs you leaves no header at all, because stripping Bcc in transit is the entire
98
+ point of Bcc. The address survives only in the SMTP envelope. This is the same
99
+ move Action Mailbox's own Postmark ingress makes.
100
+
101
+ **Envelope addresses are filtered before being written into headers.** Header
102
+ injection would otherwise be one crafted address away — an address containing a
103
+ newline could add arbitrary headers, or close the header block and forge a body.
104
+
105
+ **MailerSend's SPF and DKIM verdicts are stamped on as `<prefix>SPF` and
106
+ `<prefix>DKIM`.** They are the one part of the payload a forger cannot write: a
107
+ `From:` line is whatever the sender typed, and these are the only evidence about
108
+ it that arrives from outside the message. Each is normalized to one word from a
109
+ fixed list — `pass`, `fail`, `softfail`, `neutral`, or `none` — so nothing in the
110
+ payload can put anything else into a header. `none` means MailerSend said nothing,
111
+ which is distinguishable from a message that never came through the ingress at
112
+ all, since that one has no such header.
113
+
114
+ **Headers in the ingress's own namespace are cleared off an arriving message
115
+ before its own are stamped.** Otherwise a sender supplies the verdict that is
116
+ supposed to judge them, or an `X-Original-To` naming somewhere the message was
117
+ never delivered. The whole prefix is reserved, and so is `X-Original-To`. A
118
+ message carrying none of them is passed through byte for byte.
119
+
120
+ The signature is verified against the exact bytes MailerSend signed, before
121
+ anything parses them; checking against a re-serialised body would verify our own
122
+ JSON encoder rather than the sender.
123
+
124
+ ## Tests
125
+
126
+ ```bash
127
+ bundle exec rake test
128
+ ```
129
+
130
+ The parts most worth getting right — the payload guard rails and signature
131
+ verification — are plain Ruby and tested without a Rails app.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailersendRails
4
+ # Credentials first, ENV as the escape hatch for CI and one-off scripts.
5
+ #
6
+ # Plain Ruby on purpose -- this and the inbound helpers load without Rails, so
7
+ # they can be tested on their own.
8
+ #
9
+ # Every app that copied this code hard-coded the credentials path, which is the
10
+ # main reason the copies drifted: one app keeps the token somewhere else and the
11
+ # file forks. Both sources are checked here so neither app has to patch it.
12
+ class Configuration
13
+ class MissingApiToken < StandardError; end
14
+ class MissingInboundSecret < StandardError; end
15
+
16
+ DEFAULT_HEADER_PREFIX = "X-Mailersend-"
17
+
18
+ attr_writer :api_token, :inbound_secret
19
+ attr_accessor :log_tag
20
+ attr_reader :header_prefix
21
+
22
+ def initialize
23
+ @log_tag = "mailersend"
24
+ @header_prefix = DEFAULT_HEADER_PREFIX
25
+ end
26
+
27
+ # The namespace for the headers the ingress stamps onto an arriving message,
28
+ # and the same namespace it clears off one first.
29
+ #
30
+ # Worth setting to the app's own house prefix, and worth setting once: these
31
+ # names are written into messages that are then stored, so changing the prefix
32
+ # later leaves every message already on disk answering to a name nothing reads.
33
+ #
34
+ # Normalized to end in a hyphen, because `X-Acme` and `X-Acme-` differ by one
35
+ # character and the first one produces `X-AcmeSPF`.
36
+ def header_prefix=(value)
37
+ prefix = value.to_s.strip
38
+ raise ArgumentError, "header_prefix cannot be blank" if prefix.empty?
39
+
40
+ @header_prefix = prefix.end_with?("-") ? prefix : "#{prefix}-"
41
+ end
42
+
43
+ def api_token
44
+ @api_token ||= credential(:api_token) || ENV["MAILERSEND_API_TOKEN"]
45
+ end
46
+
47
+ def api_token!
48
+ token = api_token
49
+ return token unless token.nil? || token.empty?
50
+
51
+ raise MissingApiToken, "No MailerSend API token. Set credentials mailersend.api_token, " \
52
+ "or the MAILERSEND_API_TOKEN environment variable. Outbound mail " \
53
+ "cannot be sent without it."
54
+ end
55
+
56
+ # The inbound *route's* secret, shown under the route URL in MailerSend's
57
+ # domain settings. Not the signing secret used for activity webhooks -- they
58
+ # are different values, and swapping them fails every request on signature.
59
+ def inbound_secret
60
+ @inbound_secret ||= credential(:inbound_secret) || ENV["MAILERSEND_INBOUND_SECRET"]
61
+ end
62
+
63
+ def inbound_secret?
64
+ secret = inbound_secret
65
+ !(secret.nil? || secret.empty?)
66
+ end
67
+
68
+ private
69
+ def credential(key)
70
+ return nil unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
71
+
72
+ Rails.application.credentials.dig(:mailersend, key)
73
+ end
74
+ end
75
+
76
+ class << self
77
+ def config
78
+ @config ||= Configuration.new
79
+ end
80
+
81
+ def configure
82
+ yield config
83
+ end
84
+
85
+ def reset_configuration!
86
+ @config = nil
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "version"
8
+
9
+ module MailersendRails
10
+ # An Action Mailer delivery method for MailerSend.
11
+ #
12
+ # Failures raise rather than returning quietly, so the enqueuing job retries and
13
+ # the error is visible. Swallowing them means a magic link that silently goes
14
+ # nowhere, which looks to the person waiting for it exactly like a broken app.
15
+ #
16
+ # The API is posted to directly rather than through mailersend-ruby. Sending is
17
+ # a single POST of a JSON body this class already assembles itself, and the SDK
18
+ # asks a lot in return: http, which it pins a major version behind and so holds
19
+ # back every application that carries it, plus an FFI parser that has to be
20
+ # compiled into every image.
21
+ class DeliveryMethod
22
+ class DeliveryError < StandardError; end
23
+
24
+ DEFAULT_ENDPOINT = "https://api.mailersend.com/v1/email"
25
+
26
+ # The SDK's timeouts, kept because they are sensible: long enough for a slow
27
+ # accept, short enough that a wedged connection fails into the retry rather
28
+ # than occupying a worker.
29
+ OPEN_TIMEOUT = 15
30
+ READ_TIMEOUT = 30
31
+
32
+ attr_reader :settings
33
+
34
+ def initialize(settings = {})
35
+ @settings = settings || {}
36
+ end
37
+
38
+ def deliver!(mail)
39
+ response = post(payload_for(mail))
40
+
41
+ return response if success?(response)
42
+
43
+ raise DeliveryError, "MailerSend returned #{response.code}: #{response.body}"
44
+ end
45
+
46
+ private
47
+ # MailerSend treats an absent field as "not set" but rejects several of them
48
+ # sent empty, so anything we have nothing for is dropped rather than blanked.
49
+ def payload_for(mail)
50
+ {
51
+ "from" => address_in(mail[:from]),
52
+ "to" => addresses_in(mail[:to]),
53
+ "cc" => addresses_in(mail[:cc]),
54
+ "bcc" => addresses_in(mail[:bcc]),
55
+ "reply_to" => Array(mail.reply_to).empty? ? {} : address_in(mail[:reply_to]),
56
+ "subject" => mail.subject,
57
+ "text" => mail.text_part&.body&.decoded,
58
+ "html" => html_for(mail)
59
+ }.reject { |_, value| omit?(value) }
60
+ end
61
+
62
+ def html_for(mail)
63
+ mail.html_part&.body&.decoded || mail.body.decoded
64
+ end
65
+
66
+ def address_in(field)
67
+ as_recipient(Mail::Address.new(field.to_s))
68
+ end
69
+
70
+ # `mail[:to]` is one field whose `to_s` is the whole comma-joined list, and
71
+ # `Mail::Address.new` on that parses the first address and drops the rest --
72
+ # which is how a three-person ops alias arrives as one person, silently and
73
+ # only in production. The field already holds the addresses parsed.
74
+ def addresses_in(field)
75
+ Array(field&.addrs).map { |address| as_recipient(address) }
76
+ end
77
+
78
+ def as_recipient(address)
79
+ { "email" => address.address, "name" => address.display_name }
80
+ end
81
+
82
+ def omit?(value)
83
+ value == [] || value == {} || value.to_s.strip.empty?
84
+ end
85
+
86
+ def success?(response)
87
+ response.code.to_s.start_with?("2")
88
+ end
89
+
90
+ # Overridable so a test, or a staging environment, can point somewhere other
91
+ # than the live API.
92
+ def endpoint
93
+ @endpoint ||= URI(settings[:endpoint] || DEFAULT_ENDPOINT)
94
+ end
95
+
96
+ def post(payload)
97
+ request = Net::HTTP::Post.new(endpoint)
98
+ request["Authorization"] = "Bearer #{MailersendRails.config.api_token!}"
99
+ request["Content-Type"] = "application/json"
100
+ request["Accept"] = "application/json"
101
+ request["User-Agent"] = "mailersend_rails/#{MailersendRails::VERSION}"
102
+ request.body = JSON.generate(payload)
103
+
104
+ Net::HTTP.start(
105
+ endpoint.hostname, endpoint.port,
106
+ use_ssl: endpoint.scheme == "https",
107
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT
108
+ ) { |http| http.request(request) }
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailersendRails
4
+ module Inbound
5
+ # Every email that arrives, from anywhere, for any reason.
6
+ #
7
+ # This is an ingress, not a feature: it knows nothing about what the mail is
8
+ # for. MailerSend posts the message as JSON, `data.raw` is the complete RFC822
9
+ # message, and this hands it to Action Mailbox. `ApplicationMailbox` routing
10
+ # then decides what the message *is*. A second kind of inbound mail becomes a
11
+ # new mailbox and one routing line, not a second endpoint, second secret and
12
+ # second DNS record.
13
+ #
14
+ # Mount it in the host app:
15
+ #
16
+ # class Inbound::MailersendController < MailersendRails::Inbound::Controller
17
+ # end
18
+ #
19
+ # post "inbound/mailersend" => "inbound/mailersend#create"
20
+ #
21
+ # Deliberately an ActionController::Base rather than the app's own base class:
22
+ # an ingress has no session, no tenancy and no browser, and a modern-browser
23
+ # gate would answer a webhook with 406.
24
+ class Controller < ActionController::Base
25
+ skip_forgery_protection
26
+
27
+ # Order matters. The validation ping is answered before the secret is
28
+ # required, because the two deadlock otherwise -- see answer_validation_ping.
29
+ before_action :answer_validation_ping
30
+ before_action :require_secret
31
+ before_action :verify_signature
32
+
33
+ def create
34
+ return reject("posted a body we couldn't read as a JSON object") unless payload.parseable?
35
+ return reject("posted a message with no raw MIME") unless payload.raw_message?
36
+
37
+ # Rescues RecordNotUnique internally and returns nil, so a retried webhook
38
+ # is already a no-op here.
39
+ ActionMailbox::InboundEmail.create_and_extract_message_id!(
40
+ payload.message_with_transport_headers
41
+ )
42
+
43
+ head :no_content
44
+ end
45
+
46
+ private
47
+ # MailerSend checks a new route by posting {"type": "webhook.test"}.
48
+ #
49
+ # It has to be answered before the secret is checked, because the two
50
+ # deadlock: a route's secret is generated when the route is saved, so
51
+ # there is no secret to configure until the route exists, and the route
52
+ # cannot exist while we refuse the ping for want of one.
53
+ #
54
+ # Safe because it does nothing -- no message is read, nothing is created,
55
+ # nothing is disclosed. Its signature is deliberately not verified, since
56
+ # MailerSend signs the test with a secret published in their own docs, so
57
+ # checking it would prove nothing about who sent it.
58
+ def answer_validation_ping
59
+ return unless payload.validation_ping?
60
+
61
+ log(:info, "answered a validation ping")
62
+ head :ok
63
+ end
64
+
65
+ # Fails closed, and loudly. An unconfigured secret would otherwise make
66
+ # every comparison fail against an empty key -- indistinguishable in the
67
+ # log from a provider misconfiguration, and the fix is entirely different.
68
+ def require_secret
69
+ return if MailersendRails.config.inbound_secret?
70
+
71
+ log(:error, "no inbound secret configured; refusing inbound mail")
72
+ head :service_unavailable
73
+ end
74
+
75
+ def verify_signature
76
+ return if Signature.valid?(
77
+ secret: MailersendRails.config.inbound_secret,
78
+ body: request.raw_post,
79
+ given: request.headers["Signature"]
80
+ )
81
+
82
+ log(:warn, "rejected an inbound post with a bad signature")
83
+ head :unauthorized
84
+ end
85
+
86
+ def payload
87
+ @payload ||= Payload.parse(request.raw_post)
88
+ end
89
+
90
+ def reject(reason)
91
+ log(:warn, reason)
92
+ head :unprocessable_content
93
+ end
94
+
95
+ def log(level, message)
96
+ Rails.logger.public_send(level, "[#{MailersendRails.config.log_tag}] #{message}")
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../configuration"
6
+
7
+ module MailersendRails
8
+ module Inbound
9
+ # Everything about an inbound MailerSend post that can be decided without a
10
+ # request object. Pulled out of the controller so the parts most worth getting
11
+ # right -- the header-injection guard, the envelope-recipient stamping and the
12
+ # transport's verdicts -- are testable on their own.
13
+ class Payload
14
+ # A plausible single address. Anything else is dropped rather than trusted.
15
+ ADDRESS = /\A[^\s<>@]+@[^\s<>@]+\z/
16
+ MAX_RECIPIENTS = 10
17
+
18
+ # The one header the ingress writes under a name it doesn't own: Action
19
+ # Mailbox's own Postmark ingress writes this, and code downstream looks for
20
+ # it by that name, so it isn't namespaced. It is still ours, though, and a
21
+ # sender's copy of it is cleared with the rest.
22
+ ORIGINAL_TO = "X-Original-To"
23
+
24
+ # MailerSend reports SPF in received-SPF shorthand -- `+` pass, `-` fail,
25
+ # `~` softfail, `?` neutral -- and DKIM as a boolean. Each becomes one word
26
+ # from a fixed list, so nothing in the payload can put anything else into a
27
+ # header.
28
+ SPF_CODES = {
29
+ "+" => "pass", "-" => "fail", "~" => "softfail", "?" => "neutral",
30
+ "pass" => "pass", "fail" => "fail", "softfail" => "softfail", "neutral" => "neutral"
31
+ }.freeze
32
+
33
+ def self.parse(body)
34
+ new(JSON.parse(body.to_s))
35
+ rescue JSON::ParserError
36
+ new(nil)
37
+ end
38
+
39
+ def initialize(data)
40
+ @data = data.is_a?(Hash) ? data : nil
41
+ end
42
+
43
+ def parseable? = !@data.nil?
44
+
45
+ def to_h = @data || {}
46
+
47
+ # MailerSend won't save a route whose endpoint doesn't answer, and it checks
48
+ # by posting {"type": "webhook.test"}.
49
+ def validation_ping? = to_h["type"] == "webhook.test"
50
+
51
+ # The complete RFC822 message. MailerSend hands it over intact, which is why
52
+ # there is nothing to rebuild here the way the Mailgun or Postmark ingresses
53
+ # have to.
54
+ def raw_message = to_h.dig("data", "raw")
55
+
56
+ def raw_message? = raw_message.to_s.strip != ""
57
+
58
+ # Who the message was actually delivered to.
59
+ #
60
+ # Routing keys on the recipient, and the headers frequently don't carry it:
61
+ # a sender who Bcc's you leaves no header at all, because stripping Bcc in
62
+ # transit is the entire point of Bcc. The address survives only in the SMTP
63
+ # envelope.
64
+ def envelope_recipients
65
+ Array(to_h.dig("data", "recipients", "rcptTo"))
66
+ .filter_map { |entry| entry["email"] if entry.is_a?(Hash) }
67
+ .map { |address| address.to_s.strip }
68
+ .grep(ADDRESS)
69
+ .uniq
70
+ .first(MAX_RECIPIENTS)
71
+ end
72
+
73
+ # What MailerSend's own checks made of the sender, in one word each. "none"
74
+ # when it said nothing, so a reader downstream can tell a failed check from
75
+ # a message that never passed through here at all.
76
+ def spf_verdict
77
+ check = to_h.dig("data", "spf_check")
78
+ code = check.is_a?(Hash) ? check["code"] : check
79
+
80
+ SPF_CODES.fetch(code.to_s.strip.downcase, "none")
81
+ end
82
+
83
+ def dkim_verdict
84
+ case to_h.dig("data", "dkim_check")
85
+ when true, "true", "pass" then "pass"
86
+ when false, "false", "fail" then "fail"
87
+ else "none"
88
+ end
89
+ end
90
+
91
+ def spf_header = "#{header_prefix}SPF"
92
+
93
+ def dkim_header = "#{header_prefix}DKIM"
94
+
95
+ # The raw message with what the transport knew stamped on the front: who it
96
+ # was actually delivered to, and the SPF and DKIM verdicts.
97
+ #
98
+ # The verdicts are the one thing in the payload a forger cannot write, which
99
+ # is what makes them worth carrying -- a From: line is whatever the sender
100
+ # typed, and this is the only evidence about it that arrives from outside the
101
+ # message. Any header of ours the sender supplied is cleared first, so the
102
+ # first one downstream reads is always the ingress's.
103
+ #
104
+ # The address filter above is load-bearing: an envelope recipient containing
105
+ # a newline could otherwise add arbitrary headers, or close the header block
106
+ # and forge a body.
107
+ def message_with_transport_headers
108
+ stamped = envelope_recipients.map { |address| "#{ORIGINAL_TO}: #{address}\n" }
109
+ stamped << "#{spf_header}: #{spf_verdict}\n"
110
+ stamped << "#{dkim_header}: #{dkim_verdict}\n"
111
+
112
+ stamped.join + strip_reserved_headers(raw_message.to_s)
113
+ end
114
+
115
+ private
116
+ def header_prefix = MailersendRails.config.header_prefix
117
+
118
+ # Every header the ingress writes, wherever in the block the sender put it.
119
+ # The prefix is a namespace and everything under it is ours; X-Original-To
120
+ # is one name, so the colon is required and X-Original-Tomato survives.
121
+ def reserved_header
122
+ /\A(?:#{Regexp.escape(ORIGINAL_TO)}\s*:|#{Regexp.escape(header_prefix)})/i
123
+ end
124
+
125
+ # Header lines named for us, folded continuations included. Only the header
126
+ # block is touched; the body is the sender's. A message carrying none of
127
+ # ours is returned byte-for-byte, which is the overwhelmingly common case.
128
+ def strip_reserved_headers(raw)
129
+ header_block, separator, body = raw.partition(/\r?\n\r?\n/)
130
+ return raw if separator.empty?
131
+
132
+ lines = header_block.split(/\r?\n(?![ \t])/)
133
+ kept = lines.reject { |line| line.match?(reserved_header) }
134
+ return raw if kept.size == lines.size
135
+
136
+ kept.join("\n") + separator + body
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module MailersendRails
6
+ module Inbound
7
+ # Verified against the exact bytes MailerSend signed, before anything parses
8
+ # them. A signature checked against a re-serialised body verifies our own JSON
9
+ # encoder rather than the sender -- key order and whitespace both move.
10
+ module Signature
11
+ def self.expected(secret:, body:)
12
+ OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, body.to_s)
13
+ end
14
+
15
+ def self.valid?(secret:, body:, given:)
16
+ return false if secret.to_s.empty? || given.to_s.empty?
17
+
18
+ secure_compare(expected(secret: secret, body: body), given.to_s)
19
+ end
20
+
21
+ def self.secure_compare(a, b)
22
+ if defined?(ActiveSupport::SecurityUtils)
23
+ ActiveSupport::SecurityUtils.secure_compare(a, b)
24
+ else
25
+ a.bytesize == b.bytesize && OpenSSL.secure_compare(a, b)
26
+ end
27
+ end
28
+ private_class_method :secure_compare
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module MailersendRails
6
+ # The whole point of the gem: adding it registers the delivery method, so an app
7
+ # only has to say `config.action_mailer.delivery_method = :mailersend`.
8
+ class Railtie < ::Rails::Railtie
9
+ initializer "mailersend_rails.delivery_method" do
10
+ ActiveSupport.on_load(:action_mailer) do
11
+ require "mailersend_rails/delivery_method"
12
+ ActionMailer::Base.add_delivery_method :mailersend, MailersendRails::DeliveryMethod
13
+ end
14
+ end
15
+
16
+ # Only loaded when the app actually has Action Mailbox, so the gem is usable
17
+ # for outbound alone.
18
+ initializer "mailersend_rails.inbound" do
19
+ ActiveSupport.on_load(:action_controller_base) do
20
+ require "mailersend_rails/inbound/controller" if defined?(ActionMailbox)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailersendRails
4
+ VERSION = "0.4.0"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mailersend_rails/version"
4
+ require "mailersend_rails/configuration"
5
+ require "mailersend_rails/inbound/payload"
6
+ require "mailersend_rails/inbound/signature"
7
+ require "mailersend_rails/railtie" if defined?(::Rails::Railtie)
8
+
9
+ module MailersendRails
10
+ autoload :DeliveryMethod, "mailersend_rails/delivery_method"
11
+
12
+ module Inbound
13
+ autoload :Controller, "mailersend_rails/inbound/controller"
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem is `rails_mailersend`, so Bundler's auto-require looks for this file. Everything
4
+ # lives under `mailersend_rails`, which is the spelling that matches the constant.
5
+ #
6
+ # The inversion is not a style choice. `mailersend_rails` on RubyGems is an unrelated gem by
7
+ # another author, and RubyGems refuses a new name that differs from an existing one only by
8
+ # its separators — which rules out `mailersend-rails` as well.
9
+ require "mailersend_rails"
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_mailersend
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - Dan Loman
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: actionmailer
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ description: |-
41
+ Adds a :mailersend Action Mailer delivery method and, optionally, an inbound
42
+ ingress that hands MailerSend's posted RFC822 message to Action Mailbox --
43
+ including the envelope-recipient stamping that Bcc'd mail depends on.
44
+ email:
45
+ - daniel.h.loman@gmail.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - LICENSE
51
+ - README.md
52
+ - lib/mailersend_rails.rb
53
+ - lib/mailersend_rails/configuration.rb
54
+ - lib/mailersend_rails/delivery_method.rb
55
+ - lib/mailersend_rails/inbound/controller.rb
56
+ - lib/mailersend_rails/inbound/payload.rb
57
+ - lib/mailersend_rails/inbound/signature.rb
58
+ - lib/mailersend_rails/railtie.rb
59
+ - lib/mailersend_rails/version.rb
60
+ - lib/rails_mailersend.rb
61
+ homepage: https://github.com/namolnad/mailersend-rails
62
+ licenses:
63
+ - MIT
64
+ metadata:
65
+ homepage_uri: https://github.com/namolnad/mailersend-rails
66
+ source_code_uri: https://github.com/namolnad/mailersend-rails
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '3.1'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 4.0.3
82
+ specification_version: 4
83
+ summary: 'MailerSend for Rails: an Action Mailer delivery method and an Action Mailbox
84
+ ingress'
85
+ test_files: []