zapsmtp 0.1.0.beta3
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 +326 -0
- data/lib/zapsmtp/attachment.rb +55 -0
- data/lib/zapsmtp/base_client.rb +225 -0
- data/lib/zapsmtp/core.rb +18 -0
- data/lib/zapsmtp/errors.rb +88 -0
- data/lib/zapsmtp/generated/client.rb +92 -0
- data/lib/zapsmtp/generated/models.rb +2618 -0
- data/lib/zapsmtp/generated/resources.rb +2818 -0
- data/lib/zapsmtp/model.rb +165 -0
- data/lib/zapsmtp/page.rb +147 -0
- data/lib/zapsmtp/rails/delivery_method.rb +71 -0
- data/lib/zapsmtp/rails/message.rb +74 -0
- data/lib/zapsmtp/rails/webhook_verification.rb +67 -0
- data/lib/zapsmtp/rails.rb +70 -0
- data/lib/zapsmtp/railtie.rb +17 -0
- data/lib/zapsmtp/response.rb +40 -0
- data/lib/zapsmtp/retry.rb +48 -0
- data/lib/zapsmtp/transport.rb +130 -0
- data/lib/zapsmtp/util.rb +119 -0
- data/lib/zapsmtp/version.rb +6 -0
- data/lib/zapsmtp/webhook.rb +130 -0
- data/lib/zapsmtp/webhook_middleware.rb +55 -0
- data/lib/zapsmtp.rb +17 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b3eecf99fc7d192df20863a57f9a728064f6b91e815c5a2dfda2ea4da5655116
|
|
4
|
+
data.tar.gz: 246b0b32ffddefa7069851bad0fd9533d872e19e96d6e17a4ce053aade020585
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9b767a2187a512585f43c94be011503b14575ce32a8fba60610abab52c838f1e3103c78d1e3579f970211918b7aee7729082cc041e8126c28876acb9ea9b55c9
|
|
7
|
+
data.tar.gz: e1ed416c5bcaaac0c78a381e6448b51322b0e91f2fa2f0bec40ef38a91a600de3508a3449fdd71224e3b690f583a9f6c4a3108d4cc9ebf5cf04921f258af89ef
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZapSMTP
|
|
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,326 @@
|
|
|
1
|
+
# ZapSMTP Ruby SDK
|
|
2
|
+
|
|
3
|
+
The official Ruby library for the [ZapSMTP](https://zapsmtp.com) email API: send, receive and track
|
|
4
|
+
email, manage templates, domains, webhooks and everything else the API does. It includes an
|
|
5
|
+
ActionMailer delivery method for Rails.
|
|
6
|
+
|
|
7
|
+
- Every API operation, with a reader for every documented response field
|
|
8
|
+
- Safe automatic retries: sends carry an `Idempotency-Key`, so a retry never sends twice
|
|
9
|
+
- Paging over every page of any list, lazily
|
|
10
|
+
- Webhook signature verification, with a Rack middleware and a Rails controller concern
|
|
11
|
+
- `config.action_mailer.delivery_method = :zapsmtp`
|
|
12
|
+
- Ruby 3.0+, no dependencies beyond the standard library
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# Gemfile
|
|
18
|
+
gem "zapsmtp"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Send an email
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "zapsmtp"
|
|
25
|
+
|
|
26
|
+
zap = ZapSMTP::Client.new(api_key: "zap_...") # or set ZAP_API_KEY and call ZapSMTP::Client.new
|
|
27
|
+
|
|
28
|
+
email = zap.emails.send(
|
|
29
|
+
from: "Acme <hello@acme.com>",
|
|
30
|
+
to: "jane@example.org",
|
|
31
|
+
subject: "Your receipt",
|
|
32
|
+
html: "<p>Thanks for your order.</p>",
|
|
33
|
+
tags: [{ name: "category", value: "receipt" }],
|
|
34
|
+
metadata: { order_id: "A-1042" }
|
|
35
|
+
)
|
|
36
|
+
email.id # => "019a1f2e-..."
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Parameters are keyword arguments, or a Hash, with the API's names (`reply_to`, `scheduled_at`...).
|
|
40
|
+
A `Time` is sent as RFC 3339: `scheduled_at: Time.now + 3600`.
|
|
41
|
+
|
|
42
|
+
With a template:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
zap.emails.send(
|
|
46
|
+
from: "Acme <hello@acme.com>",
|
|
47
|
+
to: "jane@example.org",
|
|
48
|
+
template: { id: "receipt", variables: { name: "Jane", total: 42 } }
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Attachments:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
zap.emails.send(
|
|
56
|
+
from: "Acme <hello@acme.com>",
|
|
57
|
+
to: "jane@example.org",
|
|
58
|
+
subject: "Your invoice",
|
|
59
|
+
html: '<p>Attached. <img src="cid:logo"></p>',
|
|
60
|
+
attachments: [
|
|
61
|
+
ZapSMTP::Attachment.from_file("invoice.pdf"),
|
|
62
|
+
ZapSMTP::Attachment.from_file("logo.png", content_id: "logo"), # inline
|
|
63
|
+
ZapSMTP::Attachment.from_string("report.csv", csv)
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Up to 100 emails at once:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
result = zap.emails.send_batch(emails, validation: "permissive")
|
|
72
|
+
result.data # the sent ones
|
|
73
|
+
result.errors # the refused ones, with their index
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Responses
|
|
77
|
+
|
|
78
|
+
A response is a model with a reader for every documented field. Every field, documented or not,
|
|
79
|
+
can be read with `[]` and `to_h` (keys as symbols), and models work with pattern matching:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
email = zap.emails.get(id)
|
|
83
|
+
email.status # => "delivered"
|
|
84
|
+
email[:status] # the same
|
|
85
|
+
email.to_h # => { id: "...", status: "delivered", ... }
|
|
86
|
+
|
|
87
|
+
case email
|
|
88
|
+
in { status: "bounced", to: } then suppress(to)
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A field the API adds later is kept, never an error. A field whose name is a Ruby method of every
|
|
93
|
+
object, such as `method` on a log entry, is read with `[]`: `log[:method]`.
|
|
94
|
+
|
|
95
|
+
## Lists
|
|
96
|
+
|
|
97
|
+
A list call answers one page; `auto_paging_each` walks every page, asking for the next one only when
|
|
98
|
+
the previous is used up.
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
page = zap.emails.list(limit: 50, status: "bounced")
|
|
102
|
+
page.data # this page's emails
|
|
103
|
+
page.next_page? # makes no request
|
|
104
|
+
page.next_page # the next page, or nil
|
|
105
|
+
|
|
106
|
+
zap.emails.list(status: "bounced").auto_paging_each do |email|
|
|
107
|
+
puts email.id
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# an Enumerator: stop early without loading more pages
|
|
111
|
+
zap.emails.list.auto_paging_each.lazy.select { |e| e.status == "bounced" }.first(10)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Errors
|
|
115
|
+
|
|
116
|
+
Every API error raises a `ZapSMTP::Error` subclass with `status`, `code`, `message`, `type`,
|
|
117
|
+
`request_id`, `headers` and `body`.
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
begin
|
|
121
|
+
zap.emails.send(**email)
|
|
122
|
+
rescue ZapSMTP::ValidationError => e
|
|
123
|
+
warn "#{e.code}: #{e.message}" # e.g. invalid_parameter
|
|
124
|
+
rescue ZapSMTP::RateLimitError => e
|
|
125
|
+
warn "retry in #{e.retry_after} s"
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Class | When |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `ZapSMTP::AuthenticationError` | 401: missing or invalid key |
|
|
132
|
+
| `ZapSMTP::PermissionError` | 403: the key may not do this, or the domain is not verified |
|
|
133
|
+
| `ZapSMTP::NotFoundError` | 404 |
|
|
134
|
+
| `ZapSMTP::ConflictError` | 409 |
|
|
135
|
+
| `ZapSMTP::ValidationError` | 400, 413, 422: the request is wrong (also an id that is empty, `.` or `..`, refused before sending) |
|
|
136
|
+
| `ZapSMTP::RateLimitError` | 429: `retry_after` says how long to wait |
|
|
137
|
+
| `ZapSMTP::ServerError` | 5xx |
|
|
138
|
+
| `ZapSMTP::ConnectionError` | no answer: network failure or timeout (`cause` holds the underlying error) |
|
|
139
|
+
|
|
140
|
+
## Retries and timeouts
|
|
141
|
+
|
|
142
|
+
A request that failed on the way (429, 408, 5xx, a lost connection) is tried again twice by default,
|
|
143
|
+
waiting as the API's `Retry-After` says or backing off exponentially. Only requests that are safe to
|
|
144
|
+
repeat are retried: reads, updates by `PUT`, deletes, and sends, which always carry an
|
|
145
|
+
`Idempotency-Key` (pass your own to make retries of your own safe too).
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
zap = ZapSMTP::Client.new(api_key: "zap_...", max_retries: 4, timeout: 20)
|
|
149
|
+
zap.emails.get(id, request_options: { timeout: 5, max_retries: 0 })
|
|
150
|
+
zap.emails.send(**email, request_options: { idempotency_key: "order-1042-receipt" })
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`request_options` takes `idempotency_key`, `headers`, `timeout` and `max_retries`.
|
|
154
|
+
|
|
155
|
+
## Response headers
|
|
156
|
+
|
|
157
|
+
Every model knows the answer it came from; `with_response` gives the response with any result,
|
|
158
|
+
`nil` ones included:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
email = zap.emails.send(**email)
|
|
162
|
+
email.http_response.request_id
|
|
163
|
+
email.http_response["ratelimit-remaining"]
|
|
164
|
+
|
|
165
|
+
result, response = zap.with_response.domains.delete(domain_id)
|
|
166
|
+
response.status # => 204
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Template assets
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
zap.template_assets.upload_file("logo.png", name: "Logo") # a path (String or Pathname)
|
|
173
|
+
zap.template_assets.upload_file(File.open("banner.webp")) # or a File, StringIO...
|
|
174
|
+
zap.template_assets.upload(url: "https://acme.com/logo.png") # or a URL
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Webhooks
|
|
178
|
+
|
|
179
|
+
Check the signature on the raw body, before parsing it. `verify` returns the event, or raises
|
|
180
|
+
`ZapSMTP::WebhookVerificationError` when the signature is missing, wrong or older than five minutes.
|
|
181
|
+
A webhook with batch delivery sends an array of events: use `verify_events`, which returns every
|
|
182
|
+
event (one event as a list of one).
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
event = ZapSMTP::Webhook.verify(raw_body, headers, ENV["ZAP_WEBHOOK_SECRET"])
|
|
186
|
+
event.type # => "email.bounced"
|
|
187
|
+
event.data # => { email_id: "...", to: "jane@example.org", ... }
|
|
188
|
+
|
|
189
|
+
events = ZapSMTP::Webhook.verify_events(raw_body, headers, ENV["ZAP_WEBHOOK_SECRET"])
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`headers` may be a Hash in any case, a Rack env, Rails' `request.headers`, or the `Zap-Signature`
|
|
193
|
+
value itself. `zap.webhooks.verify` and `zap.webhooks.verify_events` do the same.
|
|
194
|
+
|
|
195
|
+
Rails:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
class ZapsmtpWebhooksController < ActionController::API
|
|
199
|
+
include ZapSMTP::Rails::WebhookVerification # answers 400 to a request that fails verification
|
|
200
|
+
|
|
201
|
+
def create
|
|
202
|
+
event = zapsmtp_webhook_event # zapsmtp_webhook_events with batch delivery
|
|
203
|
+
Bounce.record!(event.data) if event.type == "email.bounced"
|
|
204
|
+
head :ok
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
# default: ENV["ZAP_WEBHOOK_SECRET"]
|
|
210
|
+
def zapsmtp_webhook_secret
|
|
211
|
+
Rails.application.credentials.zapsmtp_webhook_secret
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
In an `ActionController::Base` controller, add `skip_forgery_protection`.
|
|
217
|
+
|
|
218
|
+
Sinatra:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
post "/webhooks/zapsmtp" do
|
|
222
|
+
event = ZapSMTP::Webhook.verify(request.body.read, request.env, ENV["ZAP_WEBHOOK_SECRET"])
|
|
223
|
+
# ...
|
|
224
|
+
200
|
|
225
|
+
rescue ZapSMTP::WebhookVerificationError
|
|
226
|
+
400
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Any Rack app, with the middleware (it answers 400 itself, and passes the events on in
|
|
231
|
+
`env["zapsmtp.events"]`, and the one event of a request that is not a batch in
|
|
232
|
+
`env["zapsmtp.event"]`):
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
use ZapSMTP::WebhookMiddleware, path: "/webhooks/zapsmtp", secret: ENV["ZAP_WEBHOOK_SECRET"]
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Rails: the delivery method
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
# config/environments/production.rb
|
|
242
|
+
config.action_mailer.delivery_method = :zapsmtp
|
|
243
|
+
config.action_mailer.zapsmtp_settings = { api_key: Rails.application.credentials.zapsmtp_api_key }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Mailers stay as they are. From, to, cc, bcc, reply-to, the subject, the HTML and text parts, custom
|
|
247
|
+
headers (`In-Reply-To`, `List-Unsubscribe`, `X-...`) and attachments, inline ones included, go to
|
|
248
|
+
`emails.send`. ZapSMTP's own options go in `zapsmtp:`:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
class UserMailer < ApplicationMailer
|
|
252
|
+
def welcome(user)
|
|
253
|
+
attachments.inline["logo.png"] = File.binread("app/assets/images/logo.png")
|
|
254
|
+
mail(
|
|
255
|
+
to: user.email,
|
|
256
|
+
subject: "Welcome, #{user.name}",
|
|
257
|
+
zapsmtp: {
|
|
258
|
+
tags: [{ name: "category", value: "welcome" }],
|
|
259
|
+
metadata: { user_id: user.id.to_s },
|
|
260
|
+
stream: "broadcast",
|
|
261
|
+
idempotency_key: "welcome-#{user.id}" # a retried job never sends twice
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# a stored template: no view needed
|
|
267
|
+
def receipt(order)
|
|
268
|
+
mail(to: order.email, body: "", zapsmtp: {
|
|
269
|
+
template: { id: "receipt", variables: { total: order.total } },
|
|
270
|
+
subject: nil # nil: use the template's subject, not the mailer's
|
|
271
|
+
})
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The options travel in a private header, `X-Zapsmtp-Options`, which is never sent and is removed
|
|
277
|
+
once the API has taken the message; any `emails.send` parameter can be set there. Every message is
|
|
278
|
+
sent with an `Idempotency-Key`: yours, or one made on its first attempt and kept on the message, so
|
|
279
|
+
delivering the same message again after a failure (a lost answer included) never sends it twice. After a send the message carries the email's id in
|
|
280
|
+
`X-Zapsmtp-Email-Id`, and with `return_response: true` in the settings `deliver!` answers the API's
|
|
281
|
+
response. API errors raise as above (ActionMailer's `raise_delivery_errors` applies).
|
|
282
|
+
|
|
283
|
+
Settings: `api_key`, `base_url`, `timeout`, `max_retries`, `default_headers`, `transport`, or
|
|
284
|
+
`client:` an existing `ZapSMTP::Client`.
|
|
285
|
+
|
|
286
|
+
Without Rails, the Mail gem can use it too:
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
require "zapsmtp/rails"
|
|
290
|
+
|
|
291
|
+
message = Mail.new(from: "hello@acme.com", to: "jane@example.org", subject: "Hi", body: "Hello")
|
|
292
|
+
ZapSMTP::Rails.set_options(message, { tags: [{ name: "category", value: "hello" }] })
|
|
293
|
+
message.delivery_method(ZapSMTP::Rails::DeliveryMethod, api_key: ENV["ZAP_API_KEY"])
|
|
294
|
+
message.deliver
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Configuration
|
|
298
|
+
|
|
299
|
+
| Option | Environment | Default |
|
|
300
|
+
|---|---|---|
|
|
301
|
+
| `api_key` | `ZAP_API_KEY` | required |
|
|
302
|
+
| `base_url` | `ZAP_BASE_URL` | `https://api.zapsmtp.com/v1` |
|
|
303
|
+
| `timeout` | | 60 s per attempt: connecting, each write and each read, the body's included |
|
|
304
|
+
| `max_retries` | | 2 |
|
|
305
|
+
| `default_headers` | | |
|
|
306
|
+
| `transport` | | `ZapSMTP::Transport::NetHTTP` (keeps a connection alive per thread) |
|
|
307
|
+
|
|
308
|
+
A client is safe to share between threads, and a forked process (Puma, Unicorn) never reuses its
|
|
309
|
+
parent's connections. A transport is any object with `call(request)`; tests can pass a stub.
|
|
310
|
+
|
|
311
|
+
## Reference
|
|
312
|
+
|
|
313
|
+
Every method is listed with its parameters in the [API reference](https://zapsmtp.com/docs/), and in
|
|
314
|
+
the YARD docs of `lib/zapsmtp/generated/resources.rb`. The resources mirror it: `zap.emails`,
|
|
315
|
+
`zap.emails.receiving`, `zap.threads`, `zap.domains`, `zap.webhooks`, `zap.actions`, `zap.templates`,
|
|
316
|
+
`zap.variables`, `zap.template_modules`, `zap.template_assets`, `zap.template_timers`, `zap.inbound`,
|
|
317
|
+
`zap.suppressions`, `zap.stats`, `zap.api_keys`, `zap.smtp_credentials`, `zap.logs`, `zap.usage`,
|
|
318
|
+
`zap.simulator`, `zap.dedicated_ips`.
|
|
319
|
+
|
|
320
|
+
## Development
|
|
321
|
+
|
|
322
|
+
The resources and models in `lib/zapsmtp/generated` are generated from the API reference by
|
|
323
|
+
`sdks/codegen` (`node sdks/codegen/generate.mjs --only ruby`); change the generator, never those
|
|
324
|
+
files. `bundle exec rake` runs the tests (every API operation against the conformance server in
|
|
325
|
+
`sdks/conformance`, the core, webhooks, the Rails integration; 100% line and branch coverage) and
|
|
326
|
+
RuboCop.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZapSMTP
|
|
4
|
+
# Builds attachments for emails.send: the content is sent base64-encoded,
|
|
5
|
+
# and the content type follows the file name's extension unless given.
|
|
6
|
+
#
|
|
7
|
+
# zap.emails.send(..., attachments: [ZapSMTP::Attachment.from_file("invoice.pdf")])
|
|
8
|
+
# ZapSMTP::Attachment.from_string("report.csv", csv)
|
|
9
|
+
# ZapSMTP::Attachment.from_file("logo.png", content_id: "logo") # inline: <img src="cid:logo">
|
|
10
|
+
module Attachment
|
|
11
|
+
CONTENT_TYPES = {
|
|
12
|
+
".pdf" => "application/pdf", ".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
|
|
13
|
+
".gif" => "image/gif", ".webp" => "image/webp", ".svg" => "image/svg+xml", ".txt" => "text/plain",
|
|
14
|
+
".csv" => "text/csv", ".html" => "text/html", ".ics" => "text/calendar", ".json" => "application/json",
|
|
15
|
+
".xml" => "application/xml", ".zip" => "application/zip", ".doc" => "application/msword",
|
|
16
|
+
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
17
|
+
".xls" => "application/vnd.ms-excel",
|
|
18
|
+
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# An attachment from bytes or text.
|
|
24
|
+
#
|
|
25
|
+
# @param filename [String]
|
|
26
|
+
# @param data [String] the content (any encoding: its bytes are sent)
|
|
27
|
+
# @param content_type [String, nil] default: from the file name's extension
|
|
28
|
+
# @param content_id [String, nil] makes it inline: reference it as cid:<content_id>
|
|
29
|
+
# @return [Hash] filename, content (base64), content_type, content_id
|
|
30
|
+
def from_string(filename, data, content_type: nil, content_id: nil)
|
|
31
|
+
attachment = { filename: filename, content: [data.to_s].pack("m0") }
|
|
32
|
+
type = content_type || content_type_for(filename)
|
|
33
|
+
attachment[:content_type] = type if type
|
|
34
|
+
attachment[:content_id] = content_id if content_id
|
|
35
|
+
attachment
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# An attachment read from a file.
|
|
39
|
+
#
|
|
40
|
+
# @param path [String, Pathname]
|
|
41
|
+
# @param filename [String, nil] default: the file's own name
|
|
42
|
+
# @return [Hash] filename, content (base64), content_type, content_id
|
|
43
|
+
def from_file(path, filename: nil, content_type: nil, content_id: nil)
|
|
44
|
+
from_string(filename || File.basename(path), File.binread(path),
|
|
45
|
+
content_type: content_type, content_id: content_id)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The content type of a file name's extension, when it is a known one.
|
|
49
|
+
#
|
|
50
|
+
# @return [String, nil]
|
|
51
|
+
def content_type_for(filename)
|
|
52
|
+
CONTENT_TYPES[File.extname(filename.to_s).downcase]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module ZapSMTP
|
|
7
|
+
# The hand-written core of the client: HTTP, retries, errors and paging.
|
|
8
|
+
# The resources (ZapSMTP::Client) are generated from the API reference.
|
|
9
|
+
class BaseClient
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.zapsmtp.com/v1"
|
|
11
|
+
USER_AGENT = "zapsmtp-ruby/#{VERSION} (ruby/#{RUBY_VERSION})".freeze
|
|
12
|
+
REQUEST_OPTIONS = %i[idempotency_key headers timeout max_retries].freeze
|
|
13
|
+
SAFE_METHODS = %w[GET PUT DELETE].freeze
|
|
14
|
+
|
|
15
|
+
# One API request, as the resources describe it.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
APIRequest = Struct.new(:http_method, :path, :query, :body, :form, :headers, :idempotent, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
# @return [String]
|
|
21
|
+
attr_reader :api_key
|
|
22
|
+
# @return [String] without a trailing slash
|
|
23
|
+
attr_reader :base_url
|
|
24
|
+
# @return [Numeric] seconds per attempt
|
|
25
|
+
attr_reader :timeout
|
|
26
|
+
# @return [Integer] retries of a failed request that is safe to retry
|
|
27
|
+
attr_reader :max_retries
|
|
28
|
+
# @return [Hash{String => String}] headers sent with every request
|
|
29
|
+
attr_reader :default_headers
|
|
30
|
+
# @return [#call] see ZapSMTP::Transport
|
|
31
|
+
attr_reader :transport
|
|
32
|
+
|
|
33
|
+
# @param api_key [String, nil] default: ENV["ZAP_API_KEY"]
|
|
34
|
+
# @param base_url [String, nil] default: ENV["ZAP_BASE_URL"], else https://api.zapsmtp.com/v1
|
|
35
|
+
# @param timeout [Numeric] seconds per attempt (connecting, each write and each read)
|
|
36
|
+
# @param max_retries [Integer] retries of a failed request that is safe to retry
|
|
37
|
+
# @param default_headers [Hash{String => String}] sent with every request
|
|
38
|
+
# @param transport [#call, nil] default: ZapSMTP::Transport::NetHTTP
|
|
39
|
+
def initialize(api_key: nil, base_url: nil, timeout: 60, max_retries: 2, default_headers: {}, transport: nil)
|
|
40
|
+
@api_key = presence(api_key) || presence(ENV.fetch("ZAP_API_KEY", nil))
|
|
41
|
+
raise Error, "Missing API key: pass api_key: to ZapSMTP::Client.new or set ZAP_API_KEY" unless @api_key
|
|
42
|
+
|
|
43
|
+
@base_url = (presence(base_url) || presence(ENV.fetch("ZAP_BASE_URL", nil)) || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
44
|
+
@timeout = timeout
|
|
45
|
+
@max_retries = max_retries
|
|
46
|
+
@default_headers = default_headers.to_h { |k, v| [k.to_s, v.to_s] }
|
|
47
|
+
@transport = transport || Transport::NetHTTP.new
|
|
48
|
+
@with_response = false
|
|
49
|
+
build_resources
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The same client, whose calls answer a ZapSMTP::Result: the data and the
|
|
53
|
+
# ZapSMTP::Response (status, headers, request id) together.
|
|
54
|
+
#
|
|
55
|
+
# email, response = zap.with_response.emails.send(...)
|
|
56
|
+
# response.request_id
|
|
57
|
+
# response["ratelimit-remaining"]
|
|
58
|
+
#
|
|
59
|
+
# @return [ZapSMTP::Client]
|
|
60
|
+
def with_response
|
|
61
|
+
copy = dup
|
|
62
|
+
copy.instance_variable_set(:@with_response, true)
|
|
63
|
+
copy
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize_copy(other)
|
|
67
|
+
super
|
|
68
|
+
build_resources
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def inspect
|
|
72
|
+
"#<#{self.class.name} base_url=#{base_url.inspect}>"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @api private
|
|
76
|
+
def request(method:, path:, query: nil, body: nil, form: nil, headers: nil, idempotent: false, model: nil,
|
|
77
|
+
request_options: {})
|
|
78
|
+
req = APIRequest.new(http_method: method.to_s.upcase, path: path, query: query, body: body, form: form,
|
|
79
|
+
headers: headers, idempotent: idempotent)
|
|
80
|
+
data, response = execute(req, request_options)
|
|
81
|
+
result = Model.build(model, data, response)
|
|
82
|
+
@with_response ? Result.new(result, response) : result
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @api private
|
|
86
|
+
def request_page(method:, path:, model:, paging:, query: nil, request_options: {})
|
|
87
|
+
req = APIRequest.new(http_method: method.to_s.upcase, path: path, query: query)
|
|
88
|
+
page = load_page(req, model, paging, request_options)
|
|
89
|
+
@with_response ? Result.new(page, page.http_response) : page
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def build_resources; end
|
|
95
|
+
|
|
96
|
+
def presence(value)
|
|
97
|
+
value.nil? || value.to_s.empty? ? nil : value.to_s
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def load_page(req, model, paging, request_options)
|
|
101
|
+
data, response = execute(req, request_options)
|
|
102
|
+
loader = ->(query) { load_page(req.dup.tap { |r| r.query = query }, model, paging, request_options) }
|
|
103
|
+
Page.new(data, model: model, query: req.query, paging: paging, loader: loader, http_response: response)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Sends a request, retrying it as docs/DESIGN-sdks.md says; answers the
|
|
107
|
+
# parsed body and the response, or raises a ZapSMTP::Error.
|
|
108
|
+
def execute(req, request_options)
|
|
109
|
+
options = request_options(request_options)
|
|
110
|
+
attempt = build_attempt(req, options)
|
|
111
|
+
max_retries = options.fetch(:max_retries, @max_retries)
|
|
112
|
+
safe = SAFE_METHODS.include?(req.http_method) || req.idempotent == true
|
|
113
|
+
tries = 0
|
|
114
|
+
loop do
|
|
115
|
+
response, data, wait = attempt_once(attempt, safe, tries, tries < max_retries)
|
|
116
|
+
return [data, response] unless wait
|
|
117
|
+
|
|
118
|
+
sleep_for(wait)
|
|
119
|
+
tries += 1
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# One attempt: [response, data, nil] on success, [nil, nil, seconds to
|
|
124
|
+
# wait] when it should be tried again; raises otherwise.
|
|
125
|
+
def attempt_once(attempt, safe, tries, may_retry)
|
|
126
|
+
begin
|
|
127
|
+
raw = @transport.call(attempt)
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
# a timeout or a broken connection (sending, or reading the answer):
|
|
130
|
+
# the request may have reached the server, so only a request that is
|
|
131
|
+
# safe to repeat is sent again
|
|
132
|
+
return [nil, nil, Retry.backoff(tries, nil)] if may_retry && safe
|
|
133
|
+
|
|
134
|
+
raise connection_error(e)
|
|
135
|
+
end
|
|
136
|
+
response = Response.new(raw.status.to_i, normalize_headers(raw.headers))
|
|
137
|
+
data = parse(raw.body)
|
|
138
|
+
return [response, data, nil] if response.status.between?(200, 299)
|
|
139
|
+
|
|
140
|
+
error = Error.from_response(response.status, data, response.headers)
|
|
141
|
+
raise error unless may_retry && Retry.retryable?(response.status, error.code, safe)
|
|
142
|
+
|
|
143
|
+
[nil, nil, Retry.backoff(tries, Retry.retry_after(response.headers))]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A failed attempt as a ConnectionError; when the answer's head came
|
|
147
|
+
# before its body broke off, with its status, headers and request id.
|
|
148
|
+
def connection_error(error)
|
|
149
|
+
return ConnectionError.new(error.message) unless error.is_a?(Transport::ResponseBodyError)
|
|
150
|
+
|
|
151
|
+
headers = normalize_headers(error.headers)
|
|
152
|
+
ConnectionError.new(error.message, status: error.status, headers: headers, request_id: headers["x-request-id"])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def build_attempt(req, options)
|
|
156
|
+
headers = attempt_headers(req, options)
|
|
157
|
+
body = nil
|
|
158
|
+
if req.form
|
|
159
|
+
headers["Content-Type"] = req.form.content_type
|
|
160
|
+
body = req.form.body
|
|
161
|
+
elsif !req.body.nil?
|
|
162
|
+
headers["Content-Type"] = "application/json"
|
|
163
|
+
body = JSON.generate(Util.jsonable(req.body))
|
|
164
|
+
end
|
|
165
|
+
Transport::Request.new(http_method: req.http_method, url: url(req), headers: headers, body: body,
|
|
166
|
+
timeout: options.fetch(:timeout, @timeout))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The headers of a request. Later ones replace earlier ones whatever
|
|
170
|
+
# their case: the client's defaults, the operation's, the
|
|
171
|
+
# Idempotency-Key, the caller's.
|
|
172
|
+
def attempt_headers(req, options)
|
|
173
|
+
headers = { "Authorization" => "Bearer #{@api_key}", "Accept" => "application/json", "User-Agent" => USER_AGENT }
|
|
174
|
+
@default_headers.each { |k, v| set_header(headers, k, v) }
|
|
175
|
+
(req.headers || {}).each { |k, v| set_header(headers, k, v) unless v.nil? }
|
|
176
|
+
set_header(headers, "Idempotency-Key", options[:idempotency_key] || SecureRandom.uuid) if req.idempotent
|
|
177
|
+
(options[:headers] || {}).each { |k, v| set_header(headers, k, v) }
|
|
178
|
+
# an empty key turns deduplication off on the server, so a retried
|
|
179
|
+
# send could go out twice: refused before anything is sent
|
|
180
|
+
key = headers.find { |k, _| k.casecmp?("idempotency-key") }
|
|
181
|
+
if key && key[1].strip.empty?
|
|
182
|
+
raise ValidationError.new("idempotency_key must not be empty", code: "invalid_parameter")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
headers
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def set_header(headers, name, value)
|
|
189
|
+
name = name.to_s
|
|
190
|
+
headers.delete_if { |k, _| k.casecmp?(name) }
|
|
191
|
+
headers[name] = value.to_s
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def url(req)
|
|
195
|
+
query = Util.query_string(req.query)
|
|
196
|
+
query.empty? ? "#{@base_url}#{req.path}" : "#{@base_url}#{req.path}?#{query}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def request_options(options)
|
|
200
|
+
options = (options || {}).transform_keys(&:to_sym)
|
|
201
|
+
unknown = options.keys - REQUEST_OPTIONS
|
|
202
|
+
raise ArgumentError, "unknown request option(s): #{unknown.join(", ")}" unless unknown.empty?
|
|
203
|
+
|
|
204
|
+
options
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def normalize_headers(headers)
|
|
208
|
+
(headers || {}).each_with_object({}) do |(k, v), out|
|
|
209
|
+
out[k.to_s.downcase] = v.is_a?(Array) ? v.join(", ") : v.to_s
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def parse(body)
|
|
214
|
+
return nil if body.nil? || body.empty?
|
|
215
|
+
|
|
216
|
+
Util.finite(JSON.parse(body, symbolize_names: true, allow_nan: true))
|
|
217
|
+
rescue JSON::ParserError
|
|
218
|
+
body
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def sleep_for(seconds)
|
|
222
|
+
sleep(seconds)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
data/lib/zapsmtp/core.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Everything but the Rails integration (lib/zapsmtp.rb adds it when Rails is loaded).
|
|
4
|
+
require_relative "version"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "response"
|
|
7
|
+
require_relative "model"
|
|
8
|
+
require_relative "page"
|
|
9
|
+
require_relative "util"
|
|
10
|
+
require_relative "attachment"
|
|
11
|
+
require_relative "retry"
|
|
12
|
+
require_relative "transport"
|
|
13
|
+
require_relative "base_client"
|
|
14
|
+
require_relative "webhook"
|
|
15
|
+
require_relative "webhook_middleware"
|
|
16
|
+
require_relative "generated/models"
|
|
17
|
+
require_relative "generated/resources"
|
|
18
|
+
require_relative "generated/client"
|