listmonk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 220f8532f40722ac0b228e586c8a3f8ecc765153783fb1aaa7195663f2f29e48
4
+ data.tar.gz: 145a0d505fce70f1527992b7aaab70c9b409752b740d710679f325d0f8454f50
5
+ SHA512:
6
+ metadata.gz: 67792a4fc6008979036ecef64510cf56def26c26d824cc1d98fa33de6914283881410288fc0cc8ef41da5d9e2ffa5486c6f7bedab0e16c1b2693afaae5e10348
7
+ data.tar.gz: 67c86af4e3c7322cc336183af6dcefd38ce94c9c5452117c628b0885c716ee5c3ad722836d5782b36693fbdb873da148d388d10167823b806a69a04b16c24db3
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Add a resource-oriented client covering all operations in listmonk's published Swagger collection.
6
+ - Support Basic and listmonk token-header authentication.
7
+ - Add JSON response envelopes, typed HTTP errors, multipart uploads, and repeated query parameters.
8
+ - Add an opt-in Docker integration suite against Listmonk 6.1.0 and PostgreSQL 17.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 TODO: Write your name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # Listmonk
2
+
3
+ A resource-oriented Ruby client for the [listmonk HTTP API](https://listmonk.app/docs/apis/apis/).
4
+
5
+ ## Installation
6
+
7
+ Add the gem to your bundle:
8
+
9
+ ```bash
10
+ bundle add listmonk
11
+ ```
12
+
13
+ The gem requires Ruby 3.3 or newer.
14
+
15
+ ## Configuration
16
+
17
+ Listmonk supports Basic authentication and its `Authorization: token` header. Basic authentication is the default:
18
+
19
+ ```ruby
20
+ client = Listmonk::Client.new(
21
+ base_url: "https://newsletter.example.com",
22
+ username: ENV.fetch("LISTMONK_USERNAME"),
23
+ token: ENV.fetch("LISTMONK_TOKEN")
24
+ )
25
+ ```
26
+
27
+ Use token-header authentication when needed:
28
+
29
+ ```ruby
30
+ client = Listmonk.client(
31
+ base_url: "https://newsletter.example.com/api",
32
+ username: ENV.fetch("LISTMONK_USERNAME"),
33
+ token: ENV.fetch("LISTMONK_TOKEN"),
34
+ auth: :token,
35
+ timeout: 30,
36
+ open_timeout: 10
37
+ )
38
+ ```
39
+
40
+ The client accepts a server root or an URL ending in `/api`. Credentials may be omitted for public endpoints.
41
+
42
+ ## Usage
43
+
44
+ Every method returns a `Listmonk::Response`. Use `data` for the API payload, or inspect `status`, `headers`, and `body` when needed.
45
+
46
+ ```ruby
47
+ response = client.subscribers.list(page: 1, per_page: 50, list_id: [1, 2])
48
+ subscribers = response.data.fetch("results")
49
+
50
+ subscriber = client.subscribers.create(
51
+ email: "reader@example.com",
52
+ name: "A Reader",
53
+ lists: [1],
54
+ attribs: {source: "website"}
55
+ ).data
56
+
57
+ client.subscribers.update(subscriber.fetch("id"), name: "Reader")
58
+ client.subscribers.send_optin(subscriber.fetch("id"))
59
+ ```
60
+
61
+ Campaigns and transactional messages:
62
+
63
+ ```ruby
64
+ campaign = client.campaigns.create(
65
+ name: "July news",
66
+ subject: "What's new",
67
+ lists: [1],
68
+ from_email: "News <news@example.com>",
69
+ content_type: "html",
70
+ messenger: "email",
71
+ type: "regular"
72
+ ).data
73
+
74
+ client.campaigns.create_content(campaign.fetch("id"), body: "<h1>Hello</h1>")
75
+ client.campaigns.start(campaign.fetch("id"))
76
+
77
+ client.transactional.deliver(
78
+ template_id: 3,
79
+ subscriber_email: "reader@example.com",
80
+ data: {confirmation_url: "https://example.com/confirm"}
81
+ )
82
+ ```
83
+
84
+ Subscriber imports and media uploads use multipart requests:
85
+
86
+ ```ruby
87
+ client.imports.create(
88
+ file: "/tmp/subscribers.zip",
89
+ params: {
90
+ mode: "subscribe",
91
+ subscription_status: "confirmed",
92
+ lists: [1],
93
+ overwrite: true
94
+ }
95
+ )
96
+
97
+ client.media.upload(file: "/tmp/banner.png", content_type: "image/png")
98
+ ```
99
+
100
+ Resources cover all operations in the published Swagger collection:
101
+
102
+ - `miscellaneous`, `settings`, `admin`, and `logs`
103
+ - `subscribers`, `lists`, `imports`, and `bounces`
104
+ - `campaigns`, `templates`, `media`, and `transactional`
105
+ - `maintenance` and `public`
106
+
107
+ For an undocumented or newly added endpoint, use the low-level request method:
108
+
109
+ ```ruby
110
+ client.request(:get, "new-endpoint", params: {page: 1})
111
+ client.request(:post, "new-endpoint", json: {enabled: true})
112
+ ```
113
+
114
+ ## Errors
115
+
116
+ Non-successful responses raise typed exceptions. Every HTTP exception exposes `status`, `body`, and `response`.
117
+
118
+ ```ruby
119
+ begin
120
+ client.lists.retrieve(999)
121
+ rescue Listmonk::NotFoundError => error
122
+ warn "List not found: #{error.body.inspect}"
123
+ rescue Listmonk::RateLimitError
124
+ # Retry with application-specific backoff.
125
+ end
126
+ ```
127
+
128
+ Connection failures raise `Listmonk::ConnectionError`, timeouts raise `Listmonk::TimeoutError`, and malformed JSON raises `Listmonk::ParseError`.
129
+
130
+ ## Development
131
+
132
+ Install dependencies and run the complete verification task:
133
+
134
+ ```bash
135
+ bin/setup
136
+ bundle exec rake
137
+ ```
138
+
139
+ RSpec uses WebMock for request-level tests and SimpleCov for coverage.
140
+
141
+ ### Docker integration tests
142
+
143
+ Run the opt-in integration suite against an ephemeral Listmonk 6.1.0 and PostgreSQL 17 environment:
144
+
145
+ ```bash
146
+ bundle exec rake integration
147
+ ```
148
+
149
+ The task starts Docker Compose on `127.0.0.1:19000`, creates a temporary API user, runs live list, subscriber, template, authentication, and health checks, then removes the containers and database volume. Override the defaults with `LISTMONK_PORT` or `LISTMONK_IMAGE`.
150
+
151
+ Normal `bundle exec rake` runs only the WebMock suite and does not require Docker.
152
+
153
+ ## Releasing
154
+
155
+ Publishing uses [RubyGems trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no long-lived RubyGems API key is stored in GitHub.
156
+
157
+ Before the first release, create a pending trusted publisher in your RubyGems.org profile with:
158
+
159
+ - Gem name: `listmonk`
160
+ - Repository owner: `polydice`
161
+ - Repository name: `listmonk-ruby`
162
+ - Workflow filename: `release.yml`
163
+ - Environment: `release`
164
+
165
+ To publish a version:
166
+
167
+ 1. Update `lib/listmonk/version.rb` and `CHANGELOG.md`.
168
+ 2. Run `bundle exec rake`, `bundle exec rake integration`, and `bundle exec rbs validate`.
169
+ 3. Run `bundle exec rake build` and inspect the generated package under `pkg/`.
170
+ 4. Commit the release and create a matching version tag, for example `git tag v0.1.0`.
171
+ 5. Push `main` and the tag. The `Publish gem` GitHub workflow validates that the tag matches `Listmonk::VERSION` and publishes the gem.
172
+
173
+ ## License
174
+
175
+ The gem is available under the terms of the [MIT License](LICENSE.txt).
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "faraday"
6
+ require "faraday/multipart"
7
+ require_relative "resource"
8
+
9
+ module Listmonk
10
+ class Client
11
+ RESOURCE_NAMES = %i[
12
+ miscellaneous settings admin logs subscribers bounces lists imports campaigns
13
+ media templates transactional maintenance public
14
+ ].freeze
15
+
16
+ attr_reader :base_url
17
+
18
+ def initialize(base_url:, username: nil, token: nil, auth: :basic, timeout: 30, open_timeout: 10,
19
+ connection: nil)
20
+ @base_url = normalize_base_url(base_url)
21
+ @connection = connection || build_connection(username, token, auth, timeout, open_timeout)
22
+ end
23
+
24
+ RESOURCE_NAMES.each do |name|
25
+ define_method(name) do
26
+ instance_variable_get("@#{name}") || instance_variable_set(
27
+ "@#{name}",
28
+ resource_class(name).new(self)
29
+ )
30
+ end
31
+ end
32
+
33
+ def request(method, path, params: nil, json: nil, body: nil, headers: {})
34
+ raw = @connection.run_request(method, clean_path(path), nil, headers) do |request|
35
+ request.params.update(compact(params)) if params
36
+ if json.nil?
37
+ request.body = body
38
+ else
39
+ request.headers["Content-Type"] = "application/json"
40
+ request.body = JSON.generate(json)
41
+ end
42
+ end
43
+
44
+ response = Response.new(
45
+ status: raw.status,
46
+ headers: raw.headers.to_h,
47
+ body: parse_body(raw.body, raw.headers["content-type"])
48
+ )
49
+ raise_http_error(response) unless response.success?
50
+
51
+ response
52
+ rescue Faraday::TimeoutError => e
53
+ raise TimeoutError, e.message
54
+ rescue Faraday::ConnectionFailed => e
55
+ raise ConnectionError, e.message
56
+ end
57
+
58
+ def file_part(file, content_type: "application/octet-stream", filename: nil)
59
+ return file if file.is_a?(Faraday::Multipart::FilePart)
60
+
61
+ filename ||= File.basename(file.respond_to?(:path) ? file.path : file.to_s)
62
+ Faraday::Multipart::FilePart.new(file, content_type, filename)
63
+ end
64
+
65
+ private
66
+
67
+ def build_connection(username, token, auth, timeout, open_timeout)
68
+ validate_credentials!(username, token)
69
+ Faraday.new(
70
+ url: base_url,
71
+ request: {
72
+ timeout: timeout,
73
+ open_timeout: open_timeout,
74
+ params_encoder: Faraday::FlatParamsEncoder
75
+ }
76
+ ) do |faraday|
77
+ faraday.request :multipart
78
+ faraday.request :url_encoded
79
+ faraday.headers["Accept"] = "application/json"
80
+ faraday.headers["User-Agent"] = "listmonk-ruby/#{VERSION}"
81
+ apply_auth(faraday, username, token, auth) if username && token
82
+ faraday.adapter Faraday.default_adapter
83
+ end
84
+ end
85
+
86
+ def apply_auth(faraday, username, token, auth)
87
+ case auth.to_sym
88
+ when :basic
89
+ credentials = ["#{username}:#{token}"].pack("m0")
90
+ faraday.headers["Authorization"] = "Basic #{credentials}"
91
+ when :token
92
+ faraday.headers["Authorization"] = "token #{username}:#{token}"
93
+ else
94
+ raise ConfigurationError, "auth must be :basic or :token"
95
+ end
96
+ end
97
+
98
+ def validate_credentials!(username, token)
99
+ return if username.nil? == token.nil?
100
+
101
+ raise ConfigurationError, "username and token must be provided together"
102
+ end
103
+
104
+ def normalize_base_url(value)
105
+ uri = URI.parse(value.to_s)
106
+ valid = %w[http https].include?(uri.scheme) && uri.host
107
+ raise ConfigurationError, "base_url must be an absolute HTTP(S) URL" unless valid
108
+
109
+ uri.path = uri.path.sub(%r{/+\z}, "")
110
+ uri.path = "#{uri.path}/api" unless uri.path.end_with?("/api")
111
+ uri.to_s
112
+ rescue URI::InvalidURIError
113
+ raise ConfigurationError, "base_url must be an absolute HTTP(S) URL"
114
+ end
115
+
116
+ def clean_path(path)
117
+ path.to_s.sub(%r{\A/+}, "")
118
+ end
119
+
120
+ def compact(hash)
121
+ hash.compact
122
+ end
123
+
124
+ def parse_body(body, content_type)
125
+ return nil if body.nil? || body.empty?
126
+ return body unless content_type.to_s.include?("json") || body.lstrip.start_with?("{", "[")
127
+
128
+ JSON.parse(body)
129
+ rescue JSON::ParserError => e
130
+ raise ParseError, "Listmonk returned invalid JSON: #{e.message}"
131
+ end
132
+
133
+ def raise_http_error(response)
134
+ klass = HTTP_ERRORS.fetch(response.status, response.status >= 500 ? ServerError : Error)
135
+ message = (response.body["message"] || response.body["error"] if response.body.is_a?(Hash))
136
+ message ||= "Listmonk request failed with HTTP #{response.status}"
137
+ raise klass.new(message, response: response)
138
+ end
139
+
140
+ def resource_class(name)
141
+ require_relative "resources/#{name}"
142
+ Listmonk::Resources.const_get(name.to_s.capitalize)
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ class Error < StandardError
5
+ attr_reader :response
6
+
7
+ def initialize(message = nil, response: nil)
8
+ @response = response
9
+ super(message)
10
+ end
11
+
12
+ def status
13
+ response&.status
14
+ end
15
+
16
+ def body
17
+ response&.body
18
+ end
19
+ end
20
+
21
+ class ConfigurationError < Error; end
22
+ class ConnectionError < Error; end
23
+ class TimeoutError < ConnectionError; end
24
+ class ParseError < Error; end
25
+ class BadRequestError < Error; end
26
+ class AuthenticationError < Error; end
27
+ class ForbiddenError < Error; end
28
+ class NotFoundError < Error; end
29
+ class MethodNotAllowedError < Error; end
30
+ class GoneError < Error; end
31
+ class ValidationError < Error; end
32
+ class RateLimitError < Error; end
33
+ class ServerError < Error; end
34
+
35
+ HTTP_ERRORS = {
36
+ 400 => BadRequestError,
37
+ 401 => AuthenticationError,
38
+ 403 => ForbiddenError,
39
+ 404 => NotFoundError,
40
+ 405 => MethodNotAllowedError,
41
+ 410 => GoneError,
42
+ 422 => ValidationError,
43
+ 429 => RateLimitError
44
+ }.freeze
45
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ class Resource
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ private
10
+
11
+ attr_reader :client
12
+
13
+ def segment(value)
14
+ URI.encode_www_form_component(value.to_s)
15
+ end
16
+
17
+ def get(path, params = nil)
18
+ client.request(:get, path, params: params)
19
+ end
20
+
21
+ def post(path, body = nil)
22
+ client.request(:post, path, json: body)
23
+ end
24
+
25
+ def put(path, body = nil)
26
+ client.request(:put, path, json: body)
27
+ end
28
+
29
+ def delete_request(path, params = nil)
30
+ client.request(:delete, path, params: params)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Admin < Resource
6
+ def reload = post("admin/reload")
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Bounces < Resource
6
+ def list(**params) = get("bounces", params)
7
+ def retrieve(id) = get("bounces/#{segment(id)}")
8
+ def delete(id) = delete_request("bounces/#{segment(id)}")
9
+
10
+ def delete_many(ids: nil, all: nil)
11
+ delete_request("bounces", id: ids && Array(ids).join(","), all: all)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Campaigns < Resource
6
+ def list(**params) = get("campaigns", params)
7
+ def create(**attributes) = post("campaigns", attributes)
8
+ def retrieve(id) = get("campaigns/#{segment(id)}")
9
+ def update(id, **attributes) = put("campaigns/#{segment(id)}", attributes)
10
+ def delete(id) = delete_request("campaigns/#{segment(id)}")
11
+ def running_stats = get("campaigns/running/stats")
12
+
13
+ def analytics(type, **params)
14
+ get("campaigns/analytics/#{segment(type)}", params)
15
+ end
16
+
17
+ def preview(id) = get("campaigns/#{segment(id)}/preview")
18
+ def update_preview(id, **attributes) = post("campaigns/#{segment(id)}/preview", attributes)
19
+ def preview_text(id, **attributes) = post("campaigns/#{segment(id)}/text", attributes)
20
+ def update_status(id, status:) = put("campaigns/#{segment(id)}/status", status: status)
21
+ def start(id) = update_status(id, status: "running")
22
+ def pause(id) = update_status(id, status: "paused")
23
+ def cancel(id) = update_status(id, status: "cancelled")
24
+
25
+ def update_archive(id, archive:, **attributes)
26
+ put("campaigns/#{segment(id)}/archive", attributes.merge(archive: archive))
27
+ end
28
+
29
+ def create_content(id, **attributes) = post("campaigns/#{segment(id)}/content", attributes)
30
+ def test(id, **attributes) = post("campaigns/#{segment(id)}/test", attributes)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Listmonk
6
+ module Resources
7
+ class Imports < Resource
8
+ def status = get("import/subscribers")
9
+
10
+ def create(file:, params:, content_type: "application/zip")
11
+ body = {
12
+ file: client.file_part(file, content_type: content_type),
13
+ params: JSON.generate(params)
14
+ }
15
+ client.request(:post, "import/subscribers", body: body)
16
+ end
17
+
18
+ def stop = delete_request("import/subscribers")
19
+ def logs = get("import/subscribers/logs")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Lists < Resource
6
+ def list(**params) = get("lists", params)
7
+ def create(**attributes) = post("lists", attributes)
8
+ def retrieve(id) = get("lists/#{segment(id)}")
9
+ def update(id, **attributes) = put("lists/#{segment(id)}", attributes)
10
+ def delete(id) = delete_request("lists/#{segment(id)}")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Logs < Resource
6
+ def list = get("logs")
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Maintenance < Resource
6
+ def delete_subscribers(type) = delete_request("maintenance/subscribers/#{segment(type)}")
7
+ def delete_analytics(type) = delete_request("maintenance/analytics/#{segment(type)}")
8
+ def delete_unconfirmed_subscriptions = delete_request("maintenance/subscriptions/unconfirmed")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Media < Resource
6
+ def list = get("media")
7
+
8
+ def upload(file:, content_type: "application/octet-stream")
9
+ body = { file: client.file_part(file, content_type: content_type) }
10
+ client.request(:post, "media", body: body)
11
+ end
12
+
13
+ def retrieve(id) = get("media/#{segment(id)}")
14
+ def delete(id) = delete_request("media/#{segment(id)}")
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Miscellaneous < Resource
6
+ def health = get("health")
7
+ def config = get("config")
8
+ def language(lang) = get("lang/#{segment(lang)}")
9
+ def dashboard_charts = get("dashboard/charts")
10
+ def dashboard_counts = get("dashboard/counts")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Public < Resource
6
+ def lists = get("public/lists")
7
+
8
+ def subscribe(name:, email:,
9
+ list_uuids:)
10
+ post("public/subscription", name: name, email: email, list_uuids: list_uuids)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Settings < Resource
6
+ def retrieve = get("settings")
7
+ def update(**attributes) = put("settings", attributes)
8
+ def test_smtp(**attributes) = post("settings/smtp/test", attributes)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Subscribers < Resource
6
+ def list(**params) = get("subscribers", params)
7
+ def create(**attributes) = post("subscribers", attributes)
8
+ def retrieve(id) = get("subscribers/#{segment(id)}")
9
+ def update(id, **attributes) = put("subscribers/#{segment(id)}", attributes)
10
+ def delete(id) = delete_request("subscribers/#{segment(id)}")
11
+
12
+ def delete_many(ids)
13
+ delete_request("subscribers", id: Array(ids).join(","))
14
+ end
15
+
16
+ def manage_lists(**attributes) = put("subscribers/lists", attributes)
17
+ def manage_list(list_id, **attributes) = put("subscribers/lists/#{segment(list_id)}", attributes)
18
+ def manage_blocklist(**attributes) = put("subscribers/blocklist", attributes)
19
+
20
+ def manage_blocklist_for(id, **attributes)
21
+ put("subscribers/#{segment(id)}/blocklist", attributes)
22
+ end
23
+
24
+ def export(id) = get("subscribers/#{segment(id)}/export")
25
+ def bounces(id) = get("subscribers/#{segment(id)}/bounces")
26
+ def delete_bounces(id) = delete_request("subscribers/#{segment(id)}/bounces")
27
+ def send_optin(id) = post("subscribers/#{segment(id)}/optin")
28
+ def delete_by_query(**attributes) = post("subscribers/query/delete", attributes)
29
+ def blocklist_by_query(**attributes) = put("subscribers/query/blocklist", attributes)
30
+ def manage_lists_by_query(**attributes) = put("subscribers/query/lists", attributes)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Templates < Resource
6
+ def list(**params) = get("templates", params)
7
+ def create(**attributes) = post("templates", attributes)
8
+ def retrieve(id) = get("templates/#{segment(id)}")
9
+ def update(id, **attributes) = put("templates/#{segment(id)}", attributes)
10
+ def delete(id) = delete_request("templates/#{segment(id)}")
11
+ def preview(**attributes) = post("templates/preview", attributes)
12
+ def preview_by_id(id) = get("templates/#{segment(id)}/preview")
13
+ def set_default(id) = put("templates/#{segment(id)}/default")
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ module Resources
5
+ class Transactional < Resource
6
+ def deliver(**attributes) = post("tx", attributes)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ class Response
5
+ attr_reader :status, :headers, :body
6
+
7
+ def initialize(status:, headers:, body:)
8
+ @status = status
9
+ @headers = headers.freeze
10
+ @body = body
11
+ end
12
+
13
+ def success?
14
+ status.between?(200, 299)
15
+ end
16
+
17
+ def data
18
+ return body unless body.is_a?(Hash)
19
+
20
+ body.key?("data") ? body["data"] : body
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Listmonk
4
+ VERSION = "0.1.0"
5
+ end
data/lib/listmonk.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "listmonk/version"
4
+ require_relative "listmonk/errors"
5
+ require_relative "listmonk/response"
6
+ require_relative "listmonk/client"
7
+
8
+ module Listmonk
9
+ class << self
10
+ def client(**)
11
+ Client.new(**)
12
+ end
13
+ end
14
+ end
data/sig/listmonk.rbs ADDED
@@ -0,0 +1,64 @@
1
+ module Listmonk
2
+ VERSION: String
3
+
4
+ def self.client: (**untyped) -> Client
5
+
6
+ class Response
7
+ attr_reader status: Integer
8
+ attr_reader headers: Hash[String, String]
9
+ attr_reader body: untyped
10
+
11
+ def success?: () -> bool
12
+ def data: () -> untyped
13
+ end
14
+
15
+ class Error < StandardError
16
+ attr_reader response: Response?
17
+ def status: () -> Integer?
18
+ def body: () -> untyped
19
+ end
20
+
21
+ class ConfigurationError < Error
22
+ end
23
+
24
+ class ConnectionError < Error
25
+ end
26
+
27
+ class TimeoutError < ConnectionError
28
+ end
29
+
30
+ class ParseError < Error
31
+ end
32
+
33
+ class Client
34
+ attr_reader base_url: String
35
+
36
+ def initialize: (
37
+ base_url: String,
38
+ ?username: String?,
39
+ ?token: String?,
40
+ ?auth: Symbol,
41
+ ?timeout: Numeric,
42
+ ?open_timeout: Numeric,
43
+ ?connection: untyped
44
+ ) -> void
45
+
46
+ def request: (Symbol method, String path, ?params: Hash[Symbol, untyped]?, ?json: untyped, ?body: untyped, ?headers: Hash[String, String]) -> Response
47
+ def file_part: (untyped file, ?content_type: String, ?filename: String?) -> untyped
48
+
49
+ def miscellaneous: () -> untyped
50
+ def settings: () -> untyped
51
+ def admin: () -> untyped
52
+ def logs: () -> untyped
53
+ def subscribers: () -> untyped
54
+ def bounces: () -> untyped
55
+ def lists: () -> untyped
56
+ def imports: () -> untyped
57
+ def campaigns: () -> untyped
58
+ def media: () -> untyped
59
+ def templates: () -> untyped
60
+ def transactional: () -> untyped
61
+ def maintenance: () -> untyped
62
+ def public: () -> untyped
63
+ end
64
+ end
metadata ADDED
@@ -0,0 +1,201 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: listmonk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Richard Lee
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: faraday-multipart
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '1.0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rake
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '13.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '13.0'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rbs
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '4.0'
67
+ type: :development
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '4.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: rspec
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '3.13'
81
+ type: :development
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '3.13'
88
+ - !ruby/object:Gem::Dependency
89
+ name: rubocop
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '1.0'
95
+ type: :development
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '1.0'
102
+ - !ruby/object:Gem::Dependency
103
+ name: rubocop-rspec
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '3.0'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '3.0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: simplecov
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '0.22'
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '0.22'
130
+ - !ruby/object:Gem::Dependency
131
+ name: webmock
132
+ requirement: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '3.0'
137
+ type: :development
138
+ prerelease: false
139
+ version_requirements: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - "~>"
142
+ - !ruby/object:Gem::Version
143
+ version: '3.0'
144
+ description: A resource-oriented Ruby client for listmonk's HTTP API.
145
+ email:
146
+ - 14349+dlackty@users.noreply.github.com
147
+ executables: []
148
+ extensions: []
149
+ extra_rdoc_files: []
150
+ files:
151
+ - CHANGELOG.md
152
+ - LICENSE.txt
153
+ - README.md
154
+ - lib/listmonk.rb
155
+ - lib/listmonk/client.rb
156
+ - lib/listmonk/errors.rb
157
+ - lib/listmonk/resource.rb
158
+ - lib/listmonk/resources/admin.rb
159
+ - lib/listmonk/resources/bounces.rb
160
+ - lib/listmonk/resources/campaigns.rb
161
+ - lib/listmonk/resources/imports.rb
162
+ - lib/listmonk/resources/lists.rb
163
+ - lib/listmonk/resources/logs.rb
164
+ - lib/listmonk/resources/maintenance.rb
165
+ - lib/listmonk/resources/media.rb
166
+ - lib/listmonk/resources/miscellaneous.rb
167
+ - lib/listmonk/resources/public.rb
168
+ - lib/listmonk/resources/settings.rb
169
+ - lib/listmonk/resources/subscribers.rb
170
+ - lib/listmonk/resources/templates.rb
171
+ - lib/listmonk/resources/transactional.rb
172
+ - lib/listmonk/response.rb
173
+ - lib/listmonk/version.rb
174
+ - sig/listmonk.rbs
175
+ homepage: https://github.com/polydice/listmonk-ruby
176
+ licenses:
177
+ - MIT
178
+ metadata:
179
+ allowed_push_host: https://rubygems.org
180
+ homepage_uri: https://github.com/polydice/listmonk-ruby
181
+ source_code_uri: https://github.com/polydice/listmonk-ruby
182
+ changelog_uri: https://github.com/polydice/listmonk-ruby/blob/main/CHANGELOG.md
183
+ rubygems_mfa_required: 'true'
184
+ rdoc_options: []
185
+ require_paths:
186
+ - lib
187
+ required_ruby_version: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - ">="
190
+ - !ruby/object:Gem::Version
191
+ version: 3.3.0
192
+ required_rubygems_version: !ruby/object:Gem::Requirement
193
+ requirements:
194
+ - - ">="
195
+ - !ruby/object:Gem::Version
196
+ version: '0'
197
+ requirements: []
198
+ rubygems_version: 4.0.16
199
+ specification_version: 4
200
+ summary: A Ruby client for the listmonk API
201
+ test_files: []