diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05a25e1..c9233aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+## v2.2.0 - May 21, 2026
+
+- Added support for campaigns, email messages, themes, components, and dedicated sending IP endpoints.
+- THe SDK now treats `201` responses as success and raises `APIError` for new `401`, `413`, and `422` responses.
+
## v2.1.0 - Aug 8, 2026
- Added support for contact suppression endpoints with `Contacts.check_suppression()` and `Contacts.remove_suppression()`.
diff --git a/Gemfile.lock b/Gemfile.lock
index a843bc7..2bb00b3 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
- loops_sdk (2.1.0)
+ loops_sdk (2.2.0)
faraday
GEM
diff --git a/README.md b/README.md
index 733beed..78c3edf 100644
--- a/README.md
+++ b/README.md
@@ -116,6 +116,17 @@ You can use custom contact properties in API calls. Please make sure to [add cus
- [Events.send()](#eventssend)
- [Transactional.send()](#transactionalsend)
- [Transactional.list()](#transactionallist)
+- [DedicatedSendingIps.list()](#dedicatedsendingipslist)
+- [Themes.list()](#themeslist)
+- [Themes.get()](#themesget)
+- [Components.list()](#componentslist)
+- [Components.get()](#componentsget)
+- [Campaigns.list()](#campaignslist)
+- [Campaigns.create()](#campaignscreate)
+- [Campaigns.get()](#campaignsget)
+- [Campaigns.update()](#campaignsupdate)
+- [EmailMessages.get()](#emailmessagesget)
+- [EmailMessages.update()](#emailmessagesupdate)
---
@@ -870,6 +881,256 @@ response = LoopsSdk::Transactional.list(perPage: 15)
---
+### DedicatedSendingIps.list()
+
+Get Loops' dedicated sending IP addresses.
+
+[API Reference](https://loops.so/docs/api-reference/list-dedicated-sending-ips)
+
+#### Parameters
+
+None
+
+#### Example
+
+```ruby
+response = LoopsSdk::DedicatedSendingIps.list
+```
+
+#### Response
+
+Returns an array of IP address strings.
+
+```json
+["1.2.3.4", "5.6.7.8"]
+```
+
+---
+
+### Themes.list()
+
+List email themes.
+
+[API Reference](https://loops.so/docs/api-reference/list-themes)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `perPage` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. |
+| `cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. |
+
+#### Example
+
+```ruby
+response = LoopsSdk::Themes.list
+
+response = LoopsSdk::Themes.list(perPage: 15, cursor: "cursor_value")
+```
+
+---
+
+### Themes.get()
+
+Get a single theme by ID.
+
+[API Reference](https://loops.so/docs/api-reference/get-theme)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ---------- | ------ | -------- | ----- |
+| `theme_id` | string | Yes | |
+
+#### Example
+
+```ruby
+response = LoopsSdk::Themes.get(theme_id: "theme_123")
+```
+
+---
+
+### Components.list()
+
+List email components.
+
+[API Reference](https://loops.so/docs/api-reference/list-components)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `perPage` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. |
+| `cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. |
+
+#### Example
+
+```ruby
+response = LoopsSdk::Components.list
+```
+
+---
+
+### Components.get()
+
+Get a single component by ID.
+
+[API Reference](https://loops.so/docs/api-reference/get-component)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| -------------- | ------ | -------- | ----- |
+| `component_id` | string | Yes | |
+
+#### Example
+
+```ruby
+response = LoopsSdk::Components.get(component_id: "component_123")
+```
+
+---
+
+### Campaigns.list()
+
+List campaigns.
+
+[API Reference](https://loops.so/docs/api-reference/list-campaigns)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| --------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `perPage` | integer | No | How many results to return per page. Must be between 10 and 50. Defaults to 20 if omitted. |
+| `cursor` | string | No | A cursor, to return a specific page of results. Cursors can be found from the `pagination.nextCursor` value in each response. |
+
+#### Example
+
+```ruby
+response = LoopsSdk::Campaigns.list
+```
+
+---
+
+### Campaigns.create()
+
+Create a draft campaign. An empty email message is created automatically.
+
+[API Reference](https://loops.so/docs/api-reference/create-campaign)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------ | ------ | -------- | ----- |
+| `name` | string | Yes | |
+
+#### Example
+
+```ruby
+response = LoopsSdk::Campaigns.create(name: "Spring announcement")
+```
+
+---
+
+### Campaigns.get()
+
+Get a single campaign by ID.
+
+[API Reference](https://loops.so/docs/api-reference/get-campaign)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------- | ------ | -------- | ----- |
+| `campaign_id` | string | Yes | |
+
+#### Example
+
+```ruby
+response = LoopsSdk::Campaigns.get(campaign_id: "campaign_123")
+```
+
+---
+
+### Campaigns.update()
+
+Update a draft campaign's name.
+
+[API Reference](https://loops.so/docs/api-reference/update-campaign)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------- | ------ | -------- | ----- |
+| `campaign_id` | string | Yes | |
+| `name` | string | Yes | |
+
+#### Example
+
+```ruby
+response = LoopsSdk::Campaigns.update(
+ campaign_id: "campaign_123",
+ name: "Updated campaign name"
+)
+```
+
+---
+
+### EmailMessages.get()
+
+Get an email message, including its LMX content.
+
+[API Reference](https://loops.so/docs/api-reference/get-email-message)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------- | ------ | -------- | ----- |
+| `email_message_id` | string | Yes | |
+
+#### Example
+
+```ruby
+response = LoopsSdk::EmailMessages.get(email_message_id: "message_123")
+```
+
+---
+
+### EmailMessages.update()
+
+Update an email message for a draft campaign.
+
+[API Reference](https://loops.so/docs/api-reference/update-email-message)
+
+#### Parameters
+
+| Name | Type | Required | Notes |
+| ------------------------ | ------ | -------- | ---------------------------------------------------------------------------------------------------------- |
+| `email_message_id` | string | Yes | |
+| `expected_revision_id` | string | No | The `contentRevisionId` from your last fetch. Required to avoid stale concurrent updates. |
+| `subject` | string | No | |
+| `preview_text` | string | No | |
+| `from_name` | string | No | |
+| `from_email` | string | No | Sender username without `@` or domain. The team's sending domain is appended automatically. |
+| `reply_to_email` | string | No | Must be empty or a valid email address. |
+| `lmx` | string | No | Email body serialized as LMX. Styles must be embedded in the LMX `` tag. |
+
+#### Example
+
+```ruby
+response = LoopsSdk::EmailMessages.update(
+ email_message_id: "message_123",
+ expected_revision_id: "revision_123",
+ subject: "Spring announcement",
+ preview_text: "See what's new",
+ from_name: "Loops",
+ from_email: "hello",
+ lmx: ""
+)
+```
+
+---
+
## Testing
Run tests with `bundle exec rspec`.
diff --git a/lib/loops_sdk.rb b/lib/loops_sdk.rb
index d68f1c5..c7e4fa8 100644
--- a/lib/loops_sdk.rb
+++ b/lib/loops_sdk.rb
@@ -11,6 +11,11 @@
require_relative "loops_sdk/transactional"
require_relative "loops_sdk/contact_properties"
require_relative "loops_sdk/api_key"
+require_relative "loops_sdk/dedicated_sending_ips"
+require_relative "loops_sdk/themes"
+require_relative "loops_sdk/components"
+require_relative "loops_sdk/campaigns"
+require_relative "loops_sdk/email_messages"
module LoopsSdk
class << self
diff --git a/lib/loops_sdk/base.rb b/lib/loops_sdk/base.rb
index 4852141..8397ec3 100644
--- a/lib/loops_sdk/base.rb
+++ b/lib/loops_sdk/base.rb
@@ -7,13 +7,13 @@ class << self
def handle_response(response)
case response.status
- when 200
+ when 200, 201
JSON.parse(response.body)
when 429
limit = response.headers["x-ratelimit-limit"]
remaining = response.headers["x-ratelimit-remaining"]
raise RateLimitError.new(limit, remaining)
- when 400, 404, 405, 409, 500
+ when 400, 401, 404, 405, 409, 413, 422, 500
raise APIError.new(response.status, response.body)
else
raise APIError.new(response.status, "Unexpected error occurred")
diff --git a/lib/loops_sdk/campaigns.rb b/lib/loops_sdk/campaigns.rb
new file mode 100644
index 0000000..cb2a15e
--- /dev/null
+++ b/lib/loops_sdk/campaigns.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module LoopsSdk
+ class Campaigns < Base
+ class << self
+ def list(perPage: 20, cursor: nil)
+ make_request(method: :get, path: "v1/campaigns", params: { perPage: perPage, cursor: cursor })
+ end
+
+ def create(name:)
+ make_request(method: :post, path: "v1/campaigns", body: { name: name })
+ end
+
+ def get(campaign_id:)
+ make_request(method: :get, path: "v1/campaigns/#{campaign_id}")
+ end
+
+ def update(campaign_id:, name:)
+ make_request(method: :post, path: "v1/campaigns/#{campaign_id}", body: { name: name })
+ end
+ end
+ end
+end
diff --git a/lib/loops_sdk/components.rb b/lib/loops_sdk/components.rb
new file mode 100644
index 0000000..c46c253
--- /dev/null
+++ b/lib/loops_sdk/components.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module LoopsSdk
+ class Components < Base
+ class << self
+ def list(perPage: 20, cursor: nil)
+ make_request(method: :get, path: "v1/components", params: { perPage: perPage, cursor: cursor })
+ end
+
+ def get(component_id:)
+ make_request(method: :get, path: "v1/components/#{component_id}")
+ end
+ end
+ end
+end
diff --git a/lib/loops_sdk/dedicated_sending_ips.rb b/lib/loops_sdk/dedicated_sending_ips.rb
new file mode 100644
index 0000000..44bac06
--- /dev/null
+++ b/lib/loops_sdk/dedicated_sending_ips.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module LoopsSdk
+ class DedicatedSendingIps < Base
+ class << self
+ def list
+ make_request(method: :get, path: "v1/dedicated-sending-ips")
+ end
+ end
+ end
+end
diff --git a/lib/loops_sdk/email_messages.rb b/lib/loops_sdk/email_messages.rb
new file mode 100644
index 0000000..f4096ea
--- /dev/null
+++ b/lib/loops_sdk/email_messages.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module LoopsSdk
+ class EmailMessages < Base
+ class << self
+ def get(email_message_id:)
+ make_request(method: :get, path: "v1/email-messages/#{email_message_id}")
+ end
+
+ def update(email_message_id:, expected_revision_id: nil, subject: nil, preview_text: nil, from_name: nil, from_email: nil, reply_to_email: nil, lmx: nil)
+ body = {
+ expectedRevisionId: expected_revision_id,
+ subject: subject,
+ previewText: preview_text,
+ fromName: from_name,
+ fromEmail: from_email,
+ replyToEmail: reply_to_email,
+ lmx: lmx
+ }.compact
+ make_request(method: :post, path: "v1/email-messages/#{email_message_id}", body: body)
+ end
+ end
+ end
+end
diff --git a/lib/loops_sdk/themes.rb b/lib/loops_sdk/themes.rb
new file mode 100644
index 0000000..cdc4c5c
--- /dev/null
+++ b/lib/loops_sdk/themes.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module LoopsSdk
+ class Themes < Base
+ class << self
+ def list(perPage: 20, cursor: nil)
+ make_request(method: :get, path: "v1/themes", params: { perPage: perPage, cursor: cursor })
+ end
+
+ def get(theme_id:)
+ make_request(method: :get, path: "v1/themes/#{theme_id}")
+ end
+ end
+ end
+end
diff --git a/lib/loops_sdk/version.rb b/lib/loops_sdk/version.rb
index f20d6f3..11bd50a 100644
--- a/lib/loops_sdk/version.rb
+++ b/lib/loops_sdk/version.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module LoopsSdk
- VERSION = "2.1.0"
+ VERSION = "2.2.0"
end
diff --git a/spec/examples.txt b/spec/examples.txt
index e7dc197..cac4cad 100644
--- a/spec/examples.txt
+++ b/spec/examples.txt
@@ -1,41 +1,48 @@
-example_id | status | run_time |
---------------------------------------------- | ------ | --------------- |
-./spec/loops_sdk/base_spec.rb[1:1:1] | passed | 0.00046 seconds |
-./spec/loops_sdk/base_spec.rb[1:1:2] | passed | 0.00033 seconds |
-./spec/loops_sdk/base_spec.rb[1:1:3] | passed | 0.00156 seconds |
-./spec/loops_sdk/base_spec.rb[1:1:4] | passed | 0.00032 seconds |
-./spec/loops_sdk/base_spec.rb[1:1:5] | passed | 0.00045 seconds |
-./spec/loops_sdk/base_spec.rb[1:1:6] | passed | 0.0025 seconds |
-./spec/loops_sdk/base_spec.rb[1:2:1:1] | passed | 0.00106 seconds |
-./spec/loops_sdk/base_spec.rb[1:2:2:1] | passed | 0.00165 seconds |
-./spec/loops_sdk/base_spec.rb[1:2:2:2] | passed | 0.00061 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:1:1:1] | passed | 0.00038 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:1:2:1] | passed | 0.00037 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:1:3:1] | passed | 0.00013 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:2:1:1] | passed | 0.00466 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:2:1:2] | passed | 0.00054 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:2:2:1] | passed | 0.00044 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:2:3:1] | passed | 0.00011 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:2:3:2] | passed | 0.00014 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:2:4:1] | passed | 0.00137 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:3:1:1] | passed | 0.00076 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:3:2:1] | passed | 0.00197 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:3:3:1] | passed | 0.00018 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:3:3:2] | passed | 0.00016 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:4:1:1] | passed | 0.00045 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:4:2:1] | passed | 0.00051 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:4:3:1] | passed | 0.00011 seconds |
-./spec/loops_sdk/contacts_spec.rb[1:4:3:2] | passed | 0.00012 seconds |
-./spec/loops_sdk/events_spec.rb[1:1:1] | passed | 0.00079 seconds |
-./spec/loops_sdk/events_spec.rb[1:1:2] | passed | 0.00053 seconds |
-./spec/loops_sdk/events_spec.rb[1:1:3] | passed | 0.00053 seconds |
-./spec/loops_sdk/events_spec.rb[1:1:4] | passed | 0.0025 seconds |
-./spec/loops_sdk/events_spec.rb[1:1:5] | passed | 0.00066 seconds |
-./spec/loops_sdk/events_spec.rb[1:1:6] | passed | 0.00038 seconds |
-./spec/loops_sdk/transactional_spec.rb[1:1:1] | passed | 0.00033 seconds |
-./spec/loops_sdk/transactional_spec.rb[1:1:2] | passed | 0.0003 seconds |
-./spec/loops_sdk/transactional_spec.rb[1:2:1] | passed | 0.00044 seconds |
-./spec/loops_sdk/transactional_spec.rb[1:2:2] | passed | 0.00032 seconds |
-./spec/loops_sdk/transactional_spec.rb[1:2:3] | passed | 0.00041 seconds |
-./spec/loops_sdk/transactional_spec.rb[1:2:4] | passed | 0.00034 seconds |
-./spec/loops_sdk/transactional_spec.rb[1:2:5] | passed | 0.00033 seconds |
+example_id | status | run_time |
+---------------------------------------------- | ------ | --------------- |
+./spec/loops_sdk/base_spec.rb[1:1:1] | passed | 0.00033 seconds |
+./spec/loops_sdk/base_spec.rb[1:1:2] | passed | 0.00033 seconds |
+./spec/loops_sdk/base_spec.rb[1:1:3] | passed | 0.00033 seconds |
+./spec/loops_sdk/base_spec.rb[1:1:4] | passed | 0.00037 seconds |
+./spec/loops_sdk/base_spec.rb[1:1:5] | passed | 0.00033 seconds |
+./spec/loops_sdk/base_spec.rb[1:1:6] | passed | 0.0004 seconds |
+./spec/loops_sdk/base_spec.rb[1:1:7] | passed | 0.00038 seconds |
+./spec/loops_sdk/base_spec.rb[1:2:1:1] | passed | 0.00012 seconds |
+./spec/loops_sdk/base_spec.rb[1:2:2:1] | passed | 0.00012 seconds |
+./spec/loops_sdk/base_spec.rb[1:2:2:2] | passed | 0.00012 seconds |
+./spec/loops_sdk/campaigns_spec.rb[1:1:1] | passed | 0.00167 seconds |
+./spec/loops_sdk/campaigns_spec.rb[1:2:1] | passed | 0.00334 seconds |
+./spec/loops_sdk/campaigns_spec.rb[1:3:1] | passed | 0.00296 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:1:1:1] | passed | 0.00062 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:1:2:1] | passed | 0.00372 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:1:3:1] | passed | 0.00014 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:2:1:1] | passed | 0.00041 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:2:1:2] | passed | 0.00035 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:2:2:1] | passed | 0.00044 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:2:3:1] | passed | 0.00012 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:2:3:2] | passed | 0.00018 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:2:4:1] | passed | 0.00048 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:3:1:1] | passed | 0.00036 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:3:2:1] | passed | 0.00038 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:3:3:1] | passed | 0.00064 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:3:3:2] | passed | 0.00056 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:4:1:1] | passed | 0.00032 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:4:2:1] | passed | 0.00044 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:4:3:1] | passed | 0.00012 seconds |
+./spec/loops_sdk/contacts_spec.rb[1:4:3:2] | passed | 0.00021 seconds |
+./spec/loops_sdk/email_messages_spec.rb[1:1:1] | passed | 0.00035 seconds |
+./spec/loops_sdk/email_messages_spec.rb[1:2:1] | passed | 0.00052 seconds |
+./spec/loops_sdk/email_messages_spec.rb[1:2:2] | passed | 0.00438 seconds |
+./spec/loops_sdk/events_spec.rb[1:1:1] | passed | 0.00042 seconds |
+./spec/loops_sdk/events_spec.rb[1:1:2] | passed | 0.00032 seconds |
+./spec/loops_sdk/events_spec.rb[1:1:3] | passed | 0.00033 seconds |
+./spec/loops_sdk/events_spec.rb[1:1:4] | passed | 0.00034 seconds |
+./spec/loops_sdk/events_spec.rb[1:1:5] | passed | 0.00034 seconds |
+./spec/loops_sdk/events_spec.rb[1:1:6] | passed | 0.00012 seconds |
+./spec/loops_sdk/transactional_spec.rb[1:1:1] | passed | 0.00037 seconds |
+./spec/loops_sdk/transactional_spec.rb[1:1:2] | passed | 0.00032 seconds |
+./spec/loops_sdk/transactional_spec.rb[1:2:1] | passed | 0.00045 seconds |
+./spec/loops_sdk/transactional_spec.rb[1:2:2] | passed | 0.00044 seconds |
+./spec/loops_sdk/transactional_spec.rb[1:2:3] | passed | 0.00032 seconds |
+./spec/loops_sdk/transactional_spec.rb[1:2:4] | passed | 0.00262 seconds |
+./spec/loops_sdk/transactional_spec.rb[1:2:5] | passed | 0.00032 seconds |
diff --git a/spec/loops_sdk/base_spec.rb b/spec/loops_sdk/base_spec.rb
index b1433ca..836f4b8 100644
--- a/spec/loops_sdk/base_spec.rb
+++ b/spec/loops_sdk/base_spec.rb
@@ -105,6 +105,26 @@
expect(result).to eq({ "success" => true })
end
+ it "parses a successful 201 response" do
+ expect(connection).to receive(:send).with(:post) do |&block|
+ req = double("req")
+ expect(req).to receive(:url).with(path)
+ expect(req).to receive(:headers=).with(default_headers)
+ expect(req).to receive(:params=).with({})
+ expect(req).to receive(:body=) do |body|
+ expect(JSON.parse(body)).to eq(JSON.parse({ baz: "qux" }.to_json))
+ end
+ block.call(req)
+ response
+ end
+
+ allow(response).to receive(:status).and_return(201)
+ allow(response).to receive(:body).and_return('{"success":true,"id":"created"}')
+
+ result = described_class.make_request(method: :post, path: path, body: body)
+ expect(result).to eq({ "success" => true, "id" => "created" })
+ end
+
it "raises an error when the API returns an error" do
expect(connection).to receive(:send).with(:get) do |&block|
req = double("req")
diff --git a/spec/loops_sdk/campaigns_spec.rb b/spec/loops_sdk/campaigns_spec.rb
new file mode 100644
index 0000000..ab4fbdc
--- /dev/null
+++ b/spec/loops_sdk/campaigns_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe LoopsSdk::Campaigns do
+ let(:connection) { instance_double(Faraday::Connection) }
+ let(:response) { instance_double(Faraday::Response) }
+ let(:default_headers) do
+ {
+ "Authorization" => "Bearer test-key",
+ "Content-Type" => "application/json"
+ }
+ end
+
+ before do
+ allow(LoopsSdk.configuration).to receive(:connection).and_return(connection)
+ allow(connection).to receive(:headers).and_return(default_headers)
+ end
+
+ describe ".list" do
+ it "makes a GET request to list campaigns" do
+ expect(connection).to receive(:send).with(:get) do |&block|
+ req = double("req")
+ expect(req).to receive(:url).with("v1/campaigns")
+ expect(req).to receive(:headers=).with(default_headers)
+ expect(req).to receive(:params=).with({ cursor: nil, perPage: 20 })
+ expect(req).to receive(:body=).with(nil)
+ block.call(req)
+ response
+ end
+
+ allow(response).to receive(:status).and_return(200)
+ allow(response).to receive(:body).and_return('{"success":true,"data":[]}')
+
+ result = described_class.list
+ expect(result).to eq({ "success" => true, "data" => [] })
+ end
+ end
+
+ describe ".create" do
+ it "makes a POST request to create a campaign" do
+ expect(connection).to receive(:send).with(:post) do |&block|
+ req = double("req")
+ expect(req).to receive(:url).with("v1/campaigns")
+ expect(req).to receive(:headers=).with(default_headers)
+ expect(req).to receive(:params=).with({})
+ expect(req).to receive(:body=).with({ name: "Spring announcement" }.to_json)
+ block.call(req)
+ response
+ end
+
+ allow(response).to receive(:status).and_return(201)
+ allow(response).to receive(:body).and_return('{"success":true,"campaignId":"camp_123"}')
+
+ result = described_class.create(name: "Spring announcement")
+ expect(result).to eq({ "success" => true, "campaignId" => "camp_123" })
+ end
+ end
+
+ describe ".update" do
+ it "makes a POST request to update a campaign" do
+ expect(connection).to receive(:send).with(:post) do |&block|
+ req = double("req")
+ expect(req).to receive(:url).with("v1/campaigns/camp_123")
+ expect(req).to receive(:headers=).with(default_headers)
+ expect(req).to receive(:params=).with({})
+ expect(req).to receive(:body=).with({ name: "Updated name" }.to_json)
+ block.call(req)
+ response
+ end
+
+ allow(response).to receive(:status).and_return(200)
+ allow(response).to receive(:body).and_return('{"success":true,"campaignId":"camp_123"}')
+
+ result = described_class.update(campaign_id: "camp_123", name: "Updated name")
+ expect(result).to eq({ "success" => true, "campaignId" => "camp_123" })
+ end
+ end
+end
diff --git a/spec/loops_sdk/email_messages_spec.rb b/spec/loops_sdk/email_messages_spec.rb
new file mode 100644
index 0000000..6b80375
--- /dev/null
+++ b/spec/loops_sdk/email_messages_spec.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe LoopsSdk::EmailMessages do
+ let(:connection) { instance_double(Faraday::Connection) }
+ let(:response) { instance_double(Faraday::Response) }
+ let(:default_headers) do
+ {
+ "Authorization" => "Bearer test-key",
+ "Content-Type" => "application/json"
+ }
+ end
+
+ before do
+ allow(LoopsSdk.configuration).to receive(:connection).and_return(connection)
+ allow(connection).to receive(:headers).and_return(default_headers)
+ end
+
+ describe ".get" do
+ it "makes a GET request to fetch an email message" do
+ expect(connection).to receive(:send).with(:get) do |&block|
+ req = double("req")
+ expect(req).to receive(:url).with("v1/email-messages/msg_123")
+ expect(req).to receive(:headers=).with(default_headers)
+ expect(req).to receive(:params=).with({})
+ expect(req).to receive(:body=).with(nil)
+ block.call(req)
+ response
+ end
+
+ allow(response).to receive(:status).and_return(200)
+ allow(response).to receive(:body).and_return('{"success":true,"emailMessageId":"msg_123"}')
+
+ result = described_class.get(email_message_id: "msg_123")
+ expect(result).to eq({ "success" => true, "emailMessageId" => "msg_123" })
+ end
+ end
+
+ describe ".update" do
+ it "makes a POST request with camelCase body fields" do
+ expected_body = {
+ expectedRevisionId: "rev_1",
+ subject: "Hello",
+ previewText: "Preview",
+ fromName: "Loops",
+ fromEmail: "hello",
+ replyToEmail: "support@loops.so",
+ lmx: ""
+ }
+
+ expect(connection).to receive(:send).with(:post) do |&block|
+ req = double("req")
+ expect(req).to receive(:url).with("v1/email-messages/msg_123")
+ expect(req).to receive(:headers=).with(default_headers)
+ expect(req).to receive(:params=).with({})
+ expect(req).to receive(:body=) do |body|
+ expect(JSON.parse(body)).to eq(JSON.parse(expected_body.to_json))
+ end
+ block.call(req)
+ response
+ end
+
+ allow(response).to receive(:status).and_return(200)
+ allow(response).to receive(:body).and_return('{"success":true}')
+
+ result = described_class.update(
+ email_message_id: "msg_123",
+ expected_revision_id: "rev_1",
+ subject: "Hello",
+ preview_text: "Preview",
+ from_name: "Loops",
+ from_email: "hello",
+ reply_to_email: "support@loops.so",
+ lmx: ""
+ )
+ expect(result).to eq({ "success" => true })
+ end
+
+ it "omits nil fields from the request body" do
+ expect(connection).to receive(:send).with(:post) do |&block|
+ req = double("req")
+ expect(req).to receive(:url).with("v1/email-messages/msg_123")
+ expect(req).to receive(:headers=).with(default_headers)
+ expect(req).to receive(:params=).with({})
+ expect(req).to receive(:body=).with({ subject: "Hello" }.to_json)
+ block.call(req)
+ response
+ end
+
+ allow(response).to receive(:status).and_return(200)
+ allow(response).to receive(:body).and_return('{"success":true}')
+
+ result = described_class.update(email_message_id: "msg_123", subject: "Hello")
+ expect(result).to eq({ "success" => true })
+ end
+ end
+end