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 +7 -0
- data/LICENSE +21 -0
- data/README.md +131 -0
- data/lib/mailersend_rails/configuration.rb +89 -0
- data/lib/mailersend_rails/delivery_method.rb +111 -0
- data/lib/mailersend_rails/inbound/controller.rb +100 -0
- data/lib/mailersend_rails/inbound/payload.rb +140 -0
- data/lib/mailersend_rails/inbound/signature.rb +31 -0
- data/lib/mailersend_rails/railtie.rb +24 -0
- data/lib/mailersend_rails/version.rb +5 -0
- data/lib/mailersend_rails.rb +15 -0
- data/lib/rails_mailersend.rb +9 -0
- metadata +85 -0
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,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: []
|