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 +7 -0
- data/LICENSE +21 -0
- data/README.md +125 -0
- data/lib/axene/mailer/client.rb +53 -0
- data/lib/axene/mailer/error.rb +33 -0
- data/lib/axene/mailer/resources/contacts.rb +134 -0
- data/lib/axene/mailer/resources/domains.rb +122 -0
- data/lib/axene/mailer/resources/emails.rb +173 -0
- data/lib/axene/mailer/resources/suppressions.rb +65 -0
- data/lib/axene/mailer/resources/templates.rb +89 -0
- data/lib/axene/mailer/resources/webhooks.rb +82 -0
- data/lib/axene/mailer/transport.rb +186 -0
- data/lib/axene/mailer/util.rb +62 -0
- data/lib/axene/mailer/version.rb +8 -0
- data/lib/axene/mailer.rb +19 -0
- data/lib/axene-mailer.rb +4 -0
- metadata +91 -0
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
|
data/lib/axene/mailer.rb
ADDED
|
@@ -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
|
data/lib/axene-mailer.rb
ADDED
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: []
|