camelmailer 0.1.0 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +41 -1
- data/README.md +89 -0
- data/lib/camelmailer/campaigns.rb +67 -0
- data/lib/camelmailer/client.rb +26 -25
- data/lib/camelmailer/emails.rb +39 -10
- data/lib/camelmailer/errors.rb +12 -1
- data/lib/camelmailer/inbound.rb +32 -0
- data/lib/camelmailer/layouts.rb +44 -0
- data/lib/camelmailer/logs.rb +22 -0
- data/lib/camelmailer/subscribers.rb +56 -0
- data/lib/camelmailer/version.rb +1 -1
- data/lib/camelmailer.rb +5 -0
- data/sig/camelmailer.rbs +99 -11
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41d8264272f61a2491baff76c168d97a23b4e1796d857aafdd71231575857646
|
|
4
|
+
data.tar.gz: 62d16a4eaf8c26b46d2ae6eceab8da589b6915218338ec2f7637795586fd40d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3131d93b2ce22e1db7b94c657729fc3f144a85921f95358865408321a7663963ab46e3351c066d0775ba009731f9d654082c59fd7253eb8d358705f9a655c5e7
|
|
7
|
+
data.tar.gz: '09dc68868cda095877223425c26f338a9895bc0af4e198143c029097d662cdc60f3e92f34c0c6faf39010cfcd16cd16ce9fc6d20dfb31f5776cef196583b44b5'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1] - 2026-09-14
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- The `upload_logo` documentation and spec read `:logo_url`. The endpoint
|
|
15
|
+
answers with `:url`, so anyone following the README read a key that is
|
|
16
|
+
never there. No call was broken; only the documented shape was wrong.
|
|
17
|
+
|
|
18
|
+
## [0.2.0] - 2026-09-14
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `Emails.send_batch` and `Emails.send_with_template_batch` wrapped the
|
|
23
|
+
entries in `{ messages: [...] }`. The endpoint reads a bare JSON array,
|
|
24
|
+
so every batch send was rejected before anything was queued. The existing
|
|
25
|
+
spec asserted the wrapper, which is why it survived. Both now send the
|
|
26
|
+
array as given.
|
|
27
|
+
- `sig/camelmailer.rbs` had a syntax error (`(module)` is not a type) and
|
|
28
|
+
could not be parsed by any type checker. CI now validates it.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `Campaigns`: `create_draft`, `create_and_send`, `list`, `list_for_stream`,
|
|
33
|
+
`get`, `get_for_stream`, `update`, `send`, `cancel`. The two create
|
|
34
|
+
methods hit different routes: `create_draft` writes the campaign and
|
|
35
|
+
waits, `create_and_send` expands it to the stream's subscribers before
|
|
36
|
+
the call returns.
|
|
37
|
+
- `Subscribers`: `list`, `add`, `import`, `complaint`, `remove`.
|
|
38
|
+
- `Layouts`: `list`, `create`, `get`, `update`, `delete`, `upload_logo`.
|
|
39
|
+
- `Inbound`: `list`, `get`, `retry`, `bypass`.
|
|
40
|
+
- `Logs`: `list`, `tags`.
|
|
41
|
+
- `Emails.send_to_stream` for broadcasting to a stream's subscribers.
|
|
42
|
+
- An optional `idempotency_key:` on every send. It travels as the
|
|
43
|
+
`Idempotency-Key` header, because the body is what the server hashes to
|
|
44
|
+
recognise a replay.
|
|
45
|
+
- `SendLimitExceededError` (429) and `InvalidIdempotentRequestError` (409).
|
|
46
|
+
- `Client#delete`, and request headers on `Client#post`.
|
|
47
|
+
|
|
10
48
|
## [0.1.0] - 2026-07-11
|
|
11
49
|
|
|
12
50
|
### Added
|
|
@@ -18,5 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
18
56
|
- Rails integration: `:camelmailer` ActionMailer delivery method with full MIME mapping (from/to/cc/bcc/reply_to, html+text parts, attachments, custom headers, tag/stream/metadata pseudo-headers).
|
|
19
57
|
- RBS type signatures.
|
|
20
58
|
|
|
21
|
-
[Unreleased]: https://github.com/camelmailer/camelmailer-ruby/compare/v0.1
|
|
59
|
+
[Unreleased]: https://github.com/camelmailer/camelmailer-ruby/compare/v0.2.1...HEAD
|
|
60
|
+
[0.2.1]: https://github.com/camelmailer/camelmailer-ruby/releases/tag/v0.2.1
|
|
61
|
+
[0.2.0]: https://github.com/camelmailer/camelmailer-ruby/releases/tag/v0.2.0
|
|
22
62
|
[0.1.0]: https://github.com/camelmailer/camelmailer-ruby/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -88,6 +88,7 @@ CamelMailer::Emails.send(from:, to:, subject:, html_body:, text_body:, cc:, bcc:
|
|
|
88
88
|
CamelMailer::Emails.send_batch([{ from:, to:, ... }, ...])
|
|
89
89
|
CamelMailer::Emails.send_with_template(from:, to:, template: "welcome", template_model: { name: "Ada" })
|
|
90
90
|
CamelMailer::Emails.send_with_template_batch([...])
|
|
91
|
+
CamelMailer::Emails.send_to_stream("newsletter", from:, subject:, text_body:)
|
|
91
92
|
CamelMailer::Emails.get(42)
|
|
92
93
|
CamelMailer::Emails.list(scope: "outgoing", status: "Sent", tag: "receipt", query: "ada", page: 1, per_page: 50)
|
|
93
94
|
CamelMailer::Emails.deliveries(42) # delivery attempts
|
|
@@ -98,6 +99,20 @@ CamelMailer::Emails.raw(42) # raw RFC 5322 source
|
|
|
98
99
|
|
|
99
100
|
Attachments are `{ name:, content_type:, data_base64: }`; addresses are either `"a@b.com"` or `{ email: "a@b.com", name: "Ada" }`.
|
|
100
101
|
|
|
102
|
+
`send_to_stream` broadcasts to everyone subscribed to a stream, up to 1000
|
|
103
|
+
per call; the response counts `queued:` against `skipped:`, so a larger
|
|
104
|
+
audience wants a campaign.
|
|
105
|
+
|
|
106
|
+
Every send takes an optional `idempotency_key:`, which makes it replayable:
|
|
107
|
+
the same key with the same body returns the first result instead of sending
|
|
108
|
+
twice, and a different body under the same key raises
|
|
109
|
+
`CamelMailer::InvalidIdempotentRequestError`.
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
CamelMailer::Emails.send(from: "billing@acme.com", to: ["ada@example.com"],
|
|
113
|
+
subject: "Your receipt", idempotency_key: "receipt-#{order.id}")
|
|
114
|
+
```
|
|
115
|
+
|
|
101
116
|
### Templates
|
|
102
117
|
|
|
103
118
|
```ruby
|
|
@@ -119,6 +134,80 @@ CamelMailer::Streams.update("broadcasts", name: "News")
|
|
|
119
134
|
CamelMailer::Streams.archive("broadcasts")
|
|
120
135
|
```
|
|
121
136
|
|
|
137
|
+
### Campaigns
|
|
138
|
+
|
|
139
|
+
A campaign is content plus an audience. The two ways to create one behave
|
|
140
|
+
differently, so pick deliberately: `create_draft` writes it and waits,
|
|
141
|
+
`create_and_send` expands it to the stream's subscribers before the call
|
|
142
|
+
returns.
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
CamelMailer::Campaigns.create_draft(stream: "newsletter", name: "September",
|
|
146
|
+
from: "news@acme.com", subject: "What shipped",
|
|
147
|
+
text_body: "Hello.")
|
|
148
|
+
# scheduled_at: "2026-10-01T09:00:00Z" arms it as `scheduled`
|
|
149
|
+
# send_now: true sends it on creation
|
|
150
|
+
|
|
151
|
+
CamelMailer::Campaigns.create_and_send("newsletter", name: "Status update",
|
|
152
|
+
from: "news@acme.com", text_body: "All clear.")
|
|
153
|
+
|
|
154
|
+
CamelMailer::Campaigns.list
|
|
155
|
+
CamelMailer::Campaigns.list_for_stream("newsletter")
|
|
156
|
+
CamelMailer::Campaigns.get(7) # with stats
|
|
157
|
+
CamelMailer::Campaigns.get_for_stream("newsletter", 7)
|
|
158
|
+
CamelMailer::Campaigns.update(7, subject: "Corrected")
|
|
159
|
+
CamelMailer::Campaigns.update(7, scheduled_at: nil) # back to draft
|
|
160
|
+
CamelMailer::Campaigns.send(7) # now, whatever the schedule said
|
|
161
|
+
CamelMailer::Campaigns.cancel(7)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Subscribers
|
|
165
|
+
|
|
166
|
+
A broadcast send to an address that is not subscribed is refused, so this
|
|
167
|
+
list is the audience.
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
CamelMailer::Subscribers.list("newsletter")
|
|
171
|
+
CamelMailer::Subscribers.add("newsletter", address: "ada@example.com", name: "Ada")
|
|
172
|
+
CamelMailer::Subscribers.import("newsletter", ["ada@example.com", "grace@example.com"])
|
|
173
|
+
CamelMailer::Subscribers.complaint("newsletter", "ada@example.com") # suppress + unsubscribe
|
|
174
|
+
CamelMailer::Subscribers.remove("newsletter", "ada@example.com")
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Layouts
|
|
178
|
+
|
|
179
|
+
A layout wraps every template that uses it. `html_wrapper` has to embed the
|
|
180
|
+
body with `{{{ content }}}`.
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
CamelMailer::Layouts.list
|
|
184
|
+
CamelMailer::Layouts.create(name: "Default", permalink: "default",
|
|
185
|
+
html_wrapper: "<html><body>{{{ content }}}</body></html>")
|
|
186
|
+
CamelMailer::Layouts.get("default")
|
|
187
|
+
CamelMailer::Layouts.update("default", name: "Main")
|
|
188
|
+
CamelMailer::Layouts.upload_logo("default", "data:image/png;base64,...")[:url]
|
|
189
|
+
CamelMailer::Layouts.delete("default")
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Inbound and held messages
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
CamelMailer::Inbound.list(status: "held")
|
|
196
|
+
CamelMailer::Inbound.get(55)
|
|
197
|
+
CamelMailer::Inbound.retry(55) # back on the delivery queue
|
|
198
|
+
CamelMailer::Inbound.bypass(55) # release past the hold
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Logs
|
|
202
|
+
|
|
203
|
+
Useful when a send did not arrive and the question is whether the request
|
|
204
|
+
ever reached the API.
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
CamelMailer::Logs.list(per_page: 25)
|
|
208
|
+
CamelMailer::Logs.tags
|
|
209
|
+
```
|
|
210
|
+
|
|
122
211
|
### Stats & bounces
|
|
123
212
|
|
|
124
213
|
```ruby
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CamelMailer
|
|
4
|
+
# Broadcast campaigns (/api/v2/server/campaigns...).
|
|
5
|
+
#
|
|
6
|
+
# A campaign is content plus an audience. There are two ways to create
|
|
7
|
+
# one and they behave differently: #create_draft writes it and waits,
|
|
8
|
+
# while #create_and_send expands it to the stream's subscribers before
|
|
9
|
+
# the call returns.
|
|
10
|
+
class Campaigns < Resource
|
|
11
|
+
expose :list, :list_for_stream, :get, :get_for_stream, :create_draft,
|
|
12
|
+
:create_and_send, :update, :send, :cancel
|
|
13
|
+
|
|
14
|
+
# Every campaign on the server, newest first.
|
|
15
|
+
def list
|
|
16
|
+
client.get("campaigns")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The campaigns of one broadcast stream.
|
|
20
|
+
def list_for_stream(permalink)
|
|
21
|
+
client.get("streams/#{permalink}/campaigns")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# One campaign together with its statistics.
|
|
25
|
+
def get(id)
|
|
26
|
+
client.get("campaigns/#{id}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# One campaign through its stream.
|
|
30
|
+
def get_for_stream(permalink, id)
|
|
31
|
+
client.get("streams/#{permalink}/campaigns/#{id}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Creates a campaign without sending it. Name the audience with
|
|
35
|
+
# stream:. Leave scheduled_at: out for a draft, set it for a
|
|
36
|
+
# scheduled send, or pass send_now: true to send on creation.
|
|
37
|
+
def create_draft(params)
|
|
38
|
+
client.post("campaigns", params)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Creates a campaign on a broadcast stream and sends it immediately.
|
|
42
|
+
# The send starts before this call returns, so there is no draft to
|
|
43
|
+
# review and no schedule to set. Use #create_draft when the campaign
|
|
44
|
+
# should wait.
|
|
45
|
+
def create_and_send(permalink, params)
|
|
46
|
+
client.post("streams/#{permalink}/campaigns", params)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Updates a draft or scheduled campaign. Setting scheduled_at: moves a
|
|
50
|
+
# draft to scheduled; nil clears the schedule and drops it back to a
|
|
51
|
+
# draft. A campaign that is already sending cannot be edited.
|
|
52
|
+
def update(id, params)
|
|
53
|
+
client.patch("campaigns/#{id}", params)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Sends a campaign now, whatever its schedule said.
|
|
57
|
+
def send(id)
|
|
58
|
+
client.post("campaigns/#{id}/send")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Cancels a scheduled or in-flight campaign. Messages already queued
|
|
62
|
+
# are not recalled.
|
|
63
|
+
def cancel(id)
|
|
64
|
+
client.post("campaigns/#{id}/cancel")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/camelmailer/client.rb
CHANGED
|
@@ -34,38 +34,34 @@ module CamelMailer
|
|
|
34
34
|
@read_timeout = read_timeout
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def bounces
|
|
54
|
-
@bounces ||= Bounces.new(self)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def dmarc
|
|
58
|
-
@dmarc ||= Dmarc.new(self)
|
|
37
|
+
# The resource each accessor returns, memoized per client so
|
|
38
|
+
# +client.emails+ is the same object every time. Named by constant
|
|
39
|
+
# rather than by class, because the resource files are required after
|
|
40
|
+
# this one.
|
|
41
|
+
RESOURCES = {
|
|
42
|
+
emails: :Emails, templates: :Templates, streams: :Streams,
|
|
43
|
+
stats: :Stats, bounces: :Bounces, dmarc: :Dmarc,
|
|
44
|
+
campaigns: :Campaigns, subscribers: :Subscribers, layouts: :Layouts,
|
|
45
|
+
inbound: :Inbound, logs: :Logs
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
RESOURCES.each do |name, const|
|
|
49
|
+
define_method(name) do
|
|
50
|
+
(@resources ||= {})[name] ||= CamelMailer.const_get(const).new(self)
|
|
51
|
+
end
|
|
59
52
|
end
|
|
60
53
|
|
|
61
54
|
def get(path, query = nil)
|
|
62
55
|
perform(Net::HTTP::Get.new(build_uri(path, query)))
|
|
63
56
|
end
|
|
64
57
|
|
|
65
|
-
|
|
58
|
+
# +headers+ carries request headers such as Idempotency-Key, which
|
|
59
|
+
# belong outside the body: the body is what the server hashes to
|
|
60
|
+
# recognise the same request.
|
|
61
|
+
def post(path, body = nil, headers = {})
|
|
66
62
|
req = Net::HTTP::Post.new(build_uri(path))
|
|
67
63
|
attach_body(req, body)
|
|
68
|
-
perform(req)
|
|
64
|
+
perform(req, headers)
|
|
69
65
|
end
|
|
70
66
|
|
|
71
67
|
def patch(path, body = nil)
|
|
@@ -74,6 +70,10 @@ module CamelMailer
|
|
|
74
70
|
perform(req)
|
|
75
71
|
end
|
|
76
72
|
|
|
73
|
+
def delete(path)
|
|
74
|
+
perform(Net::HTTP::Delete.new(build_uri(path)))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
77
|
private
|
|
78
78
|
|
|
79
79
|
def build_uri(path, query = nil)
|
|
@@ -92,10 +92,11 @@ module CamelMailer
|
|
|
92
92
|
req.body = JSON.generate(body)
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
-
def perform(req)
|
|
95
|
+
def perform(req, headers = {})
|
|
96
96
|
req["X-Server-API-Key"] = api_key
|
|
97
97
|
req["Accept"] = "application/json"
|
|
98
98
|
req["User-Agent"] = "camelmailer-ruby/#{VERSION}"
|
|
99
|
+
headers.each { |name, value| req[name.to_s] = value.to_s }
|
|
99
100
|
|
|
100
101
|
handle(transport(req.uri).request(req))
|
|
101
102
|
rescue *NETWORK_ERRORS => e
|
data/lib/camelmailer/emails.rb
CHANGED
|
@@ -4,28 +4,51 @@ module CamelMailer
|
|
|
4
4
|
# Send and read messages (POST/GET /api/v2/server/messages...).
|
|
5
5
|
class Emails < Resource
|
|
6
6
|
expose :send, :send_batch, :send_with_template, :send_with_template_batch,
|
|
7
|
-
:get, :list, :deliveries, :opens, :clicks, :raw
|
|
7
|
+
:send_to_stream, :get, :list, :deliveries, :opens, :clicks, :raw
|
|
8
8
|
|
|
9
9
|
# Sends one message. Required: from:, to:. See the API docs for the
|
|
10
10
|
# full SendRequest shape (html_body, text_body, cc, bcc, reply_to,
|
|
11
11
|
# headers, attachments, tag, metadata, stream).
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
#
|
|
13
|
+
# Pass idempotency_key: to make the send replayable: the same key with
|
|
14
|
+
# the same body returns the first result instead of sending twice.
|
|
15
|
+
#
|
|
16
|
+
# Takes the message either as a hash or as keywords, because both read
|
|
17
|
+
# naturally and both were already in use:
|
|
18
|
+
#
|
|
19
|
+
# emails.send(from: "a@acme.com", to: ["b@e.com"], subject: "Hi")
|
|
20
|
+
# emails.send(params, idempotency_key: "receipt-7")
|
|
21
|
+
def send(params = {}, **fields)
|
|
22
|
+
key = fields.delete(:idempotency_key)
|
|
23
|
+
client.post("messages", params.merge(fields), idempotency_headers(key))
|
|
14
24
|
end
|
|
15
25
|
|
|
16
26
|
# Sends a batch of SendRequest hashes; returns one result per entry.
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
#
|
|
28
|
+
# Sent as a bare JSON array, which is what the endpoint reads.
|
|
29
|
+
def send_batch(messages, idempotency_key: nil)
|
|
30
|
+
client.post("messages/batch", messages, idempotency_headers(idempotency_key))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Sends the same content to every subscriber of a broadcast stream.
|
|
34
|
+
# Either give subject: with a body, or a template: permalink with an
|
|
35
|
+
# optional template_model:. The response counts queued: against
|
|
36
|
+
# skipped:; recipients past the per-request cap of 1000 are skipped,
|
|
37
|
+
# so a larger audience wants a campaign.
|
|
38
|
+
def send_to_stream(permalink, params)
|
|
39
|
+
client.post("streams/#{permalink}/send", params)
|
|
19
40
|
end
|
|
20
41
|
|
|
21
42
|
# Renders a stored template against template_model:, then sends.
|
|
22
|
-
|
|
23
|
-
|
|
43
|
+
# Takes idempotency_key: the same way #send does.
|
|
44
|
+
def send_with_template(params = {}, **fields)
|
|
45
|
+
key = fields.delete(:idempotency_key)
|
|
46
|
+
client.post("messages/with_template", params.merge(fields), idempotency_headers(key))
|
|
24
47
|
end
|
|
25
48
|
|
|
26
|
-
# Batch variant of #send_with_template.
|
|
27
|
-
def send_with_template_batch(messages)
|
|
28
|
-
client.post("messages/with_template/batch",
|
|
49
|
+
# Batch variant of #send_with_template. Also a bare JSON array.
|
|
50
|
+
def send_with_template_batch(messages, idempotency_key: nil)
|
|
51
|
+
client.post("messages/with_template/batch", messages, idempotency_headers(idempotency_key))
|
|
29
52
|
end
|
|
30
53
|
|
|
31
54
|
# Shows one message by id.
|
|
@@ -58,5 +81,11 @@ module CamelMailer
|
|
|
58
81
|
def raw(id)
|
|
59
82
|
client.get("messages/#{id}/raw")
|
|
60
83
|
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def idempotency_headers(key)
|
|
88
|
+
key.nil? ? {} : { "Idempotency-Key" => key }
|
|
89
|
+
end
|
|
61
90
|
end
|
|
62
91
|
end
|
data/lib/camelmailer/errors.rb
CHANGED
|
@@ -43,6 +43,15 @@ module CamelMailer
|
|
|
43
43
|
# 400 — a required parameter is missing.
|
|
44
44
|
class ParameterMissingError < APIError; end
|
|
45
45
|
|
|
46
|
+
# 429 — the server's 30-day send allowance is used up. Answered before
|
|
47
|
+
# anything is stored, so nothing was queued and a retry once the window
|
|
48
|
+
# moves on will work.
|
|
49
|
+
class SendLimitExceededError < APIError; end
|
|
50
|
+
|
|
51
|
+
# 409 — an Idempotency-Key was reused for a different request body, or
|
|
52
|
+
# sent twice on one request.
|
|
53
|
+
class InvalidIdempotentRequestError < APIError; end
|
|
54
|
+
|
|
46
55
|
# Unexpected non-envelope response (e.g. a 5xx from a proxy).
|
|
47
56
|
class ServerError < APIError; end
|
|
48
57
|
|
|
@@ -51,6 +60,8 @@ module CamelMailer
|
|
|
51
60
|
"Forbidden" => ForbiddenError,
|
|
52
61
|
"NotFound" => NotFoundError,
|
|
53
62
|
"ValidationError" => ValidationError,
|
|
54
|
-
"ParameterMissing" => ParameterMissingError
|
|
63
|
+
"ParameterMissing" => ParameterMissingError,
|
|
64
|
+
"SendLimitExceeded" => SendLimitExceededError,
|
|
65
|
+
"InvalidIdempotentRequest" => InvalidIdempotentRequestError
|
|
55
66
|
}.freeze
|
|
56
67
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CamelMailer
|
|
4
|
+
# Inbound and held messages (/api/v2/server/inbound...).
|
|
5
|
+
#
|
|
6
|
+
# Covers mail arriving through an inbound route as well as outbound mail
|
|
7
|
+
# the spam filter put on hold, which is why a message here can be either
|
|
8
|
+
# retried or released past the hold.
|
|
9
|
+
class Inbound < Resource
|
|
10
|
+
expose :list, :get, :retry, :bypass
|
|
11
|
+
|
|
12
|
+
# Searches inbound and held messages, newest first.
|
|
13
|
+
def list(**filters)
|
|
14
|
+
client.get("inbound", filters)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def get(id)
|
|
18
|
+
client.get("inbound/#{id}")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Puts a message back on the delivery queue, for instance after fixing
|
|
22
|
+
# the route it should have matched.
|
|
23
|
+
def retry(id)
|
|
24
|
+
client.post("inbound/#{id}/retry")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Releases a held message past the hold and delivers it.
|
|
28
|
+
def bypass(id)
|
|
29
|
+
client.post("inbound/#{id}/bypass")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CamelMailer
|
|
4
|
+
# Template layouts (/api/v2/server/layouts...).
|
|
5
|
+
#
|
|
6
|
+
# A layout wraps every template that uses it, so header, footer and
|
|
7
|
+
# styling live in one place instead of in each template.
|
|
8
|
+
class Layouts < Resource
|
|
9
|
+
expose :list, :create, :get, :update, :delete, :upload_logo
|
|
10
|
+
|
|
11
|
+
def list
|
|
12
|
+
client.get("layouts")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Creates a layout. html_wrapper: has to embed the body with
|
|
16
|
+
# {{{ content }}}; anything else is refused with ValidationError.
|
|
17
|
+
def create(params)
|
|
18
|
+
client.post("layouts", params)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get(permalink)
|
|
22
|
+
client.get("layouts/#{permalink}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Updates a layout; only the given fields change.
|
|
26
|
+
def update(permalink, params)
|
|
27
|
+
client.patch("layouts/#{permalink}", params)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Deletes a layout. Templates that referenced it fall back to no
|
|
31
|
+
# wrapper.
|
|
32
|
+
def delete(permalink)
|
|
33
|
+
client.delete("layouts/#{permalink}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Uploads the layout's logo as a data URL
|
|
37
|
+
# ("data:image/png;base64,..."). The absolute URL to reference from
|
|
38
|
+
# the wrapper comes back under :url; it is served without
|
|
39
|
+
# authentication, because mail clients fetch it without a session.
|
|
40
|
+
def upload_logo(permalink, data_url)
|
|
41
|
+
client.post("layouts/#{permalink}/logo", { data_url: data_url })
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CamelMailer
|
|
4
|
+
# The server's own request log and tag index
|
|
5
|
+
# (/api/v2/server/logs, /api/v2/server/tags).
|
|
6
|
+
#
|
|
7
|
+
# Useful when a send did not arrive and the question is whether the
|
|
8
|
+
# request ever reached the API, and with what answer.
|
|
9
|
+
class Logs < Resource
|
|
10
|
+
expose :list, :tags
|
|
11
|
+
|
|
12
|
+
# Logged API requests, newest first.
|
|
13
|
+
def list(**filters)
|
|
14
|
+
client.get("logs", filters)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Tags used by the server's recent messages, most used first.
|
|
18
|
+
def tags
|
|
19
|
+
client.get("tags")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module CamelMailer
|
|
6
|
+
# Opt-in subscribers of a broadcast stream
|
|
7
|
+
# (/api/v2/server/streams/:permalink/subscribers...).
|
|
8
|
+
#
|
|
9
|
+
# A broadcast send to an address that is not subscribed is refused, so
|
|
10
|
+
# this list is the audience.
|
|
11
|
+
class Subscribers < Resource
|
|
12
|
+
expose :list, :add, :import, :complaint, :remove
|
|
13
|
+
|
|
14
|
+
# The stream's subscribers, subscribed and unsubscribed alike.
|
|
15
|
+
def list(permalink)
|
|
16
|
+
client.get(base(permalink))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Adds or updates one subscriber. Upserts by address:, so calling it
|
|
20
|
+
# twice is safe.
|
|
21
|
+
def add(permalink, params)
|
|
22
|
+
client.post(base(permalink), params)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Adds many addresses at once, all as subscribed. Blanks and
|
|
26
|
+
# duplicates within the request are skipped, and the response reports
|
|
27
|
+
# how many were written against how many survived that filtering.
|
|
28
|
+
def import(permalink, addresses)
|
|
29
|
+
client.post("#{base(permalink)}/import", { addresses: addresses })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Records a spam complaint: writes a stream-scoped suppression and
|
|
33
|
+
# flips the subscription to unsubscribed. Idempotent, so a feedback
|
|
34
|
+
# loop can replay it safely.
|
|
35
|
+
def complaint(permalink, address)
|
|
36
|
+
client.post("#{base(permalink)}/#{encode(address)}/complaint")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Removes a subscriber from the stream entirely.
|
|
40
|
+
def remove(permalink, address)
|
|
41
|
+
client.delete("#{base(permalink)}/#{encode(address)}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def base(permalink)
|
|
47
|
+
"streams/#{permalink}/subscribers"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The plus in an address has to survive the path, or a different
|
|
51
|
+
# address is addressed.
|
|
52
|
+
def encode(address)
|
|
53
|
+
URI.encode_www_form_component(address).gsub("+", "%2B")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/camelmailer/version.rb
CHANGED
data/lib/camelmailer.rb
CHANGED
|
@@ -10,6 +10,11 @@ require "camelmailer/streams"
|
|
|
10
10
|
require "camelmailer/stats"
|
|
11
11
|
require "camelmailer/bounces"
|
|
12
12
|
require "camelmailer/dmarc"
|
|
13
|
+
require "camelmailer/campaigns"
|
|
14
|
+
require "camelmailer/subscribers"
|
|
15
|
+
require "camelmailer/layouts"
|
|
16
|
+
require "camelmailer/inbound"
|
|
17
|
+
require "camelmailer/logs"
|
|
13
18
|
|
|
14
19
|
# The Ruby SDK for CamelMailer — https://camelmailer.com
|
|
15
20
|
#
|
data/sig/camelmailer.rbs
CHANGED
|
@@ -9,8 +9,8 @@ module CamelMailer
|
|
|
9
9
|
def self.api_key=: (String?) -> String?
|
|
10
10
|
def self.base_url: () -> String
|
|
11
11
|
def self.base_url=: (String?) -> String?
|
|
12
|
-
def self.configure: () { (
|
|
13
|
-
def self.config: () { (
|
|
12
|
+
def self.configure: () { (singleton(CamelMailer)) -> void } -> singleton(CamelMailer)
|
|
13
|
+
def self.config: () { (singleton(CamelMailer)) -> void } -> singleton(CamelMailer)
|
|
14
14
|
def self.client: () -> Client
|
|
15
15
|
def self.reset!: () -> void
|
|
16
16
|
|
|
@@ -45,6 +45,12 @@ module CamelMailer
|
|
|
45
45
|
class ParameterMissingError < APIError
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
class SendLimitExceededError < APIError
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class InvalidIdempotentRequestError < APIError
|
|
52
|
+
end
|
|
53
|
+
|
|
48
54
|
class ServerError < APIError
|
|
49
55
|
end
|
|
50
56
|
|
|
@@ -59,16 +65,24 @@ module CamelMailer
|
|
|
59
65
|
|
|
60
66
|
def initialize: (api_key: String?, ?base_url: String?, ?open_timeout: Integer, ?read_timeout: Integer) -> void
|
|
61
67
|
|
|
68
|
+
RESOURCES: Hash[Symbol, Symbol]
|
|
69
|
+
|
|
62
70
|
def emails: () -> Emails
|
|
63
71
|
def templates: () -> Templates
|
|
64
72
|
def streams: () -> Streams
|
|
65
73
|
def stats: () -> Stats
|
|
66
74
|
def bounces: () -> Bounces
|
|
67
75
|
def dmarc: () -> Dmarc
|
|
76
|
+
def campaigns: () -> Campaigns
|
|
77
|
+
def subscribers: () -> Subscribers
|
|
78
|
+
def layouts: () -> Layouts
|
|
79
|
+
def inbound: () -> Inbound
|
|
80
|
+
def logs: () -> Logs
|
|
68
81
|
|
|
69
82
|
def get: (String path, ?payload? query) -> response
|
|
70
|
-
def post: (String path, ?payload? body) -> response
|
|
83
|
+
def post: (String path, ?(payload | Array[payload])? body, ?Hash[String, String] headers) -> response
|
|
71
84
|
def patch: (String path, ?payload? body) -> response
|
|
85
|
+
def delete: (String path) -> response
|
|
72
86
|
end
|
|
73
87
|
|
|
74
88
|
class Resource
|
|
@@ -79,10 +93,11 @@ module CamelMailer
|
|
|
79
93
|
end
|
|
80
94
|
|
|
81
95
|
class Emails < Resource
|
|
82
|
-
def send: (payload params) -> response
|
|
83
|
-
def send_batch: (Array[payload] messages) -> response
|
|
84
|
-
def send_with_template: (payload params) -> response
|
|
85
|
-
def send_with_template_batch: (Array[payload] messages) -> response
|
|
96
|
+
def send: (?payload params, **untyped fields) -> response
|
|
97
|
+
def send_batch: (Array[payload] messages, ?idempotency_key: String?) -> response
|
|
98
|
+
def send_with_template: (?payload params, **untyped fields) -> response
|
|
99
|
+
def send_with_template_batch: (Array[payload] messages, ?idempotency_key: String?) -> response
|
|
100
|
+
def send_to_stream: (String permalink, payload params) -> response
|
|
86
101
|
def get: (Integer | String id) -> response
|
|
87
102
|
def list: (**untyped filters) -> response
|
|
88
103
|
def deliveries: (Integer | String id) -> response
|
|
@@ -90,10 +105,11 @@ module CamelMailer
|
|
|
90
105
|
def clicks: (Integer | String id) -> response
|
|
91
106
|
def raw: (Integer | String id) -> response
|
|
92
107
|
|
|
93
|
-
def self.send: (payload params) -> response
|
|
94
|
-
def self.send_batch: (Array[payload] messages) -> response
|
|
95
|
-
def self.send_with_template: (payload params) -> response
|
|
96
|
-
def self.send_with_template_batch: (Array[payload] messages) -> response
|
|
108
|
+
def self.send: (?payload params, **untyped fields) -> response
|
|
109
|
+
def self.send_batch: (Array[payload] messages, ?idempotency_key: String?) -> response
|
|
110
|
+
def self.send_with_template: (?payload params, **untyped fields) -> response
|
|
111
|
+
def self.send_with_template_batch: (Array[payload] messages, ?idempotency_key: String?) -> response
|
|
112
|
+
def self.send_to_stream: (String permalink, payload params) -> response
|
|
97
113
|
def self.get: (Integer | String id) -> response
|
|
98
114
|
def self.list: (**untyped filters) -> response
|
|
99
115
|
def self.deliveries: (Integer | String id) -> response
|
|
@@ -158,6 +174,78 @@ module CamelMailer
|
|
|
158
174
|
def self.report: (Integer | String id) -> response
|
|
159
175
|
end
|
|
160
176
|
|
|
177
|
+
class Campaigns < Resource
|
|
178
|
+
def list: () -> response
|
|
179
|
+
def list_for_stream: (String permalink) -> response
|
|
180
|
+
def get: (Integer | String id) -> response
|
|
181
|
+
def get_for_stream: (String permalink, Integer | String id) -> response
|
|
182
|
+
def create_draft: (payload params) -> response
|
|
183
|
+
def create_and_send: (String permalink, payload params) -> response
|
|
184
|
+
def update: (Integer | String id, payload params) -> response
|
|
185
|
+
def send: (Integer | String id) -> response
|
|
186
|
+
def cancel: (Integer | String id) -> response
|
|
187
|
+
|
|
188
|
+
def self.list: () -> response
|
|
189
|
+
def self.list_for_stream: (String permalink) -> response
|
|
190
|
+
def self.get: (Integer | String id) -> response
|
|
191
|
+
def self.get_for_stream: (String permalink, Integer | String id) -> response
|
|
192
|
+
def self.create_draft: (payload params) -> response
|
|
193
|
+
def self.create_and_send: (String permalink, payload params) -> response
|
|
194
|
+
def self.update: (Integer | String id, payload params) -> response
|
|
195
|
+
def self.send: (Integer | String id) -> response
|
|
196
|
+
def self.cancel: (Integer | String id) -> response
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
class Subscribers < Resource
|
|
200
|
+
def list: (String permalink) -> response
|
|
201
|
+
def add: (String permalink, payload params) -> response
|
|
202
|
+
def import: (String permalink, Array[String] addresses) -> response
|
|
203
|
+
def complaint: (String permalink, String address) -> response
|
|
204
|
+
def remove: (String permalink, String address) -> response
|
|
205
|
+
|
|
206
|
+
def self.list: (String permalink) -> response
|
|
207
|
+
def self.add: (String permalink, payload params) -> response
|
|
208
|
+
def self.import: (String permalink, Array[String] addresses) -> response
|
|
209
|
+
def self.complaint: (String permalink, String address) -> response
|
|
210
|
+
def self.remove: (String permalink, String address) -> response
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
class Layouts < Resource
|
|
214
|
+
def list: () -> response
|
|
215
|
+
def create: (payload params) -> response
|
|
216
|
+
def get: (String permalink) -> response
|
|
217
|
+
def update: (String permalink, payload params) -> response
|
|
218
|
+
def delete: (String permalink) -> response
|
|
219
|
+
def upload_logo: (String permalink, String data_url) -> response
|
|
220
|
+
|
|
221
|
+
def self.list: () -> response
|
|
222
|
+
def self.create: (payload params) -> response
|
|
223
|
+
def self.get: (String permalink) -> response
|
|
224
|
+
def self.update: (String permalink, payload params) -> response
|
|
225
|
+
def self.delete: (String permalink) -> response
|
|
226
|
+
def self.upload_logo: (String permalink, String data_url) -> response
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
class Inbound < Resource
|
|
230
|
+
def list: (**untyped filters) -> response
|
|
231
|
+
def get: (Integer | String id) -> response
|
|
232
|
+
def retry: (Integer | String id) -> response
|
|
233
|
+
def bypass: (Integer | String id) -> response
|
|
234
|
+
|
|
235
|
+
def self.list: (**untyped filters) -> response
|
|
236
|
+
def self.get: (Integer | String id) -> response
|
|
237
|
+
def self.retry: (Integer | String id) -> response
|
|
238
|
+
def self.bypass: (Integer | String id) -> response
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
class Logs < Resource
|
|
242
|
+
def list: (**untyped filters) -> response
|
|
243
|
+
def tags: () -> response
|
|
244
|
+
|
|
245
|
+
def self.list: (**untyped filters) -> response
|
|
246
|
+
def self.tags: () -> response
|
|
247
|
+
end
|
|
248
|
+
|
|
161
249
|
class Mailer
|
|
162
250
|
IGNORED_HEADERS: Array[String]
|
|
163
251
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: camelmailer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Camelmailer contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Ruby SDK for the Camelmailer transactional email API — send messages,
|
|
14
14
|
manage templates, streams and stats, and plug into Rails via the :camelmailer ActionMailer
|
|
@@ -25,15 +25,20 @@ files:
|
|
|
25
25
|
- README.md
|
|
26
26
|
- lib/camelmailer.rb
|
|
27
27
|
- lib/camelmailer/bounces.rb
|
|
28
|
+
- lib/camelmailer/campaigns.rb
|
|
28
29
|
- lib/camelmailer/client.rb
|
|
29
30
|
- lib/camelmailer/dmarc.rb
|
|
30
31
|
- lib/camelmailer/emails.rb
|
|
31
32
|
- lib/camelmailer/errors.rb
|
|
33
|
+
- lib/camelmailer/inbound.rb
|
|
34
|
+
- lib/camelmailer/layouts.rb
|
|
35
|
+
- lib/camelmailer/logs.rb
|
|
32
36
|
- lib/camelmailer/mailer.rb
|
|
33
37
|
- lib/camelmailer/railtie.rb
|
|
34
38
|
- lib/camelmailer/resource.rb
|
|
35
39
|
- lib/camelmailer/stats.rb
|
|
36
40
|
- lib/camelmailer/streams.rb
|
|
41
|
+
- lib/camelmailer/subscribers.rb
|
|
37
42
|
- lib/camelmailer/templates.rb
|
|
38
43
|
- lib/camelmailer/version.rb
|
|
39
44
|
- sig/camelmailer.rbs
|