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 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ - Initial release: API client, webhook signature verification, ActionMailer delivery method `:mailsenpai`.
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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailSenpai
4
+ VERSION = "1.0.0"
5
+ 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: []