mailsenpai 1.0.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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +108 -0
- data/lib/mailsenpai/client.rb +214 -0
- data/lib/mailsenpai/delivery_method.rb +97 -0
- data/lib/mailsenpai/errors.rb +33 -0
- data/lib/mailsenpai/railtie.rb +10 -0
- data/lib/mailsenpai/version.rb +5 -0
- data/lib/mailsenpai/webhook.rb +71 -0
- data/lib/mailsenpai.rb +23 -0
- metadata +142 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a285a6487d8b02c8e68e983f3e3013c0f6c1ff46b0cee02eae637e5fd1004db9
|
|
4
|
+
data.tar.gz: 109b14fafb936421447187a32634b05569497828c0d396d39f1d3790f6be3acd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a5db8b0577bf3eb633f120f70cbe6c22625fe1385bca886ee8da3382c28e9ba60417ea5daccbb6fe8da9103fb3d2aadc90678a28d6fb2eadadfd9c5e05608cc6
|
|
7
|
+
data.tar.gz: 9e878499379eda00086458964d5c7a984227599ba144e89e4272b6e9a69914ef88f182f7c029d84357b4b84be29bcea547d9a0c6c42e39c0e9c8f506182ac89f
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MailSenpai
|
|
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,108 @@
|
|
|
1
|
+
# mailsenpai (Ruby)
|
|
2
|
+
|
|
3
|
+
Ruby client and ActionMailer delivery method for **SMTP Senpai by MailSenpai**, the EU-hosted SMTP relay and
|
|
4
|
+
transactional email API. Standard library only (`net/http`, `json`, `openssl`) plus `base64`.
|
|
5
|
+
|
|
6
|
+
Docs: https://en.mailsenpai.com/smtp-api/ · Product: https://en.mailsenpai.com/smtp-senpai/ · All integrations: https://en.mailsenpai.com/integrations/
|
|
7
|
+
Source code and issues: https://gitlab.com/smtp-senpai/mailsenpai-ruby
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Gemfile
|
|
13
|
+
gem "mailsenpai"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Ruby 3.1+. Get your API key (it starts with `msp_`) in the MailSenpai customer area, SMTP Senpai page.
|
|
17
|
+
|
|
18
|
+
## Rails / ActionMailer
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# config/environments/production.rb
|
|
22
|
+
config.action_mailer.delivery_method = :mailsenpai
|
|
23
|
+
config.action_mailer.mailsenpai_settings = {
|
|
24
|
+
api_key: Rails.application.credentials.dig(:mailsenpai, :api_key) # default: ENV["MAILSENPAI_API_KEY"]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The delivery method is registered for you in Rails. Without Rails, `require "action_mailer"` before
|
|
29
|
+
`require "mailsenpai"`, or call `MailSenpai.add_delivery_method` once.
|
|
30
|
+
|
|
31
|
+
Then use your mailers as usual. Supported: text and HTML parts, attachments, Reply-To, display name in `from`,
|
|
32
|
+
custom `X-...` headers (other headers are dropped by the API). After delivery, `message.message_id` is the
|
|
33
|
+
Message-ID given by SMTP Senpai. Add an `Idempotency-Key` header (`headers["Idempotency-Key"] = "order-#{order.id}"`)
|
|
34
|
+
so that a retried job never sends the same email twice within 24 hours.
|
|
35
|
+
|
|
36
|
+
Good to know:
|
|
37
|
+
|
|
38
|
+
- every recipient (To, Cc and Bcc) gets an individual copy, so Cc addresses are not visible to the other
|
|
39
|
+
recipients; use SMTP (`relay.mailsenpai.com`, port 2525, STARTTLS) if you need a visible Cc;
|
|
40
|
+
- messages with more than 50 recipients are split into several API calls; up to 10 attachments, 10 MB in total;
|
|
41
|
+
- recipients on your suppression list are skipped without error; API errors raise `MailSenpai::Error` subclasses
|
|
42
|
+
(respecting `raise_delivery_errors`).
|
|
43
|
+
|
|
44
|
+
## API client
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
require "mailsenpai"
|
|
48
|
+
|
|
49
|
+
client = MailSenpai::Client.new("msp_...") # or ENV["MAILSENPAI_API_KEY"]
|
|
50
|
+
|
|
51
|
+
result = client.send_email(
|
|
52
|
+
to: "customer@example.com", # or an array of up to 50 addresses
|
|
53
|
+
from: "orders@yourdomain.com", # on a verified domain
|
|
54
|
+
from_name: "Your shop",
|
|
55
|
+
subject: "Order 10293 confirmed",
|
|
56
|
+
html: "<p>Thanks, your order is confirmed.</p>",
|
|
57
|
+
reply_to: "support@yourdomain.com",
|
|
58
|
+
headers: { "X-Order" => "10293" },
|
|
59
|
+
attachments: [{ filename: "invoice.pdf", content: File.binread("invoice.pdf"), content_type: "application/pdf" }],
|
|
60
|
+
idempotency_key: "order-10293"
|
|
61
|
+
)
|
|
62
|
+
result.sent? # => true
|
|
63
|
+
result.message_id # => "9f2c...@yourdomain.com"
|
|
64
|
+
result.not_sent # => recipients on the suppression list, with #reason
|
|
65
|
+
|
|
66
|
+
client.status # SMTP details, monthly volume, tracking
|
|
67
|
+
client.events(limit: 50, type: "bounce") # latest events
|
|
68
|
+
client.each_event(after: last_id) { |e| ... } # everything new since last_id, oldest first
|
|
69
|
+
client.suppressions(search: "example.com")
|
|
70
|
+
client.suppress("someone@example.com", note: "asked to stop")
|
|
71
|
+
client.unsuppress("someone@example.com")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Errors: `AuthenticationError` (401), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409),
|
|
75
|
+
`PayloadTooLargeError` (413), `UnprocessableEntityError` (422), `RateLimitError` (429), `UpstreamError` (502),
|
|
76
|
+
`ConnectionError`. Each has `#status` and `#body`; the message is the API text (in Italian).
|
|
77
|
+
|
|
78
|
+
## Webhooks
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
class MailsenpaiWebhooksController < ActionController::API
|
|
82
|
+
def create
|
|
83
|
+
event = MailSenpai::Webhook.construct_event(
|
|
84
|
+
request.raw_post,
|
|
85
|
+
request.headers["X-MailSenpai-Signature"],
|
|
86
|
+
ENV.fetch("MAILSENPAI_WEBHOOK_SECRET") # whsec_...
|
|
87
|
+
)
|
|
88
|
+
# event["type"], event["data"]["email"], event["data"]["message_id"] ...
|
|
89
|
+
head :no_content
|
|
90
|
+
rescue MailSenpai::WebhookVerificationError
|
|
91
|
+
head :bad_request
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Signature: `t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>`, 5 minutes tolerance by default.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
bundle install
|
|
102
|
+
bundle exec rake test
|
|
103
|
+
gem build mailsenpai.gemspec
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT, © MailSenpai.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module MailSenpai
|
|
9
|
+
# Outcome for one recipient.
|
|
10
|
+
RecipientResult = Struct.new(:email, :sent, :message_id, :reason, :error, keyword_init: true) do
|
|
11
|
+
alias_method :sent?, :sent
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Normalised response of POST /invio, for one or many recipients.
|
|
15
|
+
class SendResult
|
|
16
|
+
attr_reader :results, :remaining, :raw
|
|
17
|
+
|
|
18
|
+
def initialize(results:, remaining: nil, over_plan: false, replayed: false, raw: {})
|
|
19
|
+
@results = results
|
|
20
|
+
@remaining = remaining
|
|
21
|
+
@over_plan = over_plan
|
|
22
|
+
@replayed = replayed
|
|
23
|
+
@raw = raw
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def over_plan? = @over_plan
|
|
27
|
+
def replayed? = @replayed
|
|
28
|
+
def sent? = results.any?(&:sent)
|
|
29
|
+
def sent_count = results.count(&:sent)
|
|
30
|
+
def not_sent = results.reject(&:sent)
|
|
31
|
+
def message_ids = results.select(&:sent).map(&:message_id).compact
|
|
32
|
+
def message_id = message_ids.first
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Synchronous client for the SMTP Senpai REST API.
|
|
36
|
+
#
|
|
37
|
+
# client = MailSenpai::Client.new("msp_...") # or ENV["MAILSENPAI_API_KEY"]
|
|
38
|
+
# client.send_email(to: "customer@example.com", from: "orders@yourdomain.com",
|
|
39
|
+
# subject: "Order confirmed", html: "<p>Thanks!</p>")
|
|
40
|
+
class Client
|
|
41
|
+
DEFAULT_BASE_URL = "https://app.mailsenpai.com/relay/v1"
|
|
42
|
+
MAX_RECIPIENTS = 50
|
|
43
|
+
MAX_ATTACHMENTS = 10
|
|
44
|
+
EVENT_TYPES = %w[sent delivered bounce defer complaint open click dropped].freeze
|
|
45
|
+
|
|
46
|
+
attr_reader :base_url, :timeout
|
|
47
|
+
|
|
48
|
+
def initialize(api_key = nil, base_url: nil, timeout: 30)
|
|
49
|
+
@api_key = api_key || ENV.fetch("MAILSENPAI_API_KEY", nil)
|
|
50
|
+
raise Error, "Missing API key: pass api_key or set MAILSENPAI_API_KEY." if @api_key.to_s.empty?
|
|
51
|
+
|
|
52
|
+
@base_url = (base_url || ENV["MAILSENPAI_BASE_URL"] || DEFAULT_BASE_URL).chomp("/")
|
|
53
|
+
@timeout = timeout
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inspect = "#<MailSenpai::Client base_url=#{@base_url.inspect}>"
|
|
57
|
+
|
|
58
|
+
# Sends an email. +to+ is an address or an array of up to 50 (each gets an individual copy).
|
|
59
|
+
# +attachments+: [{filename:, content: (raw bytes), content_type:}]. +headers+: only X-... are kept.
|
|
60
|
+
def send_email(to:, from:, subject:, text: nil, html: nil, from_name: nil, reply_to: nil,
|
|
61
|
+
headers: nil, attachments: nil, idempotency_key: nil)
|
|
62
|
+
raise ArgumentError, "Provide text, html or both." if text.nil? && html.nil?
|
|
63
|
+
|
|
64
|
+
recipients = normalize_recipients(to)
|
|
65
|
+
payload = { "to" => recipients, "from" => from, "subject" => subject.to_s }
|
|
66
|
+
payload["text"] = text unless text.nil?
|
|
67
|
+
payload["html"] = html unless html.nil?
|
|
68
|
+
payload["from_name"] = from_name if from_name && !from_name.empty?
|
|
69
|
+
payload["reply_to"] = reply_to if reply_to && !reply_to.empty?
|
|
70
|
+
payload["headers"] = headers.to_h.transform_keys(&:to_s).transform_values(&:to_s) if headers && !headers.empty?
|
|
71
|
+
if attachments && !attachments.empty?
|
|
72
|
+
raise ArgumentError, "Up to #{MAX_ATTACHMENTS} attachments per email." if attachments.size > MAX_ATTACHMENTS
|
|
73
|
+
|
|
74
|
+
payload["attachments"] = attachments.map { |a| attachment_to_api(a) }
|
|
75
|
+
end
|
|
76
|
+
extra = idempotency_key ? { "Idempotency-Key" => idempotency_key.to_s } : {}
|
|
77
|
+
body, response_headers = request(:post, "/invio", body: payload, headers: extra)
|
|
78
|
+
build_send_result(body, recipients, response_headers)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def status = request(:get, "/stato").first
|
|
82
|
+
def stats(days: nil) = request(:get, "/statistiche", params: { giorni: days }).first
|
|
83
|
+
|
|
84
|
+
# One page of events. Without +after+: the latest, newest first. With +after+: oldest first.
|
|
85
|
+
# Returns {"eventi" => [...], "prossimo" => "...", "altri" => true/false}.
|
|
86
|
+
def events(after: nil, limit: nil, type: nil)
|
|
87
|
+
raise ArgumentError, "Unknown event type #{type.inspect}" if type && !EVENT_TYPES.include?(type.to_s)
|
|
88
|
+
|
|
89
|
+
request(:get, "/eventi", params: { dopo: after, quanti: limit, tipo: type }).first
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Yields every event after the cursor (oldest first), following "prossimo" while "altri" is true.
|
|
93
|
+
def each_event(after: 0, type: nil, page_size: 500)
|
|
94
|
+
return enum_for(:each_event, after: after, type: type, page_size: page_size) unless block_given?
|
|
95
|
+
|
|
96
|
+
cursor = after.to_i
|
|
97
|
+
loop do
|
|
98
|
+
page = events(after: cursor, limit: page_size, type: type)
|
|
99
|
+
list = page["eventi"] || []
|
|
100
|
+
list.each { |event| yield event }
|
|
101
|
+
next_cursor = page["prossimo"] ? page["prossimo"].to_i : list.last&.fetch("event_id", nil)&.to_i
|
|
102
|
+
break if !page["altri"] || list.empty? || next_cursor.nil? || next_cursor <= cursor
|
|
103
|
+
|
|
104
|
+
cursor = next_cursor
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def suppressions(limit: nil, search: nil) = request(:get, "/soppressi", params: { quanti: limit, cerca: search }).first
|
|
109
|
+
|
|
110
|
+
def suppress(email, note: nil)
|
|
111
|
+
payload = { "email" => email }
|
|
112
|
+
payload["nota"] = note if note
|
|
113
|
+
request(:post, "/sopprimi", body: payload).first
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def unsuppress(email) = request(:post, "/riammetti", body: { "email" => email }).first
|
|
117
|
+
|
|
118
|
+
def webhook = request(:get, "/webhook").first["webhook"] || {}
|
|
119
|
+
|
|
120
|
+
def set_webhook(url:, events: nil, active: true, rotate_secret: false)
|
|
121
|
+
payload = { "url" => url, "attivo" => active }
|
|
122
|
+
payload["eventi"] = events if events
|
|
123
|
+
payload["nuovo_segreto"] = true if rotate_secret
|
|
124
|
+
request(:post, "/webhook", body: payload).first["webhook"] || {}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_webhook = request(:post, "/webhook/prova").first
|
|
128
|
+
|
|
129
|
+
# Low-level call: returns [parsed JSON body, response headers (lowercase keys)].
|
|
130
|
+
def request(method, path, params: nil, body: nil, headers: {})
|
|
131
|
+
uri = URI.parse(@base_url + path)
|
|
132
|
+
query = (params || {}).compact
|
|
133
|
+
uri.query = URI.encode_www_form(query) unless query.empty?
|
|
134
|
+
|
|
135
|
+
klass = method == :post ? Net::HTTP::Post : Net::HTTP::Get
|
|
136
|
+
req = klass.new(uri)
|
|
137
|
+
req["Authorization"] = "Bearer #{@api_key}"
|
|
138
|
+
req["Accept"] = "application/json"
|
|
139
|
+
req["User-Agent"] = "mailsenpai-ruby/#{VERSION}"
|
|
140
|
+
headers.each { |k, v| req[k] = v }
|
|
141
|
+
if body
|
|
142
|
+
req["Content-Type"] = "application/json; charset=utf-8"
|
|
143
|
+
req.body = JSON.generate(body)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
response = begin
|
|
147
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
148
|
+
open_timeout: @timeout, read_timeout: @timeout) { |http| http.request(req) }
|
|
149
|
+
rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, IOError => e
|
|
150
|
+
raise ConnectionError, "Could not reach SMTP Senpai: #{e.message}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
status = response.code.to_i
|
|
154
|
+
parsed = begin
|
|
155
|
+
response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
156
|
+
rescue JSON::ParserError
|
|
157
|
+
raise APIError.new("Unexpected non-JSON response from SMTP Senpai (HTTP #{status}).", status: status)
|
|
158
|
+
end
|
|
159
|
+
parsed = { "data" => parsed } unless parsed.is_a?(Hash)
|
|
160
|
+
|
|
161
|
+
if status >= 400 || parsed["ok"] == false
|
|
162
|
+
message = parsed["errore"] || parsed["error"] || "HTTP #{status}"
|
|
163
|
+
raise STATUS_ERRORS.fetch(status, APIError).new(message, status: status, body: parsed)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
[parsed, response.each_header.to_h]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def normalize_recipients(to)
|
|
172
|
+
if to.is_a?(Array)
|
|
173
|
+
list = to.map { |a| a.to_s.strip }.reject(&:empty?)
|
|
174
|
+
raise ArgumentError, "A recipient is required." if list.empty?
|
|
175
|
+
raise ArgumentError, "Up to #{MAX_RECIPIENTS} recipients per call (got #{list.size})." if list.size > MAX_RECIPIENTS
|
|
176
|
+
|
|
177
|
+
list
|
|
178
|
+
else
|
|
179
|
+
addr = to.to_s.strip
|
|
180
|
+
raise ArgumentError, "A recipient is required." if addr.empty?
|
|
181
|
+
|
|
182
|
+
addr
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def attachment_to_api(att)
|
|
187
|
+
att = att.transform_keys(&:to_s)
|
|
188
|
+
filename = att["filename"] || att["nome"]
|
|
189
|
+
content = att["content"] || att["contenuto"]
|
|
190
|
+
raise ArgumentError, "Each attachment needs filename and content." if filename.nil? || content.nil?
|
|
191
|
+
|
|
192
|
+
out = { "filename" => filename, "content" => att["base64"] ? content : Base64.strict_encode64(content) }
|
|
193
|
+
type = att["content_type"] || att["tipo"]
|
|
194
|
+
out["content_type"] = type if type
|
|
195
|
+
out
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def build_send_result(body, recipients, response_headers)
|
|
199
|
+
results =
|
|
200
|
+
if body["risultati"].is_a?(Array)
|
|
201
|
+
body["risultati"].map do |row|
|
|
202
|
+
RecipientResult.new(email: row["a"], sent: row["inviato"] ? true : false, message_id: row["id_messaggio"],
|
|
203
|
+
reason: row["motivo"], error: row["errore"])
|
|
204
|
+
end
|
|
205
|
+
else
|
|
206
|
+
email = recipients.is_a?(Array) ? recipients.first : recipients
|
|
207
|
+
[RecipientResult.new(email: email, sent: body["inviato"] ? true : false,
|
|
208
|
+
message_id: body["id_messaggio"], reason: body["motivo"])]
|
|
209
|
+
end
|
|
210
|
+
SendResult.new(results: results, remaining: body["residuo"]&.to_i, over_plan: body["oltre_il_piano"] ? true : false,
|
|
211
|
+
replayed: response_headers["idempotent-replayed"].to_s.downcase == "true", raw: body)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module MailSenpai
|
|
6
|
+
# Delivery method for the mail gem / ActionMailer.
|
|
7
|
+
#
|
|
8
|
+
# config.action_mailer.delivery_method = :mailsenpai
|
|
9
|
+
# config.action_mailer.mailsenpai_settings = { api_key: ENV["MAILSENPAI_API_KEY"] }
|
|
10
|
+
#
|
|
11
|
+
# Every recipient (To, Cc and Bcc) gets an individual copy: Cc addresses are not shown to the
|
|
12
|
+
# other recipients. Messages with more than 50 recipients are split into several API calls.
|
|
13
|
+
class DeliveryMethod
|
|
14
|
+
SKIPPED_HEADERS = %w[from to cc bcc subject reply-to sender return-path content-type
|
|
15
|
+
content-transfer-encoding mime-version date message-id idempotency-key].freeze
|
|
16
|
+
|
|
17
|
+
attr_accessor :settings
|
|
18
|
+
|
|
19
|
+
def initialize(settings = {})
|
|
20
|
+
@settings = settings.to_h.transform_keys(&:to_sym)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def deliver!(mail)
|
|
24
|
+
recipients = Array(mail.smtp_envelope_to).map(&:to_s).uniq(&:downcase)
|
|
25
|
+
raise ArgumentError, "SMTP Senpai: the email has no recipients." if recipients.empty?
|
|
26
|
+
|
|
27
|
+
payload = build_payload(mail)
|
|
28
|
+
results = recipients.each_slice(Client::MAX_RECIPIENTS).map do |chunk|
|
|
29
|
+
client.send_email(to: chunk, **payload)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
first_id = results.flat_map(&:message_ids).first
|
|
33
|
+
mail.message_id = first_id if first_id
|
|
34
|
+
if results.none?(&:sent?) && results.flat_map(&:results).any?(&:error)
|
|
35
|
+
errors = results.flat_map(&:not_sent).map { |r| "#{r.email}: #{r.error || r.reason}" }
|
|
36
|
+
raise UpstreamError.new("SMTP Senpai did not send the email: #{errors.join('; ')}", status: 200)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
settings[:return_response] ? results : self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def client
|
|
43
|
+
@client ||= Client.new(settings[:api_key], base_url: settings[:base_url], timeout: settings[:timeout] || 30)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_payload(mail)
|
|
47
|
+
from = mail[:from]&.addrs&.first
|
|
48
|
+
raise ArgumentError, "SMTP Senpai: the email has no From address." unless from
|
|
49
|
+
|
|
50
|
+
envelope_from = mail.smtp_envelope_from.to_s
|
|
51
|
+
payload = {
|
|
52
|
+
from: envelope_from.empty? ? from.address : envelope_from,
|
|
53
|
+
from_name: from.display_name,
|
|
54
|
+
subject: mail.subject.to_s,
|
|
55
|
+
reply_to: mail[:reply_to]&.addrs&.first&.address,
|
|
56
|
+
idempotency_key: mail.header["Idempotency-Key"]&.value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
text, html = bodies(mail)
|
|
60
|
+
payload[:text] = text if text
|
|
61
|
+
payload[:html] = html if html
|
|
62
|
+
payload[:text] = "" if text.nil? && html.nil?
|
|
63
|
+
|
|
64
|
+
headers = custom_headers(mail)
|
|
65
|
+
payload[:headers] = headers unless headers.empty?
|
|
66
|
+
|
|
67
|
+
unless mail.attachments.empty?
|
|
68
|
+
payload[:attachments] = mail.attachments.map do |part|
|
|
69
|
+
{ filename: part.filename, content: part.decoded, content_type: part.mime_type }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
payload.compact
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def bodies(mail)
|
|
78
|
+
text = mail.text_part&.decoded
|
|
79
|
+
html = mail.html_part&.decoded
|
|
80
|
+
if !mail.multipart?
|
|
81
|
+
body = mail.body.decoded
|
|
82
|
+
mail.mime_type == "text/html" ? html = body : text = body
|
|
83
|
+
end
|
|
84
|
+
[text, html]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def custom_headers(mail)
|
|
88
|
+
mail.header.fields.each_with_object({}) do |field, out|
|
|
89
|
+
name = field.name.to_s
|
|
90
|
+
next if SKIPPED_HEADERS.include?(name.downcase)
|
|
91
|
+
next unless name.downcase.start_with?("x-")
|
|
92
|
+
|
|
93
|
+
out[name] = field.value.to_s
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailSenpai
|
|
4
|
+
# Base class for every error of this gem. +message+ is the API text (in Italian) or a local description.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :status, :body
|
|
7
|
+
|
|
8
|
+
def initialize(message = nil, status: nil, body: nil)
|
|
9
|
+
super(message)
|
|
10
|
+
@status = status
|
|
11
|
+
@body = body || {}
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class ConnectionError < Error; end
|
|
16
|
+
class APIError < Error; end
|
|
17
|
+
class BadRequestError < APIError; end # 400
|
|
18
|
+
class AuthenticationError < APIError; end # 401
|
|
19
|
+
class PermissionDeniedError < APIError; end # 403 unverified domain, suspended, being activated
|
|
20
|
+
class NotFoundError < APIError; end # 404
|
|
21
|
+
class ConflictError < APIError; end # 409
|
|
22
|
+
class PayloadTooLargeError < APIError; end # 413
|
|
23
|
+
class UnprocessableEntityError < APIError; end # 422
|
|
24
|
+
class RateLimitError < APIError; end # 429 monthly volume used up / spending cap
|
|
25
|
+
class UpstreamError < APIError; end # 502 sending server refused the message
|
|
26
|
+
class WebhookVerificationError < Error; end
|
|
27
|
+
|
|
28
|
+
STATUS_ERRORS = {
|
|
29
|
+
400 => BadRequestError, 401 => AuthenticationError, 403 => PermissionDeniedError,
|
|
30
|
+
404 => NotFoundError, 409 => ConflictError, 413 => PayloadTooLargeError,
|
|
31
|
+
422 => UnprocessableEntityError, 429 => RateLimitError, 502 => UpstreamError
|
|
32
|
+
}.freeze
|
|
33
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailSenpai
|
|
4
|
+
# Registers the :mailsenpai delivery method in Rails apps.
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
initializer "mailsenpai.add_delivery_method", before: "action_mailer.set_configs" do
|
|
7
|
+
ActiveSupport.on_load(:action_mailer) { MailSenpai.add_delivery_method(self) }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module MailSenpai
|
|
7
|
+
# Verification of X-MailSenpai-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>.
|
|
8
|
+
module Webhook
|
|
9
|
+
HEADER = "X-MailSenpai-Signature"
|
|
10
|
+
DEFAULT_TOLERANCE = 300
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def sign(payload, secret, timestamp: Time.now.to_i)
|
|
15
|
+
"t=#{timestamp.to_i},v1=#{compute(payload, secret, timestamp.to_i)}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def compute(payload, secret, timestamp)
|
|
19
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, "#{timestamp}.#{payload}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Raises WebhookVerificationError unless the signature is valid; returns true otherwise.
|
|
23
|
+
def verify!(payload, header, secret, tolerance: DEFAULT_TOLERANCE, now: Time.now.to_i)
|
|
24
|
+
raise WebhookVerificationError, "A non-empty webhook secret is required." if secret.to_s.empty?
|
|
25
|
+
raise WebhookVerificationError, "Missing #{HEADER} header." if header.to_s.empty?
|
|
26
|
+
|
|
27
|
+
timestamp = nil
|
|
28
|
+
signatures = []
|
|
29
|
+
header.to_s.split(",").each do |part|
|
|
30
|
+
key, value = part.strip.split("=", 2)
|
|
31
|
+
if key == "t" && value.to_s.match?(/\A\d+\z/)
|
|
32
|
+
timestamp = value.to_i
|
|
33
|
+
elsif key == "v1" && !value.to_s.empty?
|
|
34
|
+
signatures << value.downcase
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
raise WebhookVerificationError, "Malformed signature header." if timestamp.nil? || signatures.empty?
|
|
38
|
+
if tolerance && (now.to_i - timestamp).abs > tolerance
|
|
39
|
+
raise WebhookVerificationError, "Timestamp outside the tolerance window."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
expected = compute(payload, secret, timestamp)
|
|
43
|
+
return true if signatures.any? { |sig| secure_compare(expected, sig) }
|
|
44
|
+
|
|
45
|
+
raise WebhookVerificationError, "Signature does not match."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def valid?(payload, header, secret, **opts)
|
|
49
|
+
verify!(payload, header, secret, **opts)
|
|
50
|
+
rescue WebhookVerificationError
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Verifies and returns the parsed event: {"id", "type", "created_at", "data" => {...}, "account", "test"}.
|
|
55
|
+
def construct_event(payload, header, secret, **opts)
|
|
56
|
+
verify!(payload, header, secret, **opts)
|
|
57
|
+
event = JSON.parse(payload)
|
|
58
|
+
raise WebhookVerificationError, "The webhook body is not a JSON object." unless event.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
event
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
raise WebhookVerificationError, "The webhook body is not valid JSON."
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def secure_compare(a, b)
|
|
66
|
+
return false unless a.bytesize == b.bytesize
|
|
67
|
+
|
|
68
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/mailsenpai.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "mailsenpai/version"
|
|
4
|
+
require_relative "mailsenpai/errors"
|
|
5
|
+
require_relative "mailsenpai/client"
|
|
6
|
+
require_relative "mailsenpai/webhook"
|
|
7
|
+
require_relative "mailsenpai/delivery_method"
|
|
8
|
+
|
|
9
|
+
# SMTP Senpai by MailSenpai: EU SMTP relay and transactional email API.
|
|
10
|
+
module MailSenpai
|
|
11
|
+
# Registers :mailsenpai on an ActionMailer::Base-like class (done for you in Rails).
|
|
12
|
+
def self.add_delivery_method(base = ::ActionMailer::Base)
|
|
13
|
+
return if base.delivery_methods.key?(:mailsenpai)
|
|
14
|
+
|
|
15
|
+
base.add_delivery_method :mailsenpai, MailSenpai::DeliveryMethod, api_key: ENV.fetch("MAILSENPAI_API_KEY", nil)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if defined?(::Rails::Railtie)
|
|
20
|
+
require_relative "mailsenpai/railtie"
|
|
21
|
+
elsif defined?(::ActionMailer::Base)
|
|
22
|
+
MailSenpai.add_delivery_method
|
|
23
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mailsenpai
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- MailSenpai
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-10 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: base64
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.2'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: actionmailer
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '7.1'
|
|
34
|
+
- - "<"
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: '9'
|
|
37
|
+
type: :development
|
|
38
|
+
prerelease: false
|
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '7.1'
|
|
44
|
+
- - "<"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '9'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: mail
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.8'
|
|
54
|
+
type: :development
|
|
55
|
+
prerelease: false
|
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.8'
|
|
61
|
+
- !ruby/object:Gem::Dependency
|
|
62
|
+
name: minitest
|
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '5.20'
|
|
68
|
+
- - "<"
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '7'
|
|
71
|
+
type: :development
|
|
72
|
+
prerelease: false
|
|
73
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
74
|
+
requirements:
|
|
75
|
+
- - ">="
|
|
76
|
+
- !ruby/object:Gem::Version
|
|
77
|
+
version: '5.20'
|
|
78
|
+
- - "<"
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '7'
|
|
81
|
+
- !ruby/object:Gem::Dependency
|
|
82
|
+
name: rake
|
|
83
|
+
requirement: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - "~>"
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '13.0'
|
|
88
|
+
type: :development
|
|
89
|
+
prerelease: false
|
|
90
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - "~>"
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '13.0'
|
|
95
|
+
description: 'Send transactional email through SMTP Senpai by MailSenpai, the EU-hosted
|
|
96
|
+
SMTP relay and email API: API client (send, events, suppression list), webhook signature
|
|
97
|
+
verification and an ActionMailer delivery method (:mailsenpai).'
|
|
98
|
+
email:
|
|
99
|
+
executables: []
|
|
100
|
+
extensions: []
|
|
101
|
+
extra_rdoc_files: []
|
|
102
|
+
files:
|
|
103
|
+
- CHANGELOG.md
|
|
104
|
+
- LICENSE.txt
|
|
105
|
+
- README.md
|
|
106
|
+
- lib/mailsenpai.rb
|
|
107
|
+
- lib/mailsenpai/client.rb
|
|
108
|
+
- lib/mailsenpai/delivery_method.rb
|
|
109
|
+
- lib/mailsenpai/errors.rb
|
|
110
|
+
- lib/mailsenpai/railtie.rb
|
|
111
|
+
- lib/mailsenpai/version.rb
|
|
112
|
+
- lib/mailsenpai/webhook.rb
|
|
113
|
+
homepage: https://en.mailsenpai.com/integrations/
|
|
114
|
+
licenses:
|
|
115
|
+
- MIT
|
|
116
|
+
metadata:
|
|
117
|
+
homepage_uri: https://en.mailsenpai.com/integrations/
|
|
118
|
+
documentation_uri: https://en.mailsenpai.com/smtp-api/
|
|
119
|
+
source_code_uri: https://gitlab.com/smtp-senpai/mailsenpai-ruby
|
|
120
|
+
bug_tracker_uri: https://gitlab.com/smtp-senpai/mailsenpai-ruby/-/issues
|
|
121
|
+
changelog_uri: https://gitlab.com/smtp-senpai/mailsenpai-ruby/-/blob/main/CHANGELOG.md
|
|
122
|
+
rubygems_mfa_required: 'true'
|
|
123
|
+
post_install_message:
|
|
124
|
+
rdoc_options: []
|
|
125
|
+
require_paths:
|
|
126
|
+
- lib
|
|
127
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
128
|
+
requirements:
|
|
129
|
+
- - ">="
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: '3.1'
|
|
132
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - ">="
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '0'
|
|
137
|
+
requirements: []
|
|
138
|
+
rubygems_version: 3.5.22
|
|
139
|
+
signing_key:
|
|
140
|
+
specification_version: 4
|
|
141
|
+
summary: Ruby client and ActionMailer delivery method for SMTP Senpai by MailSenpai
|
|
142
|
+
test_files: []
|