camelmailer 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9d144156fc057f5dede175ed29f43b9c6587a33ae84b7b25c097a814c1537b7
4
- data.tar.gz: 482b37a49af5b42f3a527788a59c1dfaeb115797ed985c202deb7841c8fd8058
3
+ metadata.gz: 28bcdafc5b4d88c345cfdde3bcee95fef29cca02184e6585a68c792f6d1c45e6
4
+ data.tar.gz: ead53848baadb6a9fa379a65db5cd6c21500af8413c965832b7306a655f3a037
5
5
  SHA512:
6
- metadata.gz: 865924a5a43a2087e6e73abfb70e5fbb677b7cf9d961aa893bca18eec7859320f7a879d97a4d22d16e37d1b1840f1490ea1436692a357aa74aa24302a4d29801
7
- data.tar.gz: a14c759e870581dec25f9a8eb3bd1cdf2e24ba4e9a9989d9128193e45858ee1f51022dc5a7886e33f31f7d2c61d6c5b60766f42fd95a498c85e892282cfb15b3
6
+ metadata.gz: cf0a7c0616c7e1d846925aa7d2aabdc07b77f0991537b63a744e544dcdd1af4d17cf9fb54e16d3f1d93f375104736129eab3185fe68f2e1a1b4653b201f024e0
7
+ data.tar.gz: 8730ed2043e369e014e4931012db680417a2b86581a5cbabc75c65eb8ffa11e64ce84a3dafdb731917530c0ece5a3d837d976d53d7bb462daee716d5a7389bd0
data/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-09-14
11
+
12
+ ### Fixed
13
+
14
+ - `Emails.send_batch` and `Emails.send_with_template_batch` wrapped the
15
+ entries in `{ messages: [...] }`. The endpoint reads a bare JSON array,
16
+ so every batch send was rejected before anything was queued. The existing
17
+ spec asserted the wrapper, which is why it survived. Both now send the
18
+ array as given.
19
+ - `sig/camelmailer.rbs` had a syntax error (`(module)` is not a type) and
20
+ could not be parsed by any type checker. CI now validates it.
21
+
22
+ ### Added
23
+
24
+ - `Campaigns`: `create_draft`, `create_and_send`, `list`, `list_for_stream`,
25
+ `get`, `get_for_stream`, `update`, `send`, `cancel`. The two create
26
+ methods hit different routes: `create_draft` writes the campaign and
27
+ waits, `create_and_send` expands it to the stream's subscribers before
28
+ the call returns.
29
+ - `Subscribers`: `list`, `add`, `import`, `complaint`, `remove`.
30
+ - `Layouts`: `list`, `create`, `get`, `update`, `delete`, `upload_logo`.
31
+ - `Inbound`: `list`, `get`, `retry`, `bypass`.
32
+ - `Logs`: `list`, `tags`.
33
+ - `Emails.send_to_stream` for broadcasting to a stream's subscribers.
34
+ - An optional `idempotency_key:` on every send. It travels as the
35
+ `Idempotency-Key` header, because the body is what the server hashes to
36
+ recognise a replay.
37
+ - `SendLimitExceededError` (429) and `InvalidIdempotentRequestError` (409).
38
+ - `Client#delete`, and request headers on `Client#post`.
39
+
10
40
  ## [0.1.0] - 2026-07-11
11
41
 
12
42
  ### Added
@@ -18,5 +48,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
18
48
  - 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
49
  - RBS type signatures.
20
50
 
21
- [Unreleased]: https://github.com/camelmailer/camelmailer-ruby/compare/v0.1.0...HEAD
51
+ [Unreleased]: https://github.com/camelmailer/camelmailer-ruby/compare/v0.2.0...HEAD
52
+ [0.2.0]: https://github.com/camelmailer/camelmailer-ruby/releases/tag/v0.2.0
22
53
  [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,...")
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
@@ -34,38 +34,34 @@ module CamelMailer
34
34
  @read_timeout = read_timeout
35
35
  end
36
36
 
37
- def emails
38
- @emails ||= Emails.new(self)
39
- end
40
-
41
- def templates
42
- @templates ||= Templates.new(self)
43
- end
44
-
45
- def streams
46
- @streams ||= Streams.new(self)
47
- end
48
-
49
- def stats
50
- @stats ||= Stats.new(self)
51
- end
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
- def post(path, body = nil)
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
@@ -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
- def send(params)
13
- client.post("messages", params)
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
- def send_batch(messages)
18
- client.post("messages/batch", { messages: messages })
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
- def send_with_template(params)
23
- client.post("messages/with_template", params)
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", { messages: messages })
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
@@ -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,43 @@
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,...") and returns the absolute URL to
38
+ # reference from the wrapper.
39
+ def upload_logo(permalink, data_url)
40
+ client.post("layouts/#{permalink}/logo", { data_url: data_url })
41
+ end
42
+ end
43
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CamelMailer
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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: () { (module) -> void } -> module
13
- def self.config: () { (module) -> void } -> module
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.0
4
+ version: 0.2.0
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-13 00:00:00.000000000 Z
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