email_list_api 0.1.0.rc.1 → 0.1.0.rc.2

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: e9f6eff66fff016c13db422fe77d7b6173a6389a4ebb48e56c6a976180fe78e9
4
- data.tar.gz: 5eb26fa011aeb57b16a6d22dbfb422f69310c16d75a0750337c22c25771394bd
3
+ metadata.gz: e1ed6ace184bb9f8af7eaffe002158c0b2dcd67bd5d11fcc4fad7a93bd33bca0
4
+ data.tar.gz: b14740c3dc18fca184637df8402f92957201e637c817ca193cde6298dcd1df8b
5
5
  SHA512:
6
- metadata.gz: 5d8fa655ccabbdb642b73954f6a426a26ff8f6c92c8cb9a1b4bbf4f8e391977327779aaa5b78956e6fe1dcf8b978d0094b6650b478b4df3e17599b3df13c3790
7
- data.tar.gz: 32760843f905f7c7918fd4b358394c10e57420b6eedc4f8b98b2ef24cac062d32f2c4bb6a307f30e180d6200acfaac527628ff423c768af94d8b896e33eb466d
6
+ metadata.gz: 8e3aadd9e8de672857ee08a3d27bd657dde1ae6e9af9d75fe07a6abd0e6b8a128d1503804f824e6880aaafe6def2459dbfb05eb0207bc26e3b4e630014409c33
7
+ data.tar.gz: 03b298cb089969cb48e429b069cc07accb36cac2eac32d5d1c019790376ffd01a64ec87b497bb62400197689fecb586364e15254dfeb04b8d526329816f5ac10
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Email List Dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -18,21 +18,131 @@ Or install it yourself as:
18
18
 
19
19
  $ gem install email_list_api
20
20
 
21
+ ## Configuration
22
+
23
+ You can configure the gem globally using an initializer (recommended for Rails apps):
24
+
25
+ ```ruby
26
+ # config/initializers/email_list_api.rb
27
+ EmailListApi.configure do |config|
28
+ config.api_key = ENV['EMAILLIST_API_KEY'] || Rails.application.credentials.dig(:emaillist, :api_key)
29
+ # api_version is optional (defaults to latest based on gem version)
30
+ # config.api_version = 1
31
+ end
32
+ ```
33
+
34
+ This sets a global default that will be used by all clients unless overridden. The configuration priority is:
35
+ 1. Explicit parameter (highest priority)
36
+ 2. Global configuration (`EmailListApi.configure`)
37
+ 3. Environment variable (`EMAILLIST_API_KEY`)
38
+
21
39
  ## Usage
22
40
 
23
41
  ```ruby
24
42
  require 'email_list_api'
25
43
 
44
+ # If configured globally, you can create a client without specifying the API key
45
+ client = EmailListApi::Client.new
46
+
47
+ # Or explicitly pass the API key (overrides global config)
26
48
  client = EmailListApi::Client.new(api_key: 'your_api_key')
27
49
 
50
+ # Optionally specify API version (defaults to latest)
51
+ client = EmailListApi::Client.new(api_key: 'your_api_key', api_version: 1)
52
+
28
53
  # Projects
29
54
  projects = client.projects
30
55
  project = client.create_project(name: 'My Project')
31
56
 
32
- # Lists
33
- lists = client.lists(project_id: project['id'])
34
- list = client.create_list(project_id: project['id'], name: 'Newsletter')
57
+ # Lists (project can be slug or ID)
58
+ lists = client.lists(project: project['data']['slug'])
59
+ list = client.create_list(project: project['data']['slug'], name: 'Newsletter')
35
60
 
36
61
  # Contacts
37
- client.create_contact(project_id: project['id'], email: 'user@example.com')
62
+ client.create_contact(project: project['data']['slug'], email: 'user@example.com')
63
+ client.update_contact(project: project['data']['slug'], id: contact_id, first_name: 'John')
64
+ # Upsert: creates if new, updates if exists (by email)
65
+ client.upsert_contact(project: project['data']['slug'], email: 'user@example.com', first_name: 'Jane')
66
+
67
+ # Unsubscribe URLs
68
+ # Contact responses include unsubscribe_urls hash (list_id => unsubscribe_url)
69
+ contact = client.contact(project: project['data']['slug'], id: contact_id)
70
+ contact['data']['unsubscribe_urls'] # => { 1 => "https://emaillist.dev/unsubscribe/...", 2 => "..." }
71
+
72
+ # List contact responses include unsubscribe_url for that specific list
73
+ list_contacts = client.list_contacts(project: project['data']['slug'], list_id: list_id)
74
+ list_contacts['data'].each do |contact|
75
+ unsubscribe_url = contact['unsubscribe_url'] # => "https://emaillist.dev/unsubscribe/..."
76
+ # Include this URL in your email templates
77
+ end
78
+ ```
79
+
80
+ ## Automatic Contact Sync (Rails Models)
81
+
82
+ The gem includes a concern that automatically syncs Rails models to EmailList contacts. This is useful for syncing User models or other models that represent contacts.
83
+
84
+ ### Basic Usage
85
+
86
+ Include the concern in your model and configure it:
87
+
88
+ ```ruby
89
+ class User < ApplicationRecord
90
+ include EmailListApi::SyncsEmailListContact
91
+
92
+ syncs_email_list_contact project_slug: 'newsletter'
93
+ end
94
+ ```
95
+
96
+ Now, whenever a User is created or updated, it will automatically create or update the corresponding contact in EmailList.
97
+
98
+ ### Custom Field Mapping
99
+
100
+ If your model uses different field names, you can configure them:
101
+
102
+ ```ruby
103
+ class User < ApplicationRecord
104
+ include EmailListApi::SyncsEmailListContact
105
+
106
+ syncs_email_list_contact project_slug: 'newsletter',
107
+ email_field: :email_address,
108
+ first_name_field: :given_name,
109
+ last_name_field: :family_name
110
+ end
38
111
  ```
112
+
113
+ ### Custom API Version
114
+
115
+ You can specify a custom API version per model (overrides global config):
116
+
117
+ ```ruby
118
+ class User < ApplicationRecord
119
+ include EmailListApi::SyncsEmailListContact
120
+
121
+ syncs_email_list_contact project_slug: 'newsletter',
122
+ api_version: 1
123
+ end
124
+ ```
125
+
126
+ The API key is always taken from the global configuration (`EmailListApi.configure`) or the `EMAILLIST_API_KEY` environment variable. The API version defaults to 1 if not specified, and the base URL is automatically determined (production: `https://emaillist.dev/api/v1`, development: `http://localhost:3000/api/v1`)
127
+
128
+ ### Optional: Store Contact ID
129
+
130
+ You can optionally add a column to store the EmailList contact ID for faster lookups:
131
+
132
+ ```ruby
133
+ # Migration
134
+ add_column :users, :emaillist_contact_id, :integer
135
+ add_index :users, :emaillist_contact_id
136
+ ```
137
+
138
+ The concern will automatically populate this field when contacts are created or updated.
139
+
140
+ ## Environments
141
+
142
+ Each API key is associated with an environment. When you make API requests, all operations are automatically scoped to the environment associated with your API key. This means:
143
+
144
+ - Projects, lists, and contacts are automatically filtered to your environment
145
+ - You don't need to specify an environment in API calls - it's determined by your API key
146
+ - To work with multiple environments, use different API keys (one per environment)
147
+
148
+ You can manage environments and create API keys in the EmailList web dashboard.
@@ -1,17 +1,41 @@
1
- require 'faraday'
2
- require 'faraday/retry'
3
- require 'faraday/follow_redirects'
1
+ require 'httpx'
4
2
  require 'json'
3
+ require 'uri'
5
4
 
6
5
  module EmailListApi
7
6
  class Client
8
- DEFAULT_BASE_URL = "http://localhost:3000/api/v1"
7
+ PRODUCTION_BASE_URL = "https://emaillist.dev/api"
8
+ DEVELOPMENT_BASE_URL = "http://localhost:3000/api"
9
9
 
10
- def initialize(api_key: ENV['EMAILLIST_API_KEY'], base_url: DEFAULT_BASE_URL)
11
- @api_key = api_key
12
- @base_url = base_url
10
+ def initialize(api_key: nil, api_version: nil, base_url: nil)
11
+ # Priority: explicit parameter > global config
12
+ @api_key = api_key || EmailListApi.configuration.api_key
13
+ @version = api_version || EmailListApi.configuration.api_version || 1
14
+ @origin, @base_path = build_url_parts(base_url)
13
15
  end
14
16
 
17
+ private
18
+
19
+ def build_url_parts(base_url_override = nil)
20
+ # Priority: explicit parameter > config
21
+ base_url = base_url_override || EmailListApi.configuration.base_url
22
+
23
+ raise ArgumentError, "base_url must be configured" unless base_url
24
+
25
+ uri = URI.parse(base_url)
26
+ origin = "#{uri.scheme}://#{uri.host}#{uri.port && uri.port != (uri.scheme == 'https' ? 443 : 80) ? ":#{uri.port}" : ''}"
27
+ base_path = "#{uri.path}/v#{@version}".gsub(%r{/+}, '/')
28
+ [ origin, base_path ]
29
+ end
30
+
31
+ def build_path(path)
32
+ # Ensure path starts with / and combine with base_path
33
+ path = "/#{path}" unless path.start_with?('/')
34
+ "#{@base_path}#{path}".gsub(%r{/+}, '/')
35
+ end
36
+
37
+ public
38
+
15
39
  # Projects
16
40
  def projects
17
41
  get("projects")
@@ -25,6 +49,10 @@ module EmailListApi
25
49
  post("projects", { project: { name: name, description: description } })
26
50
  end
27
51
 
52
+ def upsert_project(name:, description: nil)
53
+ post("projects/upsert", { project: { name: name, description: description } })
54
+ end
55
+
28
56
  def update_project(id, name: nil, description: nil)
29
57
  params = { project: {} }
30
58
  params[:project][:name] = name if name
@@ -37,116 +65,187 @@ module EmailListApi
37
65
  end
38
66
 
39
67
  # Lists
40
- def lists(project_id:)
41
- get("projects/#{project_id}/lists")
68
+ def lists(project:)
69
+ get("projects/#{project}/lists")
70
+ end
71
+
72
+ def list(project:, slug:)
73
+ get("projects/#{project}/lists/#{slug}")
42
74
  end
43
75
 
44
- def list(project_id:, id:)
45
- get("projects/#{project_id}/lists/#{id}")
76
+ def create_list(project:, name:, description: nil)
77
+ post("projects/#{project}/lists", { list: { name: name, description: description } })
46
78
  end
47
79
 
48
- def create_list(project_id:, name:, description: nil)
49
- post("projects/#{project_id}/lists", { list: { name: name, description: description } })
80
+ def upsert_list(project:, name:, description: nil)
81
+ post("projects/#{project}/lists/upsert", { list: { name: name, description: description } })
50
82
  end
51
83
 
52
- def update_list(project_id:, id:, name: nil, description: nil)
84
+ def update_list(project:, slug:, name: nil, description: nil)
53
85
  params = { list: {} }
54
86
  params[:list][:name] = name if name
55
87
  params[:list][:description] = description if description
56
- patch("projects/#{project_id}/lists/#{id}", params)
88
+ patch("projects/#{project}/lists/#{slug}", params)
57
89
  end
58
90
 
59
- def delete_list(project_id:, id:)
60
- delete("projects/#{project_id}/lists/#{id}")
91
+ def delete_list(project:, slug:)
92
+ delete("projects/#{project}/lists/#{slug}")
61
93
  end
62
94
 
63
95
  # Contacts
64
- def contacts(project_id:, page: 1)
65
- get("projects/#{project_id}/contacts", { page: page })
96
+ def contacts(project:, page: 1)
97
+ get("projects/#{project}/contacts", { page: page })
66
98
  end
67
99
 
68
- def contact(project_id:, id:)
69
- get("projects/#{project_id}/contacts/#{id}")
100
+ def contact(project:, id:)
101
+ get("projects/#{project}/contacts/#{id}")
70
102
  end
71
103
 
72
- def create_contact(project_id:, email:, first_name: nil, last_name: nil)
104
+ def create_contact(project:, email:, first_name: nil, last_name: nil)
73
105
  params = { contact: { email: email } }
74
106
  params[:contact][:first_name] = first_name if first_name
75
107
  params[:contact][:last_name] = last_name if last_name
76
- post("projects/#{project_id}/contacts", params)
108
+ post("projects/#{project}/contacts", params)
77
109
  end
78
110
 
79
- def update_contact(project_id:, id:, email: nil, first_name: nil, last_name: nil)
111
+ def update_contact(project:, id:, email: nil, first_name: nil, last_name: nil)
80
112
  params = { contact: {} }
81
113
  params[:contact][:email] = email if email
82
114
  params[:contact][:first_name] = first_name if first_name
83
115
  params[:contact][:last_name] = last_name if last_name
84
- patch("projects/#{project_id}/contacts/#{id}", params)
116
+ patch("projects/#{project}/contacts/#{id}", params)
85
117
  end
86
118
 
87
- def delete_contact(project_id:, id:)
88
- delete("projects/#{project_id}/contacts/#{id}")
119
+ def upsert_contact(project:, email:, first_name: nil, last_name: nil)
120
+ params = { contact: { email: email } }
121
+ params[:contact][:first_name] = first_name if first_name
122
+ params[:contact][:last_name] = last_name if last_name
123
+ post("projects/#{project}/contacts/upsert", params)
124
+ end
125
+
126
+ def delete_contact(project:, id:)
127
+ delete("projects/#{project}/contacts/#{id}")
89
128
  end
90
129
 
91
130
  # List Memberships
92
- def list_contacts(project_id:, list_id:)
93
- get("projects/#{project_id}/lists/#{list_id}/contacts")
131
+ def list_contacts(project:, list_slug:)
132
+ get("projects/#{project}/lists/#{list_slug}/contacts")
94
133
  end
95
134
 
96
- def add_contacts_to_list(project_id:, list_id:, contacts:)
135
+ def add_contacts_to_list(project:, list_slug:, contacts:)
97
136
  # contacts can be an array of hashes or a single hash, but API expects array
98
- contacts = [contacts] unless contacts.is_a?(Array)
99
- post("projects/#{project_id}/lists/#{list_id}/contacts", { contacts: contacts })
137
+ contacts = [ contacts ] unless contacts.is_a?(Array)
138
+ post("projects/#{project}/lists/#{list_slug}/contacts", { contacts: contacts })
100
139
  end
101
140
 
102
- def add_contact_to_list(project_id:, list_id:, contact_id:)
141
+ def add_contact_to_list(project:, list_slug:, contact_id:)
103
142
  # Helper for adding single existing contact by ID
104
- add_contacts_to_list(project_id: project_id, list_id: list_id, contacts: [{ id: contact_id }])
143
+ add_contacts_to_list(project: project, list_slug: list_slug, contacts: [ { id: contact_id } ])
105
144
  end
106
145
 
107
- def remove_contact_from_list(project_id:, list_id:, contact_id:)
108
- delete("projects/#{project_id}/lists/#{list_id}/contacts/#{contact_id}")
146
+ def remove_contact_from_list(project:, list_slug:, contact_id:)
147
+ delete("projects/#{project}/lists/#{list_slug}/contacts/#{contact_id}")
109
148
  end
110
149
 
111
150
  # Bulk Operations
112
- def bulk_create_contacts(project_id:, contacts:)
113
- post("projects/#{project_id}/contacts/bulk", { contacts: contacts })
151
+ def bulk_create_contacts(project:, contacts:)
152
+ post("projects/#{project}/contacts/bulk", { contacts: contacts })
114
153
  end
115
154
 
116
155
  private
117
156
 
118
- def connection
119
- @connection ||= Faraday.new(url: @base_url) do |conn|
120
- conn.request :json
121
- conn.request :retry
122
- conn.response :json, content_type: /\bjson$/
123
- conn.response :raise_error
124
- conn.response :follow_redirects
125
- conn.headers['Authorization'] = "Bearer #{@api_key}"
126
- conn.headers['Content-Type'] = 'application/json'
127
- conn.headers['User-Agent'] = "EmailListApi Ruby Client/#{EmailListApi::VERSION}"
128
- conn.adapter Faraday.default_adapter
129
- end
157
+ def http_client
158
+ @http_client ||= HTTPX.plugin(:follow_redirects).plugin(:retries).with(
159
+ origin: @origin,
160
+ headers: {
161
+ 'Authorization' => "Bearer #{@api_key}",
162
+ 'Content-Type' => 'application/json',
163
+ 'User-Agent' => "EmailListApi Ruby Client/#{EmailListApi::VERSION}"
164
+ }
165
+ )
130
166
  end
131
167
 
132
168
  def get(path, params = {})
133
- response = connection.get(path, params)
134
- response.body
169
+ response = http_client.get(build_path(path), params: params)
170
+ raise_error_if_needed(response)
171
+ parse_json_response(response)
135
172
  end
136
173
 
137
174
  def post(path, body = {})
138
- response = connection.post(path, body)
139
- response.body
175
+ response = http_client.post(build_path(path), json: body)
176
+ raise_error_if_needed(response)
177
+ parse_json_response(response)
140
178
  end
141
179
 
142
180
  def patch(path, body = {})
143
- response = connection.patch(path, body)
144
- response.body
181
+ response = http_client.patch(build_path(path), json: body)
182
+ raise_error_if_needed(response)
183
+ parse_json_response(response)
145
184
  end
146
185
 
147
186
  def delete(path)
148
- response = connection.delete(path)
149
- response.body
187
+ response = http_client.delete(build_path(path))
188
+ raise_error_if_needed(response)
189
+ parse_json_response(response)
190
+ end
191
+
192
+ def parse_json_response(response)
193
+ # Handle 204 No Content and empty responses
194
+ return nil if response.status == 204 || response.body.to_s.empty?
195
+
196
+ response.json
197
+ end
198
+
199
+ def raise_error_if_needed(response)
200
+ # HTTPX returns status as Integer (200-299 are success)
201
+ return if response.status >= 200 && response.status < 300
202
+
203
+ # Try to parse JSON error response
204
+ error_message = extract_error_message(response)
205
+ raise HTTPX::Error, error_message
206
+ end
207
+
208
+ def extract_error_message(response)
209
+ body = response.body.to_s
210
+
211
+ # Try to parse as JSON first
212
+ begin
213
+ json = JSON.parse(body)
214
+ if json.is_a?(Hash)
215
+ # Extract error message from common JSON error formats
216
+ error = json['error'] || json['errors']
217
+ if error.is_a?(Hash)
218
+ message = error['message'] || error['code'] || error.to_s
219
+ elsif error.is_a?(Array) && error.any?
220
+ message = error.first.is_a?(Hash) ? (error.first['message'] || error.first.to_s) : error.first.to_s
221
+ elsif error.is_a?(String)
222
+ message = error
223
+ end
224
+
225
+ return "HTTP #{response.status}: #{message || json.to_s}" if message
226
+ end
227
+ rescue JSON::ParserError
228
+ # Not JSON, continue to HTML handling
229
+ end
230
+
231
+ # If it's HTML (likely an error page), extract a meaningful message
232
+ if body.include?('<!DOCTYPE') || body.include?('<html')
233
+ # Extract title or h1 if available
234
+ if body =~ /<title>(.*?)<\/title>/i
235
+ title = $1.strip
236
+ return "HTTP #{response.status}: #{title}" unless title.empty?
237
+ end
238
+ if body =~ /<h1[^>]*>(.*?)<\/h1>/i
239
+ heading = $1.strip
240
+ return "HTTP #{response.status}: #{heading}" unless heading.empty?
241
+ end
242
+ # Fallback to a generic message for HTML errors
243
+ return "HTTP #{response.status}: Server returned an error page"
244
+ end
245
+
246
+ # Default: return status and truncated body
247
+ truncated_body = body.length > 200 ? "#{body[0..200]}..." : body
248
+ "HTTP #{response.status}: #{truncated_body}"
150
249
  end
151
250
  end
152
251
  end
@@ -0,0 +1,14 @@
1
+ module EmailListApi
2
+ class Configuration
3
+ attr_accessor :api_key, :api_version, :base_url, :raise_on_sync_error
4
+
5
+ def initialize
6
+ @api_key = nil
7
+ @api_version = nil
8
+ @base_url = nil
9
+ # Default: raise exceptions on sync errors (fail loudly)
10
+ # Set to false to fail silently if needed (e.g., during gem bugs)
11
+ @raise_on_sync_error = true
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,144 @@
1
+ require 'active_support/concern'
2
+
3
+ module EmailListApi
4
+ module SyncsEmailListContact
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # Optional: Add a column to store the contact_id for caching
9
+ # You can add this migration: add_column :users, :emaillist_contact_id, :integer
10
+
11
+ after_create :sync_to_email_list_after_create
12
+ after_update :sync_to_email_list_after_update
13
+ end
14
+
15
+ module ClassMethods
16
+ # Configure the sync behavior
17
+ # Example:
18
+ # syncs_email_list_contact project_slug: 'my-project',
19
+ # email_field: :email_address,
20
+ # first_name_field: :given_name,
21
+ # last_name_field: :family_name,
22
+ # api_version: 1
23
+ #
24
+ # @param project_slug [String] The project slug to sync contacts to
25
+ # @param email_field [Symbol] The attribute name for email (default: :email)
26
+ # @param first_name_field [Symbol] The attribute name for first name (default: :first_name)
27
+ # @param last_name_field [Symbol] The attribute name for last name (default: :last_name)
28
+ # @param api_version [Integer, nil] API version override (default: nil, uses global config)
29
+ # @param always_sync [Boolean] Sync on every save, not just when fields change (default: false)
30
+ def syncs_email_list_contact(project_slug:,
31
+ email_field: :email,
32
+ first_name_field: :first_name,
33
+ last_name_field: :last_name,
34
+ api_version: nil,
35
+ always_sync: false)
36
+ @emaillist_project = project_slug
37
+ @emaillist_email_field = email_field
38
+ @emaillist_first_name_field = first_name_field
39
+ @emaillist_last_name_field = last_name_field
40
+ @emaillist_api_version = api_version
41
+ @emaillist_always_sync = always_sync
42
+ end
43
+
44
+ def emaillist_project
45
+ @emaillist_project
46
+ end
47
+
48
+ def emaillist_email_field
49
+ @emaillist_email_field || :email
50
+ end
51
+
52
+ def emaillist_first_name_field
53
+ @emaillist_first_name_field || :first_name
54
+ end
55
+
56
+ def emaillist_last_name_field
57
+ @emaillist_last_name_field || :last_name
58
+ end
59
+
60
+ def emaillist_api_version
61
+ @emaillist_api_version
62
+ end
63
+
64
+ def emaillist_always_sync
65
+ @emaillist_always_sync || false
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def emaillist_client
72
+ # Use shared client if no model-specific api_version override is configured
73
+ if self.class.emaillist_api_version.nil?
74
+ EmailListApi.client
75
+ else
76
+ # Create a custom client only if model has specific api_version override
77
+ @emaillist_client ||= EmailListApi::Client.new(api_version: self.class.emaillist_api_version)
78
+ end
79
+ end
80
+
81
+ def sync_to_email_list_after_create
82
+ sync_to_email_list
83
+ end
84
+
85
+ def sync_to_email_list_after_update
86
+ # If always_sync is enabled, sync on every update
87
+ if self.class.emaillist_always_sync
88
+ sync_to_email_list
89
+ return
90
+ end
91
+
92
+ # Otherwise, only sync if relevant fields changed
93
+ email_field = self.class.emaillist_email_field
94
+ first_name_field = self.class.emaillist_first_name_field
95
+ last_name_field = self.class.emaillist_last_name_field
96
+
97
+ if saved_change_to_attribute?(email_field) ||
98
+ saved_change_to_attribute?(first_name_field) ||
99
+ saved_change_to_attribute?(last_name_field)
100
+ sync_to_email_list
101
+ end
102
+ end
103
+
104
+ def sync_to_email_list
105
+ return unless self.class.emaillist_project
106
+
107
+ email = read_attribute(self.class.emaillist_email_field)
108
+ return unless email.present?
109
+
110
+ first_name = read_attribute(self.class.emaillist_first_name_field)
111
+ last_name = read_attribute(self.class.emaillist_last_name_field)
112
+
113
+ project_slug = self.class.emaillist_project
114
+
115
+ begin
116
+ # Ensure project exists (idempotent - creates if doesn't exist)
117
+ # Convert slug to name for upsert (e.g., "my-project" -> "My Project")
118
+ project_name = project_slug.to_s.split('-').map(&:capitalize).join(' ')
119
+ emaillist_client.upsert_project(name: project_name)
120
+
121
+ # Upsert contact (creates if new, updates if exists)
122
+ contact = emaillist_client.upsert_contact(
123
+ project: project_slug,
124
+ email: email,
125
+ first_name: first_name,
126
+ last_name: last_name
127
+ )
128
+
129
+ # Optionally store the contact_id if column exists
130
+ if respond_to?(:emaillist_contact_id=) && contact && contact[:data]
131
+ contact_id = contact[:data][:id]
132
+ update_column(:emaillist_contact_id, contact_id) if persisted? && contact_id
133
+ end
134
+ rescue => e
135
+ # Log error
136
+ Rails.logger.error("Failed to sync #{self.class.name}##{id} to EmailList: #{e.message}") if defined?(Rails)
137
+ Rails.logger.error(e.backtrace.join("\n")) if defined?(Rails) && Rails.logger.respond_to?(:level) && Rails.logger.level <= 0
138
+
139
+ # Raise exception based on global configuration (defaults to true)
140
+ raise e if EmailListApi.configuration.raise_on_sync_error
141
+ end
142
+ end
143
+ end
144
+ end
@@ -1,3 +1,3 @@
1
1
  module EmailListApi
2
- VERSION = "0.1.0.rc.1"
2
+ VERSION = "0.1.0.rc.2"
3
3
  end
@@ -1,6 +1,31 @@
1
1
  require "email_list_api/version"
2
+ require "email_list_api/configuration"
2
3
  require "email_list_api/client"
4
+ require "email_list_api/syncs_email_list_contact"
3
5
 
4
6
  module EmailListApi
5
7
  class Error < StandardError; end
8
+
9
+ class << self
10
+ attr_writer :configuration
11
+
12
+ def configuration
13
+ @configuration ||= Configuration.new
14
+ end
15
+
16
+ def configure
17
+ yield(configuration)
18
+ # Reset client when configuration changes
19
+ @client = nil
20
+ end
21
+
22
+ def reset
23
+ @configuration = Configuration.new
24
+ @client = nil
25
+ end
26
+
27
+ def client
28
+ @client ||= Client.new
29
+ end
30
+ end
6
31
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: email_list_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.rc.1
4
+ version: 0.1.0.rc.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Email List Dev
@@ -10,47 +10,33 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: faraday
13
+ name: httpx
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '2.0'
18
+ version: '1.0'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '2.0'
26
- - !ruby/object:Gem::Dependency
27
- name: faraday-retry
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
25
+ version: '1.0'
40
26
  - !ruby/object:Gem::Dependency
41
- name: faraday-follow_redirects
27
+ name: activesupport
42
28
  requirement: !ruby/object:Gem::Requirement
43
29
  requirements:
44
- - - ">="
30
+ - - "~>"
45
31
  - !ruby/object:Gem::Version
46
- version: '0'
32
+ version: '5.0'
47
33
  type: :runtime
48
34
  prerelease: false
49
35
  version_requirements: !ruby/object:Gem::Requirement
50
36
  requirements:
51
- - - ">="
37
+ - - "~>"
52
38
  - !ruby/object:Gem::Version
53
- version: '0'
39
+ version: '5.0'
54
40
  - !ruby/object:Gem::Dependency
55
41
  name: bundler
56
42
  requirement: !ruby/object:Gem::Requirement
@@ -80,19 +66,19 @@ dependencies:
80
66
  - !ruby/object:Gem::Version
81
67
  version: '13.0'
82
68
  - !ruby/object:Gem::Dependency
83
- name: rspec
69
+ name: minitest
84
70
  requirement: !ruby/object:Gem::Requirement
85
71
  requirements:
86
72
  - - "~>"
87
73
  - !ruby/object:Gem::Version
88
- version: '3.0'
74
+ version: '5.0'
89
75
  type: :development
90
76
  prerelease: false
91
77
  version_requirements: !ruby/object:Gem::Requirement
92
78
  requirements:
93
79
  - - "~>"
94
80
  - !ruby/object:Gem::Version
95
- version: '3.0'
81
+ version: '5.0'
96
82
  description: A simple Ruby client for managing projects, lists, and contacts via the
97
83
  Email List API.
98
84
  email:
@@ -101,14 +87,18 @@ executables: []
101
87
  extensions: []
102
88
  extra_rdoc_files: []
103
89
  files:
90
+ - LICENSE.txt
104
91
  - README.md
105
92
  - lib/email_list_api.rb
106
93
  - lib/email_list_api/client.rb
94
+ - lib/email_list_api/configuration.rb
95
+ - lib/email_list_api/syncs_email_list_contact.rb
107
96
  - lib/email_list_api/version.rb
108
- homepage: https://github.com/yourusername/emaillist-ruby
97
+ homepage: https://emaillist.dev/docs
109
98
  licenses:
110
99
  - MIT
111
- metadata: {}
100
+ metadata:
101
+ allowed_push_host: https://rubygems.org
112
102
  rdoc_options: []
113
103
  require_paths:
114
104
  - lib
@@ -116,7 +106,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
116
106
  requirements:
117
107
  - - ">="
118
108
  - !ruby/object:Gem::Version
119
- version: '0'
109
+ version: '3.0'
120
110
  required_rubygems_version: !ruby/object:Gem::Requirement
121
111
  requirements:
122
112
  - - ">="