mailfloss 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.
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailfloss
4
+ module Resources
5
+ # @api private
6
+ class Base
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ private
12
+
13
+ attr_reader :client
14
+
15
+ def encode(segment)
16
+ URI.encode_www_form_component(segment.to_s)
17
+ end
18
+ end
19
+
20
+ # POST /batch-verify and its sub-resources.
21
+ class BatchVerify < Base
22
+ # POST /batch-verify — submit a batch of emails for verification.
23
+ #
24
+ # @param emails [Array<String>]
25
+ # @param webhook_url [String, nil] optional completion callback URL
26
+ # @param idempotency_key [String, nil] auto-generated when omitted
27
+ # @return [Hash] { id: } — the created batch job id
28
+ def create(emails:, webhook_url: nil, idempotency_key: nil)
29
+ body = { emails: emails }
30
+ body[:webhook_url] = webhook_url if webhook_url
31
+ client.request(:post, "/batch-verify", body: body, idempotency_key: idempotency_key)
32
+ end
33
+
34
+ # GET /batch-verify/{id}/status
35
+ #
36
+ # @return [Hash] { status:, progress: }
37
+ def status(id)
38
+ client.request(:get, "/batch-verify/#{encode(id)}/status")
39
+ end
40
+
41
+ # GET /batch-verify/{id}/results
42
+ #
43
+ # @param per_page [Integer, nil]
44
+ # @param next_cursor [String, nil] the `next` query param
45
+ # @return [Hash] { id:, results: [...] }
46
+ def results(id, per_page: nil, next_cursor: nil)
47
+ client.request(:get, "/batch-verify/#{encode(id)}/results",
48
+ query: { per_page: per_page, next: next_cursor })
49
+ end
50
+
51
+ # POST /batch-verify/{id}/cancel
52
+ #
53
+ # @return [Hash] { success: }
54
+ def cancel(id, idempotency_key: nil)
55
+ client.request(:post, "/batch-verify/#{encode(id)}/cancel", idempotency_key: idempotency_key)
56
+ end
57
+ end
58
+
59
+ # GET /jobs
60
+ class Jobs < Base
61
+ # GET /jobs — cursor-paginated job history.
62
+ #
63
+ # @param per_page [Integer, nil]
64
+ # @param cursor [String, nil]
65
+ # @param source [String, nil] api|csv|zapier|widget|input|integration
66
+ # @param status [String, nil] queued|processing|completed|cancelled
67
+ # @return [Hash] { data: [Job], pagination: { next_cursor:, has_more: } }
68
+ def list(per_page: nil, cursor: nil, source: nil, status: nil)
69
+ client.request(:get, "/jobs",
70
+ query: { per_page: per_page, cursor: cursor, source: source, status: status })
71
+ end
72
+
73
+ # GET /jobs/{id}
74
+ #
75
+ # @return [Hash] { id:, status:, source:, source_integration:,
76
+ # created_at:, finished_at:, results: }
77
+ def get(id)
78
+ client.request(:get, "/jobs/#{encode(id)}")
79
+ end
80
+ end
81
+
82
+ # GET /users
83
+ class Users < Base
84
+ # GET /users — organization members.
85
+ #
86
+ # @return [Hash] { data: [User], pagination: { next_cursor:, has_more: } }
87
+ def list(per_page: nil, cursor: nil)
88
+ client.request(:get, "/users", query: { per_page: per_page, cursor: cursor })
89
+ end
90
+
91
+ # GET /users/{user_id}
92
+ #
93
+ # @return [Hash] { id:, email:, name:, role:, is_primary:, status:, created_at: }
94
+ def get(user_id)
95
+ client.request(:get, "/users/#{encode(user_id)}")
96
+ end
97
+ end
98
+
99
+ # GET /reports/*
100
+ class Reports < Base
101
+ # GET /reports/usage — verification usage report.
102
+ #
103
+ # @param period [String, nil] this_week|this_month|this_year|lifetime
104
+ # @param connection_id [String, nil] scope to one ESP connection
105
+ # @return [Hash] { period:, connection_id:, range:, total:, statuses:, reasons: }
106
+ def usage(period: nil, connection_id: nil)
107
+ client.request(:get, "/reports/usage",
108
+ query: { period: period, connection_id: connection_id })
109
+ end
110
+ end
111
+
112
+ # GET/PATCH /account
113
+ class Account < Base
114
+ # GET /account — the authenticated account holder.
115
+ def get
116
+ client.request(:get, "/account")
117
+ end
118
+
119
+ # PATCH /account — update writable account & settings fields.
120
+ # Only the provided keys are sent.
121
+ #
122
+ # @param name [String, nil]
123
+ # @param organization [String, nil]
124
+ # @param phone [String, nil]
125
+ # @param vat_id [String, nil]
126
+ # @param country [String, nil]
127
+ # @param address [Hash, nil] partial { line1:, line2:, city:, state:, postal_code:, country: }
128
+ # @param notifications [Hash, nil] partial { after_flossing:, weekly_report:,
129
+ # monthly_report:, payment_receipts: }
130
+ # @return [Hash] the full updated account (same shape as #get)
131
+ def update(name: :__omit__, organization: :__omit__, phone: :__omit__, vat_id: :__omit__,
132
+ country: :__omit__, address: :__omit__, notifications: :__omit__)
133
+ body = {}
134
+ body[:name] = name unless name == :__omit__
135
+ body[:organization] = organization unless organization == :__omit__
136
+ body[:phone] = phone unless phone == :__omit__
137
+ body[:vat_id] = vat_id unless vat_id == :__omit__
138
+ body[:country] = country unless country == :__omit__
139
+ body[:address] = address unless address == :__omit__
140
+ body[:notifications] = notifications unless notifications == :__omit__
141
+ client.request(:patch, "/account", body: body)
142
+ end
143
+ end
144
+
145
+ # GET /organization
146
+ class Organization < Base
147
+ # GET /organization — plan, credits, usage, trial and entitlements.
148
+ def get
149
+ client.request(:get, "/organization")
150
+ end
151
+ end
152
+
153
+ # GET /integrations and nested connections/keywords.
154
+ class Integrations < Base
155
+ # GET /integrations — all supported ESPs with connection state.
156
+ #
157
+ # @return [Hash] { data: [Integration], pagination: { next_cursor:, has_more: } }
158
+ def list(per_page: nil, cursor: nil)
159
+ client.request(:get, "/integrations", query: { per_page: per_page, cursor: cursor })
160
+ end
161
+
162
+ # GET /integrations/{type} — detail for a single ESP.
163
+ #
164
+ # @return [Hash] { type:, connected:, connection_count:, connections:,
165
+ # disconnected_connections: }
166
+ def get(type)
167
+ client.request(:get, "/integrations/#{encode(type)}")
168
+ end
169
+
170
+ def connections
171
+ @connections ||= Connections.new(client)
172
+ end
173
+
174
+ def keywords
175
+ @keywords ||= Keywords.new(client)
176
+ end
177
+ end
178
+
179
+ # /integrations/{type}/connections/*
180
+ class Connections < Base
181
+ # POST /integrations/{type}/connections — connect an ESP account.
182
+ #
183
+ # @param type [String] ESP slug (e.g. "klaviyo")
184
+ # @param credentials [Hash] per-ESP credential shape (e.g. { api_key: "..." })
185
+ # @param name [String, nil] optional display name
186
+ # @return [Hash] the created connection { id:, name:, status:, created_at:,
187
+ # last_synced_at:, settings: }
188
+ def create(type, credentials:, name: nil, idempotency_key: nil)
189
+ body = { credentials: credentials }
190
+ body[:name] = name if name
191
+ client.request(:post, "/integrations/#{encode(type)}/connections",
192
+ body: body, idempotency_key: idempotency_key)
193
+ end
194
+
195
+ # GET /integrations/{type}/connections/{id}
196
+ def get(type, id)
197
+ client.request(:get, "/integrations/#{encode(type)}/connections/#{encode(id)}")
198
+ end
199
+
200
+ # PATCH /integrations/{type}/connections/{id} — update floss settings.
201
+ #
202
+ # @param aggressiveness [String, nil] normal|aggressive|custom
203
+ # @param manual_mode [Boolean, nil]
204
+ # @param autofloss [Boolean, nil]
205
+ # @param decay_protection [Boolean, nil]
206
+ # @param action [String, nil] unsubscribe|delete|update_tags|update_custom_fields|do_nothing
207
+ # @param checks [Hash, nil] partial map of the 12 check gates
208
+ # @return [Hash] the updated connection
209
+ def update(type, id, aggressiveness: nil, manual_mode: nil, autofloss: nil,
210
+ decay_protection: nil, action: nil, checks: nil)
211
+ body = {}
212
+ body[:aggressiveness] = aggressiveness unless aggressiveness.nil?
213
+ body[:manual_mode] = manual_mode unless manual_mode.nil?
214
+ body[:autofloss] = autofloss unless autofloss.nil?
215
+ body[:decay_protection] = decay_protection unless decay_protection.nil?
216
+ body[:action] = action unless action.nil?
217
+ body[:checks] = checks unless checks.nil?
218
+ client.request(:patch, "/integrations/#{encode(type)}/connections/#{encode(id)}", body: body)
219
+ end
220
+
221
+ # DELETE /integrations/{type}/connections/{id} — disconnect (soft-disable).
222
+ #
223
+ # @return [Hash] { id:, type:, status:, disconnected_at: }
224
+ def delete(type, id)
225
+ client.request(:delete, "/integrations/#{encode(type)}/connections/#{encode(id)}")
226
+ end
227
+
228
+ # POST /integrations/{type}/connections/{id}/sync — trigger a sync.
229
+ #
230
+ # @return [Hash] the connection after the sync was queued
231
+ def sync(type, id, idempotency_key: nil)
232
+ client.request(:post, "/integrations/#{encode(type)}/connections/#{encode(id)}/sync",
233
+ idempotency_key: idempotency_key)
234
+ end
235
+
236
+ # POST /integrations/{type}/connections/{id}/test — test stored credentials.
237
+ #
238
+ # @return [Hash] { id:, type:, credential_valid:, tested_at:, reason: }
239
+ def test(type, id, idempotency_key: nil)
240
+ client.request(:post, "/integrations/#{encode(type)}/connections/#{encode(id)}/test",
241
+ idempotency_key: idempotency_key)
242
+ end
243
+ end
244
+
245
+ # /integrations/{type}/connections/{id}/keywords/{list}*
246
+ class Keywords < Base
247
+ # GET .../keywords/{list} — list keyword rules (blacklist or whitelist).
248
+ #
249
+ # @param list [String] "blacklist" or "whitelist"
250
+ # @return [Hash] { data: [Rule], pagination: { next_cursor:, has_more: } }
251
+ def list(type, id, list, per_page: nil, cursor: nil)
252
+ client.request(:get, keywords_path(type, id, list),
253
+ query: { per_page: per_page, cursor: cursor })
254
+ end
255
+
256
+ # POST .../keywords/{list} — add keyword rules (1-1000, all-or-nothing).
257
+ #
258
+ # @param rules [Array<Hash>] each { keyword:, match: "exact"|"contains",
259
+ # applies_to: { localpart:, domain:, email: } }
260
+ # @return [Hash] { data: [Rule] } — the created rules with ids
261
+ def add(type, id, list, rules:, idempotency_key: nil)
262
+ client.request(:post, keywords_path(type, id, list),
263
+ body: { rules: rules }, idempotency_key: idempotency_key)
264
+ end
265
+
266
+ # DELETE .../keywords/{list}/{ruleId} — remove a keyword rule.
267
+ #
268
+ # @return [Hash] { deleted:, id: }
269
+ def delete(type, id, list, rule_id)
270
+ client.request(:delete, "#{keywords_path(type, id, list)}/#{encode(rule_id)}")
271
+ end
272
+
273
+ private
274
+
275
+ def keywords_path(type, id, list)
276
+ "/integrations/#{encode(type)}/connections/#{encode(id)}/keywords/#{encode(list)}"
277
+ end
278
+ end
279
+
280
+ # POST /erasures
281
+ class Erasures < Base
282
+ # POST /erasures — GDPR/CCPA delete-my-data requests.
283
+ #
284
+ # @param emails [Array<String>] addresses to erase
285
+ # @param webhook_url [String, nil] optional completion callback URL
286
+ # @return [Hash] { success: }
287
+ def create(emails:, webhook_url: nil, idempotency_key: nil)
288
+ body = { emails: emails }
289
+ body[:webhook_url] = webhook_url if webhook_url
290
+ client.request(:post, "/erasures", body: body, idempotency_key: idempotency_key)
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module Mailfloss
7
+ # Low-level HTTP transport. Any object that responds to
8
+ # +call(method, url, headers, body)+ and returns an object with
9
+ # +status+, +headers+ and +body+ (or an equivalent Hash) can be injected
10
+ # into Mailfloss::Client via the +transport:+ kwarg — this is the seam
11
+ # used by the test suite.
12
+ class Transport
13
+ Response = Struct.new(:status, :headers, :body)
14
+
15
+ # Errors considered transient connection failures (retried by the client).
16
+ CONNECTION_ERRORS = [
17
+ Errno::ECONNREFUSED,
18
+ Errno::ECONNRESET,
19
+ Errno::EHOSTUNREACH,
20
+ Errno::ETIMEDOUT,
21
+ Errno::EPIPE,
22
+ SocketError,
23
+ EOFError,
24
+ Net::OpenTimeout,
25
+ Net::ReadTimeout,
26
+ OpenSSL::SSL::SSLError
27
+ ].freeze
28
+
29
+ def initialize(timeout: 30)
30
+ @timeout = timeout
31
+ end
32
+
33
+ # @param method [Symbol] :get, :post, :patch, :delete
34
+ # @param url [String] absolute URL (query string already appended)
35
+ # @param headers [Hash{String => String}]
36
+ # @param body [String, nil] already-serialized request body
37
+ # @return [Response]
38
+ def call(method, url, headers, body)
39
+ uri = URI.parse(url)
40
+ request = build_request(method, uri, headers, body)
41
+
42
+ http = Net::HTTP.new(uri.host, uri.port)
43
+ http.use_ssl = uri.scheme == "https"
44
+ http.open_timeout = @timeout
45
+ http.read_timeout = @timeout
46
+ http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
47
+
48
+ response = http.start { |conn| conn.request(request) }
49
+
50
+ response_headers = {}
51
+ response.each_header { |key, value| response_headers[key.downcase] = value }
52
+
53
+ Response.new(response.code.to_i, response_headers, response.body)
54
+ end
55
+
56
+ private
57
+
58
+ def build_request(method, uri, headers, body)
59
+ klass = case method
60
+ when :get then Net::HTTP::Get
61
+ when :post then Net::HTTP::Post
62
+ when :patch then Net::HTTP::Patch
63
+ when :delete then Net::HTTP::Delete
64
+ else
65
+ raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
66
+ end
67
+ request = klass.new(uri.request_uri)
68
+ headers.each { |key, value| request[key] = value }
69
+ request.body = body if body
70
+ request
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailfloss
4
+ VERSION = "0.1.0"
5
+ end
data/lib/mailfloss.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "mailfloss/version"
6
+ require_relative "mailfloss/errors"
7
+ require_relative "mailfloss/transport"
8
+ require_relative "mailfloss/resources"
9
+ require_relative "mailfloss/client"
10
+
11
+ # Official Ruby SDK for the mailfloss email verification API.
12
+ # https://developers.mailfloss.com
13
+ module Mailfloss
14
+ end
data/sig/mailfloss.rbs ADDED
@@ -0,0 +1,77 @@
1
+ module Mailfloss
2
+ VERSION: String
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ class ConfigurationError < Error
8
+ end
9
+
10
+ class APIError < Error
11
+ attr_reader status: Integer
12
+ attr_reader code: String
13
+ attr_reader type: String?
14
+ attr_reader request_id: String?
15
+
16
+ def initialize: (status: Integer, ?body: String?) -> void
17
+ end
18
+
19
+ class Transport
20
+ class Response < Struct[untyped]
21
+ attr_accessor status: Integer
22
+ attr_accessor headers: Hash[String, String]
23
+ attr_accessor body: String?
24
+ end
25
+
26
+ CONNECTION_ERRORS: Array[Class]
27
+
28
+ def initialize: (?timeout: Numeric) -> void
29
+
30
+ def call: (Symbol method, String url, Hash[String, String] headers, String? body) -> Response
31
+ end
32
+
33
+ # Duck type for injectable transports: anything with
34
+ # call(method, url, headers, body) returning status/headers/body.
35
+ interface _Transport
36
+ def call: (Symbol method, String url, Hash[String, String] headers, String? body) -> untyped
37
+ end
38
+
39
+ interface _Sleeper
40
+ def call: (Numeric seconds) -> void
41
+ end
42
+
43
+ class Client
44
+ DEFAULT_BASE_URL: String
45
+ DEFAULT_MAX_RETRIES: Integer
46
+ DEFAULT_TIMEOUT: Integer
47
+ RETRY_BASE_DELAY: Float
48
+ RETRY_MAX_DELAY: Float
49
+ USER_AGENT: String
50
+
51
+ attr_reader base_url: String
52
+ attr_reader max_retries: Integer
53
+ attr_reader timeout: Numeric
54
+
55
+ def initialize: (?api_key: String?, ?base_url: String, ?max_retries: Integer,
56
+ ?timeout: Numeric, ?transport: _Transport?, ?sleeper: _Sleeper?) -> void
57
+
58
+ # GET /verify
59
+ def verify: (email: String, ?timeout: (Numeric | String)?) -> Types::verify_result
60
+
61
+ # GET /check-key
62
+ def check_key: (?api_key: String?, ?public_key: String?, ?check_plan: boolish?,
63
+ ?get_token: boolish?, ?check_credits: boolish?) -> Types::check_key_result
64
+
65
+ def batch_verify: () -> Resources::BatchVerify
66
+ def jobs: () -> Resources::Jobs
67
+ def users: () -> Resources::Users
68
+ def reports: () -> Resources::Reports
69
+ def account: () -> Resources::Account
70
+ def organization: () -> Resources::Organization
71
+ def integrations: () -> Resources::Integrations
72
+ def erasures: () -> Resources::Erasures
73
+
74
+ def request: (Symbol method, String path, ?query: Hash[Symbol, untyped]?,
75
+ ?body: Hash[Symbol, untyped]?, ?idempotency_key: String?) -> untyped
76
+ end
77
+ end
data/sig/resources.rbs ADDED
@@ -0,0 +1,85 @@
1
+ module Mailfloss
2
+ module Resources
3
+ class Base
4
+ def initialize: (Client client) -> void
5
+
6
+ private
7
+
8
+ attr_reader client: Client
9
+
10
+ def encode: (untyped segment) -> String
11
+ end
12
+
13
+ class BatchVerify < Base
14
+ def create: (emails: Array[String], ?webhook_url: String?,
15
+ ?idempotency_key: String?) -> Types::batch_create_result
16
+ def status: (String id) -> Types::batch_status_result
17
+ def results: (String id, ?per_page: Integer?, ?next_cursor: String?) -> Types::batch_results_result
18
+ def cancel: (String id, ?idempotency_key: String?) -> Types::batch_cancel_result
19
+ end
20
+
21
+ class Jobs < Base
22
+ def list: (?per_page: Integer?, ?cursor: String?, ?source: String?,
23
+ ?status: String?) -> Types::jobs_list
24
+ def get: (String id) -> Types::job
25
+ end
26
+
27
+ class Users < Base
28
+ def list: (?per_page: Integer?, ?cursor: String?) -> Types::users_list
29
+ def get: (String user_id) -> Types::user
30
+ end
31
+
32
+ class Reports < Base
33
+ def usage: (?period: String?, ?connection_id: String?) -> Types::usage_report
34
+ end
35
+
36
+ class Account < Base
37
+ def get: () -> Types::account
38
+ def update: (?name: String?, ?organization: String?, ?phone: String?,
39
+ ?vat_id: String?, ?country: String?,
40
+ ?address: Hash[Symbol, String?]?,
41
+ ?notifications: Hash[Symbol, bool]?) -> Types::account
42
+ end
43
+
44
+ class Organization < Base
45
+ def get: () -> Types::organization
46
+ end
47
+
48
+ class Integrations < Base
49
+ def list: (?per_page: Integer?, ?cursor: String?) -> Types::integrations_list
50
+ def get: (String type) -> Types::integration_detail
51
+ def connections: () -> Connections
52
+ def keywords: () -> Keywords
53
+ end
54
+
55
+ class Connections < Base
56
+ def create: (String type, credentials: Hash[Symbol, String], ?name: String?,
57
+ ?idempotency_key: String?) -> Types::connection
58
+ def get: (String type, String id) -> Types::connection
59
+ def update: (String type, String id, ?aggressiveness: String?,
60
+ ?manual_mode: bool?, ?autofloss: bool?, ?decay_protection: bool?,
61
+ ?action: String?, ?checks: Hash[Symbol, bool]?) -> Types::connection
62
+ def delete: (String type, String id) -> Types::disconnect_result
63
+ def sync: (String type, String id, ?idempotency_key: String?) -> Types::connection
64
+ def test: (String type, String id, ?idempotency_key: String?) -> Types::credential_test_result
65
+ end
66
+
67
+ class Keywords < Base
68
+ def list: (String type, String id, String list, ?per_page: Integer?,
69
+ ?cursor: String?) -> Types::keyword_rules_list
70
+ def add: (String type, String id, String list,
71
+ rules: Array[Types::keyword_rule_input],
72
+ ?idempotency_key: String?) -> Types::keyword_rules_created
73
+ def delete: (String type, String id, String list, String rule_id) -> Types::keyword_rule_deleted
74
+
75
+ private
76
+
77
+ def keywords_path: (String type, String id, String list) -> String
78
+ end
79
+
80
+ class Erasures < Base
81
+ def create: (emails: Array[String], ?webhook_url: String?,
82
+ ?idempotency_key: String?) -> Types::erasure_result
83
+ end
84
+ end
85
+ end