mailengin 0.1.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: 88d3555674981032a0373f45f7d3dcde4e8d7d66fc54df3db1518ea1555b4aa7
4
+ data.tar.gz: 69d7013ba3242bcf870f7355d8749e9e8c66a6f52dd394cdd70d1b643f853e9e
5
+ SHA512:
6
+ metadata.gz: 42eabe1d62d66e95dfc74b88ebf9fede0a2751b5c4dcad43993d62052a416b8e5951504d83729f0ff0c92d2ed07920c0c1e5784355ff3c15d391c343ad149318
7
+ data.tar.gz: bc0c863858e5e2d1127e141ed205cb3992fd91f5c75c11ce8e39ee609fbd9be12531db45438e69ac58ac348d720bd67de2960bb5ed07b79784a4a829a5091231
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem will be documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).
6
+
7
+ ## Unreleased
8
+
9
+ ### Added
10
+
11
+ - Initial Ruby SDK with keyword-based APIs and immutable response objects.
12
+ - Single and personalized bulk email operations.
13
+ - Template, raw HTML, variables, sender override, and reply-routing support.
14
+ - Structured API, timeout, malformed-response, and network errors.
15
+ - Injectable transport, Minitest, CI, and trusted RubyGems publishing.
data/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MailEngin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,271 @@
1
+ # MailEngin Ruby SDK
2
+
3
+ [![Ruby](https://img.shields.io/badge/Ruby-3.2%2B-cc342d.svg)](https://www.ruby-lang.org/)
4
+ [![RubyGems](https://img.shields.io/badge/RubyGems-ready-e9573f.svg)](https://rubygems.org/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-111827.svg)](./LICENSE)
6
+
7
+ The official Ruby SDK for sending transactional email through [MailEngin](https://mailengin.app). It uses the Ruby standard library and provides keyword-based APIs, immutable response objects, configurable timeouts, injectable transports, and structured errors.
8
+
9
+ > [!IMPORTANT]
10
+ > This gem is for server-side applications only. Never expose a MailEngin API key in browser, mobile, desktop, or other client-distributed code.
11
+
12
+ ## Requirements
13
+
14
+ - Ruby 3.2 or newer
15
+ - A MailEngin API key and verified sending domain
16
+
17
+ ## Installation
18
+
19
+ Add the gem to your `Gemfile`:
20
+
21
+ ```ruby
22
+ gem "mailengin", "~> 0.1"
23
+ ```
24
+
25
+ Then run:
26
+
27
+ ```bash
28
+ bundle install
29
+ ```
30
+
31
+ Or install it directly:
32
+
33
+ ```bash
34
+ gem install mailengin
35
+ ```
36
+
37
+ ## Before You Send
38
+
39
+ 1. [Verify a sending domain](https://mailengin.app/dashboard/domains).
40
+ 2. [Create an API key](https://mailengin.app/dashboard/api-keys) and save the full secret.
41
+ 3. [Create and publish a Developer Template](https://mailengin.app/dashboard/dev-templates).
42
+ 4. Copy the template API name, such as `welcome-email`.
43
+
44
+ Store the key in your runtime's secret manager or environment:
45
+
46
+ ```env
47
+ MAILENGIN_API_KEY=re_your_full_secret_key
48
+ ```
49
+
50
+ MailEngin displays the full key only once. A masked key cannot authenticate requests.
51
+
52
+ ## Quick Start
53
+
54
+ ```ruby
55
+ require "mailengin"
56
+
57
+ client = MailEngin::Client.new(api_key: ENV.fetch("MAILENGIN_API_KEY"))
58
+
59
+ email = client.emails.send(
60
+ to: "user@example.com",
61
+ from_email: "hello@yourdomain.com",
62
+ template_name: "welcome-email",
63
+ variables: { first_name: "Asha" }
64
+ )
65
+
66
+ puts email.id
67
+ ```
68
+
69
+ The published template supplies the subject and HTML. Values in `variables` replace matching template variables such as `{{first_name}}`.
70
+
71
+ ## Send One Email
72
+
73
+ ```ruby
74
+ email = client.emails.send(
75
+ to: "customer@example.com",
76
+ from_email: "hello@yourdomain.com",
77
+ template_name: "account-verification",
78
+ variables: {
79
+ first_name: "Asha",
80
+ verification_url: "https://yourapp.com/verify/token"
81
+ },
82
+ reply_to_mailengin: true
83
+ )
84
+
85
+ puts "Queued email #{email.id} at #{email.created_at}"
86
+ ```
87
+
88
+ ### Send keyword arguments
89
+
90
+ | Keyword | Type | Required | Description |
91
+ | --- | --- | --- | --- |
92
+ | `to` | `String` | Yes | Recipient email address. |
93
+ | `template_name` | `String` | Recommended | Published template API name or exact display name. |
94
+ | `template_id` | `String` | No | Legacy template identifier. Prefer `template_name`. |
95
+ | `variables` | `Hash` | No | Values used to render template variables. |
96
+ | `subject` | `String` | Raw HTML only | Template subject override, or required subject for raw HTML. |
97
+ | `from_email` | `String` | Recommended | Sender on a verified domain authorized for the API key. |
98
+ | `html` | `String` | Advanced | Raw HTML used when no template is supplied. |
99
+ | `reply_to_mailengin` | `true` / `false` | No | Route recipient replies into the MailEngin inbox. |
100
+
101
+ Exactly one content source is required: `template_name`, `template_id`, or `html`. Raw HTML sends also require `subject`.
102
+
103
+ ## Send Personalized Bulk Email
104
+
105
+ Bulk requests support up to 1,000 recipients. Request-level variables apply to every recipient; recipient variables take precedence.
106
+
107
+ ```ruby
108
+ job = client.emails.send_bulk(
109
+ to: [
110
+ {
111
+ email: "asha@example.com",
112
+ variables: { first_name: "Asha" }
113
+ },
114
+ {
115
+ email: "ben@example.com",
116
+ variables: { first_name: "Ben" }
117
+ }
118
+ ],
119
+ from_email: "hello@yourdomain.com",
120
+ template_name: "product-update",
121
+ variables: { product_name: "MailEngin" }
122
+ )
123
+
124
+ puts "Queued #{job.queued_count} recipients in job #{job.job_id}"
125
+ ```
126
+
127
+ For recipients without individual variables, use strings:
128
+
129
+ ```ruby
130
+ job = client.emails.send_bulk(
131
+ to: ["a@example.com", "b@example.com"],
132
+ template_name: "maintenance-notice"
133
+ )
134
+ ```
135
+
136
+ A successful bulk response confirms that recipients were queued. It is not a guarantee that every message was delivered.
137
+
138
+ ## Send Raw HTML
139
+
140
+ Published templates are recommended for reusable product email. For a one-off message, provide both `subject` and `html`:
141
+
142
+ ```ruby
143
+ email = client.emails.send(
144
+ to: "user@example.com",
145
+ from_email: "reports@yourdomain.com",
146
+ subject: "Your report is ready",
147
+ html: "<h1>Report ready</h1><p>You can download it now.</p>"
148
+ )
149
+ ```
150
+
151
+ ## Sender Selection
152
+
153
+ MailEngin resolves the sender in this order:
154
+
155
+ 1. `from_email` supplied in the request.
156
+ 2. Sender saved in the published Developer Template.
157
+ 3. `noreply@<authorized-domain>` fallback.
158
+
159
+ The sender domain must be verified and authorized for the API key.
160
+
161
+ ## Error Handling
162
+
163
+ API, timeout, malformed-response, and network failures raise `MailEngin::Error`:
164
+
165
+ ```ruby
166
+ begin
167
+ client.emails.send(
168
+ to: "user@example.com",
169
+ template_name: "welcome-email"
170
+ )
171
+ rescue MailEngin::Error => error
172
+ warn error.message
173
+ warn error.status.inspect # HTTP status, when available
174
+ warn error.code.inspect # Machine-readable error code
175
+ warn error.request_id.inspect # Include when contacting support
176
+ warn error.retry_after.inspect # Seconds supplied with HTTP 429
177
+ warn error.body.inspect # Parsed JSON or response text
178
+ warn error.retryable?.to_s
179
+ end
180
+ ```
181
+
182
+ `retryable?` is true for network errors, timeouts, HTTP `408`, HTTP `429`, and `5xx` responses. The SDK never retries sends automatically because a retry could create a duplicate email until idempotency keys are supported.
183
+
184
+ Invalid local input raises `ArgumentError` before an API request is made.
185
+
186
+ ## Configuration
187
+
188
+ ```ruby
189
+ client = MailEngin::Client.new(
190
+ api_key: ENV.fetch("MAILENGIN_API_KEY"),
191
+ base_url: "https://api.mailengin.app",
192
+ timeout: 15
193
+ )
194
+ ```
195
+
196
+ | Keyword | Default | Description |
197
+ | --- | --- | --- |
198
+ | `api_key` | None | Full server-side MailEngin API key. |
199
+ | `base_url` | `https://api.mailengin.app` | Override for local, test, or dedicated environments. |
200
+ | `timeout` | `30` | Open and read timeout in seconds. |
201
+ | `transport` | `MailEngin::Transport` | Injectable object responding to `call`. |
202
+
203
+ Create one client and reuse it throughout the application.
204
+
205
+ ## Rails Integration
206
+
207
+ Initialize one client after application configuration is available:
208
+
209
+ ```ruby
210
+ # config/initializers/mailengin.rb
211
+ MAILENGIN = MailEngin::Client.new(
212
+ api_key: ENV.fetch("MAILENGIN_API_KEY")
213
+ )
214
+ ```
215
+
216
+ For larger applications, register the client in your dependency container instead of relying on a global constant. Keep the API key in Rails credentials or the deployment environment, never in source control.
217
+
218
+ ## Testing With an Injected Transport
219
+
220
+ Inject a small callable transport to test without a real API key or network request:
221
+
222
+ ```ruby
223
+ response = Struct.new(:code, :body) do
224
+ def is_a?(type)
225
+ type == Net::HTTPSuccess || super
226
+ end
227
+
228
+ def [](name)
229
+ nil
230
+ end
231
+ end
232
+
233
+ transport = lambda do |uri:, request:, timeout:|
234
+ response.new(
235
+ "200",
236
+ JSON.generate(
237
+ id: "email_123",
238
+ from: "hello@example.com",
239
+ to: "user@example.com",
240
+ template_name: "welcome-email",
241
+ created_at: "2026-08-31T12:00:00Z"
242
+ )
243
+ )
244
+ end
245
+
246
+ client = MailEngin::Client.new(api_key: "test_key", transport: transport)
247
+ ```
248
+
249
+ The repository Minitest suite contains complete transport examples for success, errors, and timeouts.
250
+
251
+ ## Development
252
+
253
+ ```bash
254
+ bundle install
255
+ bundle exec rake test
256
+ gem build mailengin.gemspec
257
+ ```
258
+
259
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution rules and [PUBLISHING.md](./PUBLISHING.md) for maintainer release instructions.
260
+
261
+ ## Resources
262
+
263
+ - [MailEngin API documentation](https://mailengin.app/dashboard/docs)
264
+ - [Developer Templates](https://mailengin.app/dashboard/dev-templates)
265
+ - [API keys](https://mailengin.app/dashboard/api-keys)
266
+ - [Security policy](./SECURITY.md)
267
+ - [Changelog](./CHANGELOG.md)
268
+
269
+ ## License
270
+
271
+ Released under the [MIT License](./LICENSE). Copyright 2026 MailEngin.
@@ -0,0 +1,58 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require_relative "error"
5
+ require_relative "emails"
6
+ require_relative "transport"
7
+ require_relative "version"
8
+
9
+ module MailEngin
10
+ class Client
11
+ DEFAULT_BASE_URL = "https://api.mailengin.app"
12
+
13
+ attr_reader :emails
14
+
15
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: 30, transport: Transport.new)
16
+ raise ArgumentError, "MailEngin requires a non-empty api_key" if api_key.to_s.strip.empty?
17
+ raise ArgumentError, "MailEngin timeout must be positive" unless timeout.positive?
18
+ @api_key = api_key.strip
19
+ @base_url = base_url.sub(%r{/+$}, "")
20
+ @timeout = timeout
21
+ @transport = transport
22
+ @emails = Emails.new(self)
23
+ end
24
+
25
+ def post(path, body)
26
+ uri = URI(@base_url + path)
27
+ request = Net::HTTP::Post.new(uri)
28
+ request["Authorization"] = "Bearer #{@api_key}"
29
+ request["Accept"] = "application/json"
30
+ request["Content-Type"] = "application/json"
31
+ request["User-Agent"] = "mailengin-ruby/#{VERSION}"
32
+ request.body = JSON.generate(body)
33
+ response = @transport.call(uri: uri, request: request, timeout: @timeout)
34
+ parsed = parse(response.body)
35
+ unless response.is_a?(Net::HTTPSuccess)
36
+ message = parsed.is_a?(Hash) && parsed["message"].is_a?(String) ? parsed["message"] : "MailEngin API request failed with status #{response.code}."
37
+ code = parsed.is_a?(Hash) && parsed["code"].is_a?(String) ? parsed["code"] : nil
38
+ retry_after = Float(response["Retry-After"], exception: false)
39
+ raise Error.new(message, status: response.code.to_i, code: code, request_id: response["x-request-id"], retry_after: retry_after, body: parsed)
40
+ end
41
+ raise Error.new("MailEngin API returned invalid JSON.", code: "invalid_response", body: response.body) unless parsed.is_a?(Hash)
42
+ parsed
43
+ rescue Net::OpenTimeout, Net::ReadTimeout => error
44
+ raise Error.new("MailEngin request timed out.", code: "request_timeout", cause: error)
45
+ rescue SocketError, SystemCallError, IOError => error
46
+ raise Error.new("Unable to reach the MailEngin API.", code: "network_error", cause: error)
47
+ end
48
+
49
+ private
50
+
51
+ def parse(raw)
52
+ return nil if raw.nil? || raw.empty?
53
+ JSON.parse(raw)
54
+ rescue JSON::ParserError
55
+ raw
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,65 @@
1
+ module MailEngin
2
+ SendEmailResponse = Data.define(:id, :from, :to, :template_name, :created_at)
3
+ SendBulkEmailResponse = Data.define(:success, :job_id, :queued_count, :sent_count, :failed_count, :template_name, :message)
4
+
5
+ class Emails
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ def send(to:, template_name: nil, template_id: nil, variables: nil, subject: nil, from_email: nil, html: nil, reply_to_mailengin: nil)
11
+ raise ArgumentError, "mailengin.emails.send requires to" if to.to_s.strip.empty?
12
+ require_content(template_name, template_id, html, subject)
13
+ body = compact(
14
+ to: to, template_name: template_name, template_id: template_id, variables: variables,
15
+ subject: subject, from_email: from_email, html: html, reply_to_mailengin: reply_to_mailengin
16
+ )
17
+ data = @client.post("/api/developer/send", body)
18
+ SendEmailResponse.new(
19
+ id: data.fetch("id"), from: data.fetch("from"), to: data.fetch("to"),
20
+ template_name: data["template_name"], created_at: data.fetch("created_at")
21
+ )
22
+ rescue KeyError, TypeError => error
23
+ raise Error.new("MailEngin API returned an invalid response.", code: "invalid_response", body: data, cause: error)
24
+ end
25
+
26
+ def send_bulk(to:, template_name: nil, template_id: nil, variables: nil, subject: nil, from_email: nil, html: nil, reply_to_mailengin: nil)
27
+ raise ArgumentError, "mailengin.emails.send_bulk requires recipients" unless to.is_a?(Array) && !to.empty?
28
+ raise ArgumentError, "mailengin.emails.send_bulk accepts up to 1000 recipients" if to.length > 1_000
29
+ recipients = to.map do |recipient|
30
+ if recipient.is_a?(String)
31
+ raise ArgumentError, "every bulk recipient requires an email" if recipient.strip.empty?
32
+ recipient
33
+ else
34
+ email = recipient[:email] || recipient["email"]
35
+ raise ArgumentError, "every bulk recipient requires an email" if email.to_s.strip.empty?
36
+ compact(email: email, variables: recipient[:variables] || recipient["variables"])
37
+ end
38
+ end
39
+ require_content(template_name, template_id, html, subject)
40
+ data = @client.post("/api/developer/send-bulk", compact(
41
+ to: recipients, template_name: template_name, template_id: template_id, variables: variables,
42
+ subject: subject, from_email: from_email, html: html, reply_to_mailengin: reply_to_mailengin
43
+ ))
44
+ SendBulkEmailResponse.new(
45
+ success: data.fetch("success"), job_id: data.fetch("jobId"), queued_count: data.fetch("queued_count"),
46
+ sent_count: data["sent_count"], failed_count: data["failed_count"],
47
+ template_name: data["template_name"], message: data.fetch("message")
48
+ )
49
+ rescue KeyError, TypeError => error
50
+ raise Error.new("MailEngin API returned an invalid response.", code: "invalid_response", body: data, cause: error)
51
+ end
52
+
53
+ private
54
+
55
+ def require_content(template_name, template_id, html, subject)
56
+ has_template = !template_name.to_s.strip.empty? || !template_id.to_s.strip.empty?
57
+ raise ArgumentError, "provide template_name, template_id, or html" if !has_template && html.to_s.strip.empty?
58
+ raise ArgumentError, "raw HTML sends require subject" if !has_template && subject.to_s.strip.empty?
59
+ end
60
+
61
+ def compact(values)
62
+ values.reject { |_, value| value.nil? }
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,19 @@
1
+ module MailEngin
2
+ class Error < StandardError
3
+ attr_reader :status, :code, :request_id, :retry_after, :body
4
+
5
+ def initialize(message, status: nil, code: nil, request_id: nil, retry_after: nil, body: nil, cause: nil)
6
+ super(message)
7
+ @status = status
8
+ @code = code
9
+ @request_id = request_id
10
+ @retry_after = retry_after
11
+ @body = body
12
+ set_backtrace(cause.backtrace) if cause
13
+ end
14
+
15
+ def retryable?
16
+ %w[network_error request_timeout].include?(code) || [408, 429].include?(status) || (!status.nil? && status >= 500)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ require "net/http"
2
+
3
+ module MailEngin
4
+ class Transport
5
+ def call(uri:, request:, timeout:)
6
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: timeout, read_timeout: timeout) do |http|
7
+ http.request(request)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module MailEngin
2
+ VERSION = "0.1.0"
3
+ end
data/lib/mailengin.rb ADDED
@@ -0,0 +1,6 @@
1
+ require_relative "mailengin/client"
2
+ require_relative "mailengin/error"
3
+ require_relative "mailengin/version"
4
+
5
+ module MailEngin
6
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mailengin
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - MailEngin
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Server-side Ruby client for sending transactional email through MailEngin.
13
+ email:
14
+ - support@mailengin.app
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - CHANGELOG.md
20
+ - LICENSE
21
+ - README.md
22
+ - lib/mailengin.rb
23
+ - lib/mailengin/client.rb
24
+ - lib/mailengin/emails.rb
25
+ - lib/mailengin/error.rb
26
+ - lib/mailengin/transport.rb
27
+ - lib/mailengin/version.rb
28
+ homepage: https://mailengin.app
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ source_code_uri: https://github.com/mailengin/mailengin-ruby-sdk
33
+ bug_tracker_uri: https://github.com/mailengin/mailengin-ruby-sdk/issues
34
+ documentation_uri: https://mailengin.app/dashboard/docs
35
+ rubygems_mfa_required: 'true'
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '3.2'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 4.0.16
51
+ specification_version: 4
52
+ summary: Official Ruby SDK for the MailEngin Email API
53
+ test_files: []