axene-mailer 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: 387abf842f6eb12070172e80dd6aaba7020b991931c5a0ab217232dc96599eb0
4
+ data.tar.gz: '09c6e130fe58403050eadf09bef07eab7984aa14e3552ed3278a24d4f51cde23'
5
+ SHA512:
6
+ metadata.gz: c7d4d61ebe57213c75eaaf0a89341c61d7a743100fc59dc3bc515f035951ab1ec55ad13f1596ffd84cb9c6140510f69da150c168891d83839acc2cc0aea4c183
7
+ data.tar.gz: 7ba6727509a1fd7721c5fc6ac9866aa6cf2d8495848c8ecec22ff56e7ad21d89a29ee7b51b9e00779656a065f72d80b20e99ed0894a3f6f4852825a6cbbf24c4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Axene Solutions
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 ADDED
@@ -0,0 +1,125 @@
1
+ # axene-mailer (Ruby)
2
+
3
+ Ruby SDK for the [Axene Mailer](https://axene.io) API. Send email, manage
4
+ domains, contacts, suppressions, templates, and webhooks. Zero runtime
5
+ dependencies (standard library only). Ruby 3.0+.
6
+
7
+ ## Install
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "axene-mailer"
12
+ ```
13
+
14
+ ```sh
15
+ bundle install
16
+ # or
17
+ gem install axene-mailer
18
+ ```
19
+
20
+ ## Quickstart
21
+
22
+ ```ruby
23
+ require "axene/mailer"
24
+
25
+ client = Axene::Mailer::Client.new(api_key: ENV.fetch("AXENE_API_KEY"))
26
+
27
+ # Send a single email. A bare string is sugar for { email: ... }.
28
+ result = client.emails.send(
29
+ from: "hello@yourdomain.com",
30
+ to: "customer@example.com",
31
+ subject: "Your receipt",
32
+ html: "<p>Thanks for your order.</p>"
33
+ )
34
+ puts result[:id]
35
+ ```
36
+
37
+ The client exposes six resources: `emails`, `domains`, `contacts`,
38
+ `suppressions`, `templates`, and `webhooks`. Methods return parsed Ruby
39
+ `Hash`/`Array` values with symbol keys.
40
+
41
+ ### Addresses
42
+
43
+ Anywhere an address is accepted you may pass a plain string or a hash:
44
+
45
+ ```ruby
46
+ client.emails.send(
47
+ from: { email: "hello@yourdomain.com", name: "Acme" },
48
+ to: ["a@example.com", { email: "b@example.com", name: "B" }],
49
+ subject: "Hi",
50
+ text: "Plain text body"
51
+ )
52
+ ```
53
+
54
+ ### Batch and validation
55
+
56
+ ```ruby
57
+ client.emails.send_batch([
58
+ { from: "hi@you.io", to: "a@example.com", subject: "1" },
59
+ { from: "hi@you.io", to: "b@example.com", subject: "2" }
60
+ ])
61
+
62
+ check = client.emails.validate(from: "hi@you.io", to: "a@example.com", subject: "Test")
63
+ puts check[:can_send]
64
+ ```
65
+
66
+ ### Contacts and CSV upload
67
+
68
+ ```ruby
69
+ list = client.contacts.create_list(name: "Newsletter")
70
+
71
+ # Upload accepts raw bytes or a file path.
72
+ client.contacts.upload_csv(list[:id], "contacts.csv", filename: "contacts.csv")
73
+
74
+ client.contacts.bulk_send(
75
+ list[:id],
76
+ sender_address_id: "sa_123",
77
+ subject: "Hello {{name}}",
78
+ html: "<p>Hi {{name}}</p>"
79
+ )
80
+ ```
81
+
82
+ ### Suppressions, templates, webhooks
83
+
84
+ ```ruby
85
+ client.suppressions.add(email: "bounce@example.com")
86
+ page = client.suppressions.list(page: 0, limit: 50) # envelope: items/total/page/limit
87
+
88
+ client.templates.create(name: "Welcome", html: "<p>Hi</p>", text: "Hi")
89
+
90
+ hook = client.webhooks.create(url: "https://you.io/hooks", events: ["email.delivered"])
91
+ client.webhooks.update(hook[:id], is_active: false)
92
+ ```
93
+
94
+ ### Errors
95
+
96
+ Non-2xx responses raise `Axene::Mailer::Error`:
97
+
98
+ ```ruby
99
+ begin
100
+ client.emails.get("nope")
101
+ rescue Axene::Mailer::Error => e
102
+ warn "#{e.status} #{e.code}: #{e.message}"
103
+ end
104
+ ```
105
+
106
+ A `status` of `0` indicates a transport failure (no HTTP response). Requests
107
+ that fail with `429` or `5xx` are retried automatically with backoff, honoring
108
+ the `Retry-After` header.
109
+
110
+ ### Configuration
111
+
112
+ ```ruby
113
+ Axene::Mailer::Client.new(
114
+ api_key: "axm_k_...",
115
+ base_url: "https://mail.axene.io", # default
116
+ max_retries: 3, # default
117
+ timeout: 30 # seconds, default
118
+ )
119
+ ```
120
+
121
+ Pagination is zero-based (`page: 0` is the first page).
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "transport"
4
+ require_relative "resources/emails"
5
+ require_relative "resources/domains"
6
+ require_relative "resources/contacts"
7
+ require_relative "resources/suppressions"
8
+ require_relative "resources/templates"
9
+ require_relative "resources/webhooks"
10
+
11
+ module Axene
12
+ module Mailer
13
+ # Axene Mailer API client. Composes the HTTP transport with the resource
14
+ # groups. This is the entry point most code touches.
15
+ #
16
+ # @example
17
+ # client = Axene::Mailer::Client.new(api_key: ENV.fetch("AXENE_API_KEY"))
18
+ # client.emails.send(
19
+ # from: "hello@yourdomain.com",
20
+ # to: "customer@example.com",
21
+ # subject: "Your receipt",
22
+ # html: "<p>Thanks for your order.</p>"
23
+ # )
24
+ class Client
25
+ # @return [Axene::Mailer::Resources::Emails] send, search, schedule, inspect emails
26
+ attr_reader :emails
27
+ # @return [Axene::Mailer::Resources::Domains] register, verify, transfer domains
28
+ attr_reader :domains
29
+ # @return [Axene::Mailer::Resources::Contacts] manage lists and bulk sends
30
+ attr_reader :contacts
31
+ # @return [Axene::Mailer::Resources::Suppressions] manage the do-not-send list
32
+ attr_reader :suppressions
33
+ # @return [Axene::Mailer::Resources::Templates] manage reusable templates
34
+ attr_reader :templates
35
+ # @return [Axene::Mailer::Resources::Webhooks] manage webhooks, inspect deliveries
36
+ attr_reader :webhooks
37
+
38
+ # @param api_key [String] required; starts with "axm_k_"
39
+ # @param base_url [String] default "https://mail.axene.io"
40
+ # @param max_retries [Integer] retries for 429/5xx, default 3
41
+ # @param timeout [Numeric] per-request timeout in seconds, default 30
42
+ def initialize(api_key:, base_url: Transport::DEFAULT_BASE_URL, max_retries: 3, timeout: 30)
43
+ transport = Transport.new(api_key: api_key, base_url: base_url, max_retries: max_retries, timeout: timeout)
44
+ @emails = Resources::Emails.new(transport)
45
+ @domains = Resources::Domains.new(transport)
46
+ @contacts = Resources::Contacts.new(transport)
47
+ @suppressions = Resources::Suppressions.new(transport)
48
+ @templates = Resources::Templates.new(transport)
49
+ @webhooks = Resources::Webhooks.new(transport)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axene
4
+ module Mailer
5
+ # Raised for any non-2xx API response, or for a transport failure that
6
+ # survives all retries.
7
+ #
8
+ # Inspect {#status} and {#code} to branch on specific failures (for example
9
+ # a 422 with code "invalid"). A {#status} of 0 indicates a transport or
10
+ # network failure with no HTTP response.
11
+ class Error < StandardError
12
+ # @return [Integer] HTTP status code (0 for transport failures)
13
+ attr_reader :status
14
+
15
+ # @return [String, nil] machine-readable error code from the API body
16
+ attr_reader :code
17
+
18
+ # @return [Object, nil] the raw parsed response body, for debugging
19
+ attr_reader :detail
20
+
21
+ # @param status [Integer]
22
+ # @param message [String]
23
+ # @param code [String, nil]
24
+ # @param detail [Object, nil]
25
+ def initialize(status, message, code = nil, detail = nil)
26
+ super(message)
27
+ @status = status
28
+ @code = code
29
+ @detail = detail
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axene
4
+ module Mailer
5
+ module Resources
6
+ # The +contacts+ resource: manage subscriber lists, their contacts, CSV
7
+ # imports, and templated bulk sends. Accessed as +client.contacts+.
8
+ class Contacts
9
+ # @param transport [Axene::Mailer::Transport]
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ # List all subscriber lists in the active workspace.
15
+ #
16
+ # @return [Array<Hash>]
17
+ def list_lists
18
+ @transport.request(:get, "/v1/contacts/")
19
+ end
20
+
21
+ # Create a subscriber list.
22
+ #
23
+ # @param name [String]
24
+ # @param description [String, nil]
25
+ # @param icon_seed [String, nil]
26
+ # @return [Hash]
27
+ def create_list(name:, description: nil, icon_seed: nil)
28
+ @transport.request(:post, "/v1/contacts/",
29
+ body: Util.prune(name: name, description: description, icon_seed: icon_seed))
30
+ end
31
+
32
+ # Get a list with a page of its contacts (zero-based +page+).
33
+ #
34
+ # @param id [String]
35
+ # @param page [Integer] default 0
36
+ # @param limit [Integer] default 50
37
+ # @return [Hash]
38
+ def get_list(id, page: 0, limit: 50)
39
+ @transport.request(:get, "/v1/contacts/#{Util.escape(id)}", query: { page: page, limit: limit })
40
+ end
41
+
42
+ # Update a list's name, description, or icon (partial).
43
+ #
44
+ # @param id [String]
45
+ # @param name [String, nil]
46
+ # @param description [String, nil]
47
+ # @param icon_seed [String, nil]
48
+ # @return [Hash]
49
+ def update_list(id, name: nil, description: nil, icon_seed: nil)
50
+ @transport.request(:patch, "/v1/contacts/#{Util.escape(id)}",
51
+ body: Util.prune(name: name, description: description, icon_seed: icon_seed))
52
+ end
53
+
54
+ # Delete a list and all of its contacts.
55
+ #
56
+ # @param id [String]
57
+ # @return [nil]
58
+ def delete_list(id)
59
+ @transport.request(:delete, "/v1/contacts/#{Util.escape(id)}")
60
+ end
61
+
62
+ # Add a single contact to a list.
63
+ #
64
+ # @param list_id [String]
65
+ # @param email [String]
66
+ # @param name [String, nil]
67
+ # @param metadata [Hash, nil]
68
+ # @return [Hash]
69
+ def add_contact(list_id, email:, name: nil, metadata: nil)
70
+ @transport.request(:post, "/v1/contacts/#{Util.escape(list_id)}/contacts",
71
+ body: Util.prune(email: email, name: name, metadata: metadata))
72
+ end
73
+
74
+ # Remove a contact from a list.
75
+ #
76
+ # @param list_id [String]
77
+ # @param contact_id [String]
78
+ # @return [nil]
79
+ def remove_contact(list_id, contact_id)
80
+ @transport.request(:delete, "/v1/contacts/#{Util.escape(list_id)}/contacts/#{Util.escape(contact_id)}")
81
+ end
82
+
83
+ # Import contacts from a CSV file (header row required). The email column
84
+ # is auto-detected; other columns become contact metadata.
85
+ #
86
+ # +file+ may be raw bytes (a String) or a path to a readable file.
87
+ #
88
+ # @param list_id [String]
89
+ # @param file [String] raw CSV bytes or a file path
90
+ # @param filename [String]
91
+ # @return [Hash] { imported:, skipped:, errors: }
92
+ def upload_csv(list_id, file, filename: "contacts.csv")
93
+ bytes = read_file(file)
94
+ @transport.upload("/v1/contacts/#{Util.escape(list_id)}/upload", bytes, filename)
95
+ end
96
+
97
+ # Send a templated email to every contact in a list. The +contact_list_id+
98
+ # is injected automatically from +list_id+. Subject/html/text may use
99
+ # {{email}}, {{name}}, and {{metadata_key}} placeholders.
100
+ #
101
+ # @param list_id [String]
102
+ # @param sender_address_id [String]
103
+ # @param subject [String]
104
+ # @param html [String, nil]
105
+ # @param text [String, nil]
106
+ # @param tags [Array<String>, nil]
107
+ # @return [Hash] { queued:, skipped:, errors: }
108
+ def bulk_send(list_id, sender_address_id:, subject:, html: nil, text: nil, tags: nil)
109
+ body = Util.prune(
110
+ contact_list_id: list_id,
111
+ sender_address_id: sender_address_id,
112
+ subject: subject,
113
+ html: html,
114
+ text: text,
115
+ tags: tags
116
+ )
117
+ @transport.request(:post, "/v1/contacts/#{Util.escape(list_id)}/send", body: body)
118
+ end
119
+
120
+ private
121
+
122
+ # Accept either raw bytes or a filesystem path. A path is detected only
123
+ # when the string is short, single-line, and names an existing file.
124
+ def read_file(file)
125
+ if file.is_a?(String) && file.length < 4096 && !file.include?("\n") && File.file?(file)
126
+ File.binread(file)
127
+ else
128
+ file
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axene
4
+ module Mailer
5
+ module Resources
6
+ # The +domains+ resource: register, verify, inspect, and transfer sending
7
+ # domains. Accessed as +client.domains+.
8
+ class Domains
9
+ # @param transport [Axene::Mailer::Transport]
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ # List your sending domains and their verification status.
15
+ #
16
+ # @return [Array<Hash>]
17
+ def list
18
+ @transport.request(:get, "/v1/domains/")
19
+ end
20
+
21
+ # Register a new sending domain. Returns the DNS records to publish.
22
+ #
23
+ # @param name [String]
24
+ # @return [Hash]
25
+ def create(name)
26
+ @transport.request(:post, "/v1/domains/", body: { name: name })
27
+ end
28
+
29
+ # Fetch a domain with its DKIM selector and DNS records.
30
+ #
31
+ # @param id [String]
32
+ # @return [Hash]
33
+ def get(id)
34
+ @transport.request(:get, "/v1/domains/#{Util.escape(id)}")
35
+ end
36
+
37
+ # Delete a domain.
38
+ #
39
+ # @param id [String]
40
+ # @return [nil]
41
+ def delete(id)
42
+ @transport.request(:delete, "/v1/domains/#{Util.escape(id)}")
43
+ end
44
+
45
+ # Re-check DNS and verify the domain.
46
+ #
47
+ # @param id [String]
48
+ # @return [Hash]
49
+ def verify(id)
50
+ @transport.request(:post, "/v1/domains/#{Util.escape(id)}/verify")
51
+ end
52
+
53
+ # Run live DNS health checks (DKIM, SPF, DMARC, return-path, MX).
54
+ #
55
+ # @param id [String]
56
+ # @return [Hash]
57
+ def health(id)
58
+ @transport.request(:get, "/v1/domains/#{Util.escape(id)}/health")
59
+ end
60
+
61
+ # Diagnose configuration issues and get a health score.
62
+ #
63
+ # @param id [String]
64
+ # @return [Hash]
65
+ def diagnose(id)
66
+ @transport.request(:get, "/v1/domains/#{Util.escape(id)}/diagnose")
67
+ end
68
+
69
+ # Current MX status for inbound/forwarding (shape varies by provider).
70
+ #
71
+ # @param id [String]
72
+ # @return [Hash]
73
+ def mx_status(id)
74
+ @transport.request(:get, "/v1/domains/#{Util.escape(id)}/mx-status")
75
+ end
76
+
77
+ # The values currently published in DNS for each of the domain's records.
78
+ #
79
+ # @param id [String]
80
+ # @return [Hash]
81
+ def published_records(id)
82
+ @transport.request(:get, "/v1/domains/#{Util.escape(id)}/published-records")
83
+ end
84
+
85
+ # Rotate the domain's DKIM key, returning the new record to publish.
86
+ #
87
+ # @param id [String]
88
+ # @return [Hash]
89
+ def rotate_dkim(id)
90
+ @transport.request(:post, "/v1/domains/#{Util.escape(id)}/rotate-dkim")
91
+ end
92
+
93
+ # Initiate a transfer of this domain to another Axene account.
94
+ #
95
+ # @param id [String]
96
+ # @param target_email [String]
97
+ # @param note [String, nil]
98
+ # @return [Hash]
99
+ def transfer(id, target_email:, note: nil)
100
+ @transport.request(:post, "/v1/domains/#{Util.escape(id)}/transfer",
101
+ body: { target_email: target_email, note: note })
102
+ end
103
+
104
+ # Check whether a domain name is available to add (checks public DNS).
105
+ #
106
+ # @param name [String]
107
+ # @return [Hash]
108
+ def check_availability(name)
109
+ @transport.request(:get, "/v1/domains/check-availability", query: { name: name })
110
+ end
111
+
112
+ # Check whether a domain name already exists in your account.
113
+ #
114
+ # @param name [String]
115
+ # @return [Hash]
116
+ def check(name)
117
+ @transport.request(:get, "/v1/domains/check/#{Util.escape(name)}")
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axene
4
+ module Mailer
5
+ module Resources
6
+ # The +emails+ resource: send, look up, search, schedule, and inspect
7
+ # messages. Accessed as +client.emails+.
8
+ class Emails
9
+ # @param transport [Axene::Mailer::Transport]
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ # Send a single email.
15
+ #
16
+ # Accepts keyword arguments or a Hash. The +from+ field is exposed
17
+ # cleanly and mapped to the wire name +from_+. A bare String is accepted
18
+ # anywhere an address is expected and becomes +{ email: ... }+.
19
+ #
20
+ # @param message [Hash] send parameters (:from, :to, :subject, :html, ...)
21
+ # @return [Hash] { id:, status:, message_id:, rejection_reason: }
22
+ def send(message = {}, **kwargs)
23
+ body = serialize_send(message.empty? ? kwargs : message)
24
+ @transport.request(:post, "/v1/emails/", body: body)
25
+ end
26
+
27
+ # Send up to your plan's batch limit in one call. The API accepts a bare
28
+ # array of messages and returns a per-message result set.
29
+ #
30
+ # @param messages [Array<Hash>]
31
+ # @return [Hash] { total:, sent:, failed:, results: [...] }
32
+ def send_batch(messages)
33
+ @transport.request(:post, "/v1/emails/batch", body: messages.map { |m| serialize_send(m) })
34
+ end
35
+
36
+ # Dry-run a send: check whether +message+ would be accepted without
37
+ # actually sending it. Uses the full send body.
38
+ #
39
+ # @param message [Hash]
40
+ # @return [Hash] { valid:, can_send:, issues:, plan:, usage: }
41
+ def validate(message = {}, **kwargs)
42
+ body = serialize_send(message.empty? ? kwargs : message)
43
+ @transport.request(:post, "/v1/emails/validate", body: body)
44
+ end
45
+
46
+ # List recent emails, newest first.
47
+ #
48
+ # @param status [String, nil]
49
+ # @param page [Integer] zero-based, default 0
50
+ # @param limit [Integer] default 20
51
+ # @return [Array<Hash>]
52
+ def list(status: nil, page: 0, limit: 20)
53
+ @transport.request(:get, "/v1/emails/", query: { status: status, page: page, limit: limit })
54
+ end
55
+
56
+ # Fetch a single email with its bodies and events.
57
+ #
58
+ # @param id [String]
59
+ # @return [Hash]
60
+ def get(id)
61
+ @transport.request(:get, "/v1/emails/#{escape(id)}")
62
+ end
63
+
64
+ # List delivery / open / click / bounce events for an email.
65
+ #
66
+ # @param id [String]
67
+ # @return [Array<Hash>]
68
+ def events(id)
69
+ @transport.request(:get, "/v1/emails/#{escape(id)}/events")
70
+ end
71
+
72
+ # Re-send a bounced, rejected, or failed email as a new message.
73
+ #
74
+ # @param id [String]
75
+ # @return [Hash]
76
+ def retry(id)
77
+ @transport.request(:post, "/v1/emails/#{escape(id)}/retry")
78
+ end
79
+
80
+ # Search emails. +q+ supports inline tokens (to:, from:, status:,
81
+ # domain:, tag:); leftover words are matched as free text.
82
+ #
83
+ # @param q [String, nil]
84
+ # @param status [String, nil]
85
+ # @param tag [String, nil]
86
+ # @param page [Integer] zero-based, default 0
87
+ # @param limit [Integer] default 20
88
+ # @return [Array<Hash>]
89
+ def search(q: nil, status: nil, tag: nil, page: 0, limit: 20)
90
+ @transport.request(:get, "/v1/emails/search",
91
+ query: { q: q, status: status, tag: tag, page: page, limit: limit })
92
+ end
93
+
94
+ # List emails scheduled for future delivery, soonest first.
95
+ #
96
+ # @return [Array<Hash>]
97
+ def list_scheduled
98
+ @transport.request(:get, "/v1/emails/scheduled")
99
+ end
100
+
101
+ # Cancel a scheduled email.
102
+ #
103
+ # @param id [String]
104
+ # @return [Hash] { id:, status: }
105
+ def cancel_scheduled(id)
106
+ @transport.request(:delete, "/v1/emails/scheduled/#{escape(id)}")
107
+ end
108
+
109
+ # Send a scheduled email immediately instead of waiting.
110
+ #
111
+ # @param id [String]
112
+ # @return [Hash] { id:, status: }
113
+ def send_scheduled_now(id)
114
+ @transport.request(:post, "/v1/emails/scheduled/#{escape(id)}/send-now")
115
+ end
116
+
117
+ # Poll for emails whose status changed at or after +since+ (ISO 8601
118
+ # string or a Time). Capped at 50 rows.
119
+ #
120
+ # @param since [String, Time]
121
+ # @return [Array<Hash>]
122
+ def updates(since)
123
+ iso = since.respond_to?(:iso8601) ? since.iso8601 : since
124
+ @transport.request(:get, "/v1/emails/updates", query: { since: iso })
125
+ end
126
+
127
+ # Get the caller's saved searches.
128
+ #
129
+ # @return [Array<Hash>]
130
+ def get_saved_searches
131
+ @transport.request(:get, "/v1/emails/saved-searches")[:searches]
132
+ end
133
+
134
+ # Replace the caller's saved searches (max 50).
135
+ #
136
+ # @param searches [Array<Hash>]
137
+ # @return [Array<Hash>]
138
+ def set_saved_searches(searches)
139
+ @transport.request(:put, "/v1/emails/saved-searches", body: { searches: searches })[:searches]
140
+ end
141
+
142
+ private
143
+
144
+ def escape(value)
145
+ Util.escape(value)
146
+ end
147
+
148
+ # Build the JSON body for a send request. The API names the sender field
149
+ # +from_+ on the wire; this is the single place that mapping happens.
150
+ def serialize_send(params)
151
+ p = Util.symbolize(params)
152
+ send_at = p[:send_at] || p[:sendAt]
153
+ send_at = send_at.iso8601 if send_at.respond_to?(:iso8601)
154
+ reply_to = p[:reply_to] || p[:replyTo]
155
+ Util.prune(
156
+ from_: Util.address(p[:from]),
157
+ to: Util.address_list(p[:to]),
158
+ subject: p[:subject],
159
+ html: p[:html],
160
+ text: p[:text],
161
+ cc: Util.address_list(p[:cc]),
162
+ bcc: Util.address_list(p[:bcc]),
163
+ reply_to: reply_to.nil? ? nil : Util.address(reply_to),
164
+ headers: p[:headers],
165
+ tags: p[:tags],
166
+ send_at: send_at,
167
+ attachments: p[:attachments]
168
+ )
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axene
4
+ module Mailer
5
+ module Resources
6
+ # The +suppressions+ resource: manage the do-not-send list. Accessed as
7
+ # +client.suppressions+.
8
+ class Suppressions
9
+ # @param transport [Axene::Mailer::Transport]
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ # List suppressed addresses (paginated envelope; zero-based +page+).
15
+ #
16
+ # @param page [Integer] default 0
17
+ # @param limit [Integer] default 50
18
+ # @param search [String, nil]
19
+ # @return [Hash] { items:, total:, page:, limit: }
20
+ def list(page: 0, limit: 50, search: nil)
21
+ @transport.request(:get, "/v1/suppressions", query: { page: page, limit: limit, search: search })
22
+ end
23
+
24
+ # Suppress a single address. The +email+ argument maps to the wire field
25
+ # +email_address+.
26
+ #
27
+ # @param email [String]
28
+ # @param reason [String] default "manual"
29
+ # @return [Hash]
30
+ def add(email:, reason: "manual")
31
+ @transport.request(:post, "/v1/suppressions", body: { email_address: email, reason: reason })
32
+ end
33
+
34
+ # Bulk-import suppressions from a file (one email per line). +file+ may be
35
+ # raw bytes (a String) or a path to a readable file.
36
+ #
37
+ # @param file [String] raw bytes or a file path
38
+ # @param filename [String]
39
+ # @return [Hash] { added:, skipped:, total_processed: }
40
+ def bulk_upload(file, filename: "suppressions.txt")
41
+ bytes = read_file(file)
42
+ @transport.upload("/v1/suppressions/bulk", bytes, filename)
43
+ end
44
+
45
+ # Remove an address from the suppression list.
46
+ #
47
+ # @param id [String]
48
+ # @return [nil]
49
+ def remove(id)
50
+ @transport.request(:delete, "/v1/suppressions/#{Util.escape(id)}")
51
+ end
52
+
53
+ private
54
+
55
+ def read_file(file)
56
+ if file.is_a?(String) && file.length < 4096 && !file.include?("\n") && File.file?(file)
57
+ File.binread(file)
58
+ else
59
+ file
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axene
4
+ module Mailer
5
+ module Resources
6
+ # The +templates+ resource: reusable email templates (Starter plan and up).
7
+ # Accessed as +client.templates+.
8
+ #
9
+ # Note the wire mapping for this resource: +html+ maps to +html_body+ and
10
+ # +text+ maps to +text_body+ (the emails resource keeps +html+/+text+).
11
+ class Templates
12
+ # @param transport [Axene::Mailer::Transport]
13
+ def initialize(transport)
14
+ @transport = transport
15
+ end
16
+
17
+ # List all templates, most recently updated first.
18
+ #
19
+ # @return [Array<Hash>]
20
+ def list
21
+ @transport.request(:get, "/v1/templates/")
22
+ end
23
+
24
+ # Create a template. +variables+ are derived server-side from {{name}}
25
+ # placeholders in the bodies, so you do not pass them.
26
+ #
27
+ # @param name [String]
28
+ # @param subject [String, nil]
29
+ # @param html [String, nil] maps to html_body
30
+ # @param text [String, nil] maps to text_body
31
+ # @param blocks_json [Hash, nil]
32
+ # @return [Hash]
33
+ def create(name:, subject: nil, html: nil, text: nil, blocks_json: nil)
34
+ @transport.request(:post, "/v1/templates/", body: serialize(name, subject, html, text, blocks_json))
35
+ end
36
+
37
+ # Fetch a single template.
38
+ #
39
+ # @param id [String]
40
+ # @return [Hash]
41
+ def get(id)
42
+ @transport.request(:get, "/v1/templates/#{Util.escape(id)}")
43
+ end
44
+
45
+ # Update a template (partial).
46
+ #
47
+ # @param id [String]
48
+ # @param name [String, nil]
49
+ # @param subject [String, nil]
50
+ # @param html [String, nil] maps to html_body
51
+ # @param text [String, nil] maps to text_body
52
+ # @param blocks_json [Hash, nil]
53
+ # @return [Hash]
54
+ def update(id, name: nil, subject: nil, html: nil, text: nil, blocks_json: nil)
55
+ @transport.request(:patch, "/v1/templates/#{Util.escape(id)}",
56
+ body: serialize(name, subject, html, text, blocks_json))
57
+ end
58
+
59
+ # Delete a template.
60
+ #
61
+ # @param id [String]
62
+ # @return [nil]
63
+ def delete(id)
64
+ @transport.request(:delete, "/v1/templates/#{Util.escape(id)}")
65
+ end
66
+
67
+ # Duplicate a template (the copy's +blocks_json+ is not carried over).
68
+ #
69
+ # @param id [String]
70
+ # @return [Hash]
71
+ def duplicate(id)
72
+ @transport.request(:post, "/v1/templates/#{Util.escape(id)}/duplicate")
73
+ end
74
+
75
+ private
76
+
77
+ def serialize(name, subject, html, text, blocks_json)
78
+ Util.prune(
79
+ name: name,
80
+ subject: subject,
81
+ html_body: html,
82
+ text_body: text,
83
+ blocks_json: blocks_json
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axene
4
+ module Mailer
5
+ module Resources
6
+ # The +webhooks+ resource: manage event subscriptions and inspect
7
+ # deliveries. Accessed as +client.webhooks+.
8
+ class Webhooks
9
+ # @param transport [Axene::Mailer::Transport]
10
+ def initialize(transport)
11
+ @transport = transport
12
+ end
13
+
14
+ # List your active webhooks.
15
+ #
16
+ # @return [Array<Hash>]
17
+ def list
18
+ @transport.request(:get, "/v1/webhooks/")
19
+ end
20
+
21
+ # Create a webhook. The signing +secret+ is generated and returned.
22
+ #
23
+ # @param url [String]
24
+ # @param events [Array<String>]
25
+ # @return [Hash]
26
+ def create(url:, events:)
27
+ @transport.request(:post, "/v1/webhooks/", body: { url: url, events: events })
28
+ end
29
+
30
+ # Update a webhook's url, events, or active state (partial). The
31
+ # +is_active+ argument maps to the wire field +is_active+.
32
+ #
33
+ # @param id [String]
34
+ # @param url [String, nil]
35
+ # @param events [Array<String>, nil]
36
+ # @param is_active [Boolean, nil]
37
+ # @return [Hash]
38
+ def update(id, url: nil, events: nil, is_active: nil)
39
+ @transport.request(:patch, "/v1/webhooks/#{Util.escape(id)}",
40
+ body: Util.prune(url: url, events: events, is_active: is_active))
41
+ end
42
+
43
+ # Delete a webhook.
44
+ #
45
+ # @param id [String]
46
+ # @return [nil]
47
+ def delete(id)
48
+ @transport.request(:delete, "/v1/webhooks/#{Util.escape(id)}")
49
+ end
50
+
51
+ # Queue a sample email.delivered delivery to test the endpoint.
52
+ #
53
+ # @param id [String]
54
+ # @return [Hash] { queued:, url: }
55
+ def test(id)
56
+ @transport.request(:post, "/v1/webhooks/#{Util.escape(id)}/test")
57
+ end
58
+
59
+ # List delivery attempts for a webhook (paginated envelope).
60
+ #
61
+ # @param id [String]
62
+ # @param page [Integer] default 0
63
+ # @param limit [Integer] default 20
64
+ # @param status [String, nil]
65
+ # @return [Hash] { items:, total:, page:, limit: }
66
+ def list_deliveries(id, page: 0, limit: 20, status: nil)
67
+ @transport.request(:get, "/v1/webhooks/#{Util.escape(id)}/deliveries",
68
+ query: { page: page, limit: limit, status: status })
69
+ end
70
+
71
+ # Fetch one delivery with its full payload and the endpoint's response.
72
+ #
73
+ # @param id [String]
74
+ # @param delivery_id [String]
75
+ # @return [Hash]
76
+ def get_delivery(id, delivery_id)
77
+ @transport.request(:get, "/v1/webhooks/#{Util.escape(id)}/deliveries/#{Util.escape(delivery_id)}")
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ require_relative "error"
9
+
10
+ module Axene
11
+ module Mailer
12
+ # HTTP transport: the single place that talks to the network. Owns bearer
13
+ # authentication, JSON encode/decode, timeouts, retries with backoff, and
14
+ # turning non-2xx responses into {Axene::Mailer::Error}. Resources depend on
15
+ # this, not on Net::HTTP directly.
16
+ class Transport
17
+ DEFAULT_BASE_URL = "https://mail.axene.io"
18
+ USER_AGENT = "axene-mailer-ruby/#{VERSION}"
19
+
20
+ # @param api_key [String] required; starts with "axm_k_"
21
+ # @param base_url [String] default "https://mail.axene.io"
22
+ # @param max_retries [Integer] retry attempts for 429/5xx, default 3
23
+ # @param timeout [Numeric] per-request timeout in seconds, default 30
24
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, max_retries: 3, timeout: 30)
25
+ raise ArgumentError, "Axene::Mailer: `api_key` is required." if api_key.nil? || api_key.empty?
26
+
27
+ @api_key = api_key
28
+ @base_url = base_url.sub(%r{/+\z}, "")
29
+ @max_retries = max_retries
30
+ @timeout = timeout
31
+ end
32
+
33
+ # Perform a JSON request and return the parsed body (symbolized keys).
34
+ #
35
+ # Retries 429 and 5xx with exponential backoff, honoring Retry-After when
36
+ # present. Raises {Axene::Mailer::Error} on a final non-2xx or a transport
37
+ # failure that survives every attempt.
38
+ #
39
+ # @param method [Symbol] :get, :post, :patch, :put, :delete
40
+ # @param path [String] path beginning with "/"
41
+ # @param body [Object, nil] request body, JSON-encoded when present
42
+ # @param query [Hash, nil] query parameters (nil values dropped)
43
+ # @return [Hash, Array, nil]
44
+ def request(method, path, body: nil, query: nil)
45
+ uri = build_uri(path, query)
46
+ last_error = nil
47
+
48
+ (1..@max_retries).each do |attempt|
49
+ req = build_request(method, uri)
50
+ unless body.nil?
51
+ req["Content-Type"] = "application/json"
52
+ req.body = JSON.generate(body)
53
+ end
54
+
55
+ begin
56
+ res = http(uri).request(req)
57
+ rescue StandardError => e
58
+ last_error = e
59
+ sleep(backoff_seconds(nil, attempt)) if attempt < @max_retries
60
+ next
61
+ end
62
+
63
+ status = res.code.to_i
64
+ if retryable?(status) && attempt < @max_retries
65
+ sleep(backoff_seconds(res, attempt))
66
+ next
67
+ end
68
+
69
+ payload = parse_body(res)
70
+ raise to_error(status, payload) unless status.between?(200, 299)
71
+
72
+ return payload
73
+ end
74
+
75
+ raise Error.new(0, "Axene::Mailer request failed: #{last_error}")
76
+ end
77
+
78
+ # Upload a single file as multipart/form-data under the field name "file".
79
+ # Used by the CSV/suppression import endpoints. Not retried (uploads are
80
+ # not idempotent).
81
+ #
82
+ # @param path [String]
83
+ # @param file_bytes [String] raw file contents
84
+ # @param filename [String]
85
+ # @return [Hash, Array, nil]
86
+ def upload(path, file_bytes, filename)
87
+ uri = build_uri(path, nil)
88
+ boundary = "AxeneBoundary#{SecureRandom.hex(16)}"
89
+ req = build_request(:post, uri)
90
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
91
+ req.body = multipart_body(boundary, file_bytes, filename)
92
+
93
+ res = http(uri).request(req)
94
+ status = res.code.to_i
95
+ payload = parse_body(res)
96
+ raise to_error(status, payload) unless status.between?(200, 299)
97
+
98
+ payload
99
+ end
100
+
101
+ private
102
+
103
+ def build_uri(path, query)
104
+ uri = URI.parse("#{@base_url}#{path}")
105
+ if query && !query.empty?
106
+ pairs = query.reject { |_, v| v.nil? }.map { |k, v| [k.to_s, v.to_s] }
107
+ uri.query = URI.encode_www_form(pairs) unless pairs.empty?
108
+ end
109
+ uri
110
+ end
111
+
112
+ def build_request(method, uri)
113
+ klass = {
114
+ get: Net::HTTP::Get,
115
+ post: Net::HTTP::Post,
116
+ patch: Net::HTTP::Patch,
117
+ put: Net::HTTP::Put,
118
+ delete: Net::HTTP::Delete
119
+ }.fetch(method)
120
+ req = klass.new(uri)
121
+ req["Authorization"] = "Bearer #{@api_key}"
122
+ req["Accept"] = "application/json"
123
+ req["User-Agent"] = USER_AGENT
124
+ req
125
+ end
126
+
127
+ def http(uri)
128
+ h = Net::HTTP.new(uri.host, uri.port)
129
+ h.use_ssl = uri.scheme == "https"
130
+ h.open_timeout = @timeout
131
+ h.read_timeout = @timeout
132
+ h
133
+ end
134
+
135
+ def multipart_body(boundary, file_bytes, filename)
136
+ safe_name = filename.to_s.gsub('"', "")
137
+ [
138
+ "--#{boundary}\r\n",
139
+ %(Content-Disposition: form-data; name="file"; filename="#{safe_name}"\r\n),
140
+ "Content-Type: application/octet-stream\r\n\r\n",
141
+ file_bytes.dup.force_encoding(Encoding::ASCII_8BIT),
142
+ "\r\n--#{boundary}--\r\n"
143
+ ].join
144
+ end
145
+
146
+ def retryable?(status)
147
+ status == 429 || status >= 500
148
+ end
149
+
150
+ def backoff_seconds(res, attempt)
151
+ if res
152
+ retry_after = res["retry-after"].to_f
153
+ return retry_after if retry_after.positive?
154
+ end
155
+ 0.25 * (2**(attempt - 1))
156
+ end
157
+
158
+ def parse_body(res)
159
+ ctype = res["content-type"].to_s
160
+ return nil unless ctype.include?("application/json")
161
+
162
+ raw = res.body
163
+ return nil if raw.nil? || raw.empty?
164
+
165
+ JSON.parse(raw, symbolize_names: true)
166
+ rescue JSON::ParserError
167
+ nil
168
+ end
169
+
170
+ # Map the API's { detail: { code, message } } (or a string detail) into an
171
+ # {Axene::Mailer::Error}.
172
+ def to_error(status, payload)
173
+ detail = payload.is_a?(Hash) ? payload[:detail] : nil
174
+ code = detail.is_a?(Hash) ? detail[:code] : nil
175
+ message =
176
+ if detail.is_a?(Hash)
177
+ detail[:message]
178
+ elsif detail.is_a?(String)
179
+ detail
180
+ end
181
+ message ||= "Axene::Mailer request failed (#{status})"
182
+ Error.new(status, message, code, payload)
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Axene
6
+ module Mailer
7
+ # Internal helpers that translate the SDK's ergonomic inputs into the exact
8
+ # JSON shape the API expects. Not part of the public API.
9
+ module Util
10
+ module_function
11
+
12
+ # Drop keys whose value is nil so they are omitted from the JSON body.
13
+ #
14
+ # @param hash [Hash]
15
+ # @return [Hash]
16
+ def prune(hash)
17
+ hash.reject { |_, v| v.nil? }
18
+ end
19
+
20
+ # Normalize a single address. A bare String becomes { email: ... }.
21
+ #
22
+ # @param addr [String, Hash, nil]
23
+ # @return [Hash, nil]
24
+ def address(addr)
25
+ return nil if addr.nil?
26
+ return { email: addr } if addr.is_a?(String)
27
+
28
+ symbolize(addr)
29
+ end
30
+
31
+ # Normalize one-or-many addresses into an array, or nil if absent.
32
+ #
33
+ # @param addr [String, Hash, Array, nil]
34
+ # @return [Array<Hash>, nil]
35
+ def address_list(addr)
36
+ return nil if addr.nil?
37
+
38
+ list = addr.is_a?(Array) ? addr : [addr]
39
+ list.map { |a| address(a) }
40
+ end
41
+
42
+ # Shallow-symbolize the keys of a Hash so callers may pass either string
43
+ # or symbol keys.
44
+ #
45
+ # @param hash [Hash]
46
+ # @return [Hash]
47
+ def symbolize(hash)
48
+ return hash unless hash.is_a?(Hash)
49
+
50
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
51
+ end
52
+
53
+ # URL-escape a path segment.
54
+ #
55
+ # @param value [#to_s]
56
+ # @return [String]
57
+ def escape(value)
58
+ URI.encode_www_form_component(value.to_s)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axene
4
+ module Mailer
5
+ # The gem version. Bumped on release; tags use the `ruby-v*` prefix.
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mailer/version"
4
+ require_relative "mailer/error"
5
+ require_relative "mailer/util"
6
+ require_relative "mailer/transport"
7
+ require_relative "mailer/client"
8
+
9
+ # Axene Solutions namespace.
10
+ module Axene
11
+ # Ruby SDK for the Axene Mailer API (https://mail.axene.io).
12
+ #
13
+ # @example
14
+ # require "axene/mailer"
15
+ # client = Axene::Mailer::Client.new(api_key: ENV.fetch("AXENE_API_KEY"))
16
+ # client.emails.send(from: "hi@you.io", to: "x@example.com", subject: "Hi")
17
+ module Mailer
18
+ end
19
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Convenience entry point matching the gem name `axene-mailer`.
4
+ require_relative "axene/mailer"
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: axene-mailer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Axene Solutions
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Send email, manage domains, contacts, suppressions, templates, and webhooks
42
+ through the Axene Mailer API. Zero runtime dependencies.
43
+ email:
44
+ - engineering@axene.io
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE
50
+ - README.md
51
+ - lib/axene-mailer.rb
52
+ - lib/axene/mailer.rb
53
+ - lib/axene/mailer/client.rb
54
+ - lib/axene/mailer/error.rb
55
+ - lib/axene/mailer/resources/contacts.rb
56
+ - lib/axene/mailer/resources/domains.rb
57
+ - lib/axene/mailer/resources/emails.rb
58
+ - lib/axene/mailer/resources/suppressions.rb
59
+ - lib/axene/mailer/resources/templates.rb
60
+ - lib/axene/mailer/resources/webhooks.rb
61
+ - lib/axene/mailer/transport.rb
62
+ - lib/axene/mailer/util.rb
63
+ - lib/axene/mailer/version.rb
64
+ homepage: https://axene.io
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ homepage_uri: https://axene.io
69
+ source_code_uri: https://github.com/axene-io/axene-sdks
70
+ documentation_uri: https://docs.axene.io
71
+ rubygems_mfa_required: 'true'
72
+ post_install_message:
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.0'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.5.22
88
+ signing_key:
89
+ specification_version: 4
90
+ summary: Ruby SDK for the Axene Mailer API.
91
+ test_files: []