sendclipper 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/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +206 -0
- data/lib/sendclipper/client.rb +41 -0
- data/lib/sendclipper/errors.rb +77 -0
- data/lib/sendclipper/http_client.rb +259 -0
- data/lib/sendclipper/resources/audience.rb +105 -0
- data/lib/sendclipper/resources/base.rb +85 -0
- data/lib/sendclipper/resources/domains.rb +35 -0
- data/lib/sendclipper/resources/emails.rb +62 -0
- data/lib/sendclipper/resources/mailboxes.rb +63 -0
- data/lib/sendclipper/resources/sandboxes.rb +99 -0
- data/lib/sendclipper/resources/templates.rb +92 -0
- data/lib/sendclipper/resources/webhooks.rb +70 -0
- data/lib/sendclipper/resources/workspace.rb +22 -0
- data/lib/sendclipper/result.rb +50 -0
- data/lib/sendclipper/version.rb +6 -0
- data/lib/sendclipper/webhook.rb +114 -0
- data/lib/sendclipper.rb +30 -0
- metadata +66 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SendClipper
|
|
6
|
+
module Resources
|
|
7
|
+
class Contacts < Base
|
|
8
|
+
# Fields: audience_id, status, page, page_size.
|
|
9
|
+
def list(params = nil, **kw)
|
|
10
|
+
query, options = split(params, kw)
|
|
11
|
+
api_get("/contacts", query: query, options: options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Every contact, page after page (an Enumerator).
|
|
15
|
+
def each(params = nil, **kw, &block)
|
|
16
|
+
query, options = split(params, kw)
|
|
17
|
+
size = (query["pageSize"] || 200).to_i
|
|
18
|
+
stop = ->(res, page, _size, _items) { page >= res["totalPages"].to_i }
|
|
19
|
+
enum = Paging.numbered(size, "items", stop) do |page, page_size|
|
|
20
|
+
api_get("/contacts", query: query.merge("page" => page, "pageSize" => page_size), options: options)
|
|
21
|
+
end
|
|
22
|
+
block ? enum.each(&block) : enum
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Fields: name, email, audience_id, status.
|
|
26
|
+
def create(params = nil, **kw)
|
|
27
|
+
body, options = split(params, kw)
|
|
28
|
+
api_post("/contacts", body, options: options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Import a CSV or vCard file. Importing never suppresses anyone.
|
|
32
|
+
#
|
|
33
|
+
# client.contacts.import(file: File.read("contacts.csv"), filename: "contacts.csv", on_duplicate: "update")
|
|
34
|
+
#
|
|
35
|
+
# The filename's extension picks the parser. Other fields: audience_id,
|
|
36
|
+
# new_audience, create_audiences, mapping.
|
|
37
|
+
def import(file:, filename: "contacts.csv", **kw)
|
|
38
|
+
fields, options = split(nil, kw)
|
|
39
|
+
@http.call("POST", "/contacts/import", form: import_form(file, filename, fields), options: options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# What an import would do, without importing.
|
|
43
|
+
def import_preview(file:, filename: "contacts.csv", **options)
|
|
44
|
+
@http.call("POST", "/contacts/import/preview", form: import_form(file, filename, {}), options: options)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Every contact as CSV (re-importable) or vCard 4.0 (format: "vcf").
|
|
48
|
+
def export(params = nil, **kw)
|
|
49
|
+
query, options = split(params, kw)
|
|
50
|
+
@http.call("GET", "/contacts/export", query: query, as: :text, options: options)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def import_form(file, filename, fields)
|
|
56
|
+
type = filename.to_s.downcase.end_with?(".vcf") ? "text/vcard" : "text/csv"
|
|
57
|
+
parts = fields.filter_map do |k, v|
|
|
58
|
+
next if v.nil? || v == ""
|
|
59
|
+
|
|
60
|
+
[k, k == "mapping" ? JSON.generate(v) : v.to_s]
|
|
61
|
+
end
|
|
62
|
+
HttpClient.multipart(parts, [["file", filename.to_s, type, file]])
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class Audiences < Base
|
|
67
|
+
# { "audiences" => [...], "totalContacts" => n }.
|
|
68
|
+
def list(**options) = api_get("/audiences", options: options)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class Suppressions < Base
|
|
72
|
+
# Fields: q (address search), reason, cursor, limit.
|
|
73
|
+
def list(params = nil, **kw)
|
|
74
|
+
query, options = split(params, kw)
|
|
75
|
+
api_get("/suppressions", query: query, options: options)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def each(params = nil, **kw, &block)
|
|
79
|
+
query, options = split(params, kw)
|
|
80
|
+
query.delete("cursor")
|
|
81
|
+
enum = Paging.cursor("data") { |c| api_get("/suppressions", query: query.merge("cursor" => c), options: options) }
|
|
82
|
+
block ? enum.each(&block) : enum
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Stop all mail to an address (reason +manual+). Fields: email, note.
|
|
86
|
+
# "created" is false when the address was already suppressed; "row" is
|
|
87
|
+
# the entry either way.
|
|
88
|
+
def create(params = nil, **kw)
|
|
89
|
+
body, options = split(params, kw)
|
|
90
|
+
api_post("/suppressions", body, options: options)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Lift a suppression. +reason+ (10+ characters) is recorded. Complaint and
|
|
94
|
+
# provider suppressions cannot be removed (+suppression_not_removable+).
|
|
95
|
+
def remove(id, reason, **options) = api_delete("/suppressions/#{seg(id)}", { "reason" => reason }, options: options)
|
|
96
|
+
|
|
97
|
+
# The list as CSV. Fields: q, reason.
|
|
98
|
+
def export(params = nil, **kw)
|
|
99
|
+
query, options = split(params, kw)
|
|
100
|
+
query = query.reject { |k, _| %w[cursor limit].include?(k) }
|
|
101
|
+
@http.call("GET", "/suppressions/export", query: query, as: :text, options: options)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendClipper
|
|
4
|
+
module Resources
|
|
5
|
+
# Every method takes its fields as a Hash or as keyword arguments, and the
|
|
6
|
+
# request options +idempotency_key:+, +timeout:+ (seconds), +max_retries:+
|
|
7
|
+
# and +headers:+ alongside them.
|
|
8
|
+
#
|
|
9
|
+
# Top-level field names may be the API's own (+templateId+) or snake_case
|
|
10
|
+
# (+template_id+); snake_case is turned into the API's camelCase. Values,
|
|
11
|
+
# such as the +variables+ map, are sent exactly as given.
|
|
12
|
+
class Base
|
|
13
|
+
def initialize(http)
|
|
14
|
+
@http = http
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def seg(value) = HttpClient.seg(value)
|
|
20
|
+
|
|
21
|
+
# Split keyword arguments into [fields, options].
|
|
22
|
+
def split(params, kw)
|
|
23
|
+
options = kw.slice(*HttpClient::OPTION_KEYS)
|
|
24
|
+
fields = (params || {}).to_h.merge(kw.reject { |k, _| HttpClient::OPTION_KEYS.include?(k) })
|
|
25
|
+
[camelize(fields), options]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def camelize(fields)
|
|
29
|
+
fields.each_with_object({}) do |(k, v), out|
|
|
30
|
+
next if v.nil?
|
|
31
|
+
|
|
32
|
+
out[k.to_s.gsub(/_([a-z0-9])/) { Regexp.last_match(1).upcase }] = v
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def api_get(path, query: nil, as: :json, options: {})
|
|
37
|
+
@http.call("GET", path, query: query, as: as, options: options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def api_post(path, body = {}, options: {}, bearer: nil)
|
|
41
|
+
@http.call("POST", path, body: body, bearer: bearer, options: options)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def api_patch(path, body, options: {}) = @http.call("PATCH", path, body: body, options: options)
|
|
45
|
+
|
|
46
|
+
def api_put(path, body, options: {}) = @http.call("PUT", path, body: body, options: options)
|
|
47
|
+
|
|
48
|
+
def api_delete(path, body = nil, options: {}) = @http.call("DELETE", path, body: body, options: options)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Iterators over a numbered or a cursor list. They raise ApiError on a
|
|
52
|
+
# refusal, since a loop has no +error+ to hand back.
|
|
53
|
+
module Paging
|
|
54
|
+
module_function
|
|
55
|
+
|
|
56
|
+
def numbered(page_size, items_key, stop)
|
|
57
|
+
Enumerator.new do |y|
|
|
58
|
+
page = 1
|
|
59
|
+
loop do
|
|
60
|
+
res = yield(page, page_size).unwrap
|
|
61
|
+
items = res[items_key] || []
|
|
62
|
+
items.each { |item| y << item }
|
|
63
|
+
break if items.empty? || stop.call(res, page, page_size, items)
|
|
64
|
+
|
|
65
|
+
page += 1
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def cursor(items_key)
|
|
71
|
+
Enumerator.new do |y|
|
|
72
|
+
cursor = nil
|
|
73
|
+
loop do
|
|
74
|
+
res = yield(cursor).unwrap
|
|
75
|
+
(res[items_key] || []).each { |item| y << item }
|
|
76
|
+
info = res["page"] || {}
|
|
77
|
+
break unless info["hasMore"] && info["cursor"].to_s != ""
|
|
78
|
+
|
|
79
|
+
cursor = info["cursor"]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendClipper
|
|
4
|
+
module Resources
|
|
5
|
+
class Domains < Base
|
|
6
|
+
def list(**options) = api_get("/domains", options: options)
|
|
7
|
+
|
|
8
|
+
# Register a sending domain (name:, region: "us-east-1" | "eu-west-1" | "us-west-2").
|
|
9
|
+
# Next: #records, publish them, then #verify.
|
|
10
|
+
def create(params = nil, **kw)
|
|
11
|
+
body, options = split(params, kw)
|
|
12
|
+
api_post("/domains", body, options: options)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The DNS records to publish, each with its current status.
|
|
16
|
+
def records(id, **options) = api_get("/domains/#{seg(id)}/records", options: options)
|
|
17
|
+
|
|
18
|
+
# Check DNS now instead of waiting for the background check.
|
|
19
|
+
def verify(id, **options) = api_post("/domains/#{seg(id)}/verify", nil, options: options)
|
|
20
|
+
|
|
21
|
+
# Start receiving mail on this domain. Adds receiving records; never blocks sending.
|
|
22
|
+
def enable_receiving(id, **options) = api_post("/domains/#{seg(id)}/receiving", nil, options: options)
|
|
23
|
+
|
|
24
|
+
def disable_receiving(id, **options) = api_delete("/domains/#{seg(id)}/receiving", options: options)
|
|
25
|
+
|
|
26
|
+
# Move a domain to another region. Refused (+region_immutable+) once it holds messages.
|
|
27
|
+
def set_region(id, region, **options) = api_patch("/domains/#{seg(id)}/region", { "region" => region }, options: options)
|
|
28
|
+
|
|
29
|
+
# What deleting the domain would take with it.
|
|
30
|
+
def deletion_preview(id, **options) = api_get("/domains/#{seg(id)}/deletion", options: options)
|
|
31
|
+
|
|
32
|
+
def remove(id, **options) = api_delete("/domains/#{seg(id)}", options: options)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendClipper
|
|
4
|
+
module Resources
|
|
5
|
+
class Emails < Base
|
|
6
|
+
# Send one message. Returns once SendClipper accepted it (status +queued+);
|
|
7
|
+
# delivery is reported later through #get and webhooks.
|
|
8
|
+
#
|
|
9
|
+
# client.emails.send(from: "hello@send.acme.io", to: "ada@lumen.io", subject: "Hi", html: "<p>Hi</p>")
|
|
10
|
+
#
|
|
11
|
+
# Every call carries an Idempotency-Key, so the SDK's own retries never
|
|
12
|
+
# send twice. Pass +idempotency_key:+ to make YOUR retries safe too.
|
|
13
|
+
def send(params = nil, **kw)
|
|
14
|
+
body, options = split(params, kw)
|
|
15
|
+
api_post("/emails", body, options: options)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Same as client.batch.send.
|
|
19
|
+
def send_batch(emails, **options) = Batch.post(@http, emails, options)
|
|
20
|
+
|
|
21
|
+
# One message with its body and event timeline.
|
|
22
|
+
def get(id, **options) = api_get("/emails/#{seg(id)}", options: options)
|
|
23
|
+
|
|
24
|
+
# One page of the sent-mail log, newest first. Fields: search, status,
|
|
25
|
+
# domain, broadcast_id, from, to, page, page_size.
|
|
26
|
+
def list(params = nil, **kw)
|
|
27
|
+
query, options = split(params, kw)
|
|
28
|
+
get_list("/emails", query, options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Every message matching the fields, page after page (an Enumerator).
|
|
32
|
+
def each(params = nil, **kw, &block)
|
|
33
|
+
query, options = split(params, kw)
|
|
34
|
+
size = (query["pageSize"] || 100).to_i
|
|
35
|
+
stop = ->(res, page, page_size, items) { items.length < page_size || page * page_size >= res["total"].to_i }
|
|
36
|
+
enum = Paging.numbered(size, "items", stop) do |page, page_size|
|
|
37
|
+
get_list("/emails", query.merge("page" => page, "pageSize" => page_size), options)
|
|
38
|
+
end
|
|
39
|
+
block ? enum.each(&block) : enum
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Counts per status.
|
|
43
|
+
def stats(**options) = get_list("/emails/stats/summary", nil, options)
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def get_list(path, query, options) = @http.call("GET", path, query: query, options: options)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class Batch < Base
|
|
51
|
+
# Up to 500 messages in one request (one recipient each). Every item is
|
|
52
|
+
# accepted or refused on its own; a refused item costs its siblings nothing.
|
|
53
|
+
def send(emails, **options) = self.class.post(@http, emails, options)
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
def self.post(http, emails, options)
|
|
57
|
+
items = emails.map { |e| new(http).__send__(:camelize, e.to_h) }
|
|
58
|
+
http.call("POST", "/emails/batch", body: { "emails" => items }, options: options)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendClipper
|
|
4
|
+
module Resources
|
|
5
|
+
class Mailboxes < Base
|
|
6
|
+
attr_reader :messages
|
|
7
|
+
|
|
8
|
+
def initialize(http)
|
|
9
|
+
super
|
|
10
|
+
@messages = MailboxMessages.new(http)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Fields: include_archived.
|
|
14
|
+
def list(params = nil, **kw)
|
|
15
|
+
query, options = split(params, kw)
|
|
16
|
+
api_get("/mailboxes", query: query, options: options)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get(id, **options) = api_get("/mailboxes/#{seg(id)}", options: options)
|
|
20
|
+
|
|
21
|
+
# A new address on a receiving domain. Fields: domain_id, local_part,
|
|
22
|
+
# display_name, color, catch_all.
|
|
23
|
+
def create(params = nil, **kw)
|
|
24
|
+
body, options = split(params, kw)
|
|
25
|
+
api_post("/mailboxes", body, options: options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def update(id, params = nil, **kw)
|
|
29
|
+
body, options = split(params, kw)
|
|
30
|
+
api_patch("/mailboxes/#{seg(id)}", body, options: options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Archives the address. Its mail is kept.
|
|
34
|
+
def archive(id, **options) = api_delete("/mailboxes/#{seg(id)}", options: options)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class MailboxMessages < Base
|
|
38
|
+
# Mail filed under one address. Reading never marks anything read.
|
|
39
|
+
# Fields: folder, search, label, limit, cursor.
|
|
40
|
+
def list(mailbox_id, params = nil, **kw)
|
|
41
|
+
query, options = split(params, kw)
|
|
42
|
+
api_get("/mailboxes/#{seg(mailbox_id)}/messages", query: query, options: options)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def each(mailbox_id, params = nil, **kw, &block)
|
|
46
|
+
query, options = split(params, kw)
|
|
47
|
+
query.delete("cursor")
|
|
48
|
+
enum = Paging.cursor("items") do |c|
|
|
49
|
+
api_get("/mailboxes/#{seg(mailbox_id)}/messages", query: query.merge("cursor" => c), options: options)
|
|
50
|
+
end
|
|
51
|
+
block ? enum.each(&block) : enum
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def get(mailbox_id, message_id, **options) = api_get("/mailboxes/#{seg(mailbox_id)}/messages/#{seg(message_id)}", options: options)
|
|
55
|
+
|
|
56
|
+
# Download one attachment: a DownloadedFile with content, content_type and filename.
|
|
57
|
+
def attachment(mailbox_id, message_id, attachment_id, **options)
|
|
58
|
+
api_get("/mailboxes/#{seg(mailbox_id)}/messages/#{seg(message_id)}/parts/#{seg(attachment_id)}",
|
|
59
|
+
query: { "download" => 1 }, as: :bytes, options: options)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module SendClipper
|
|
6
|
+
module Resources
|
|
7
|
+
class Sandboxes < Base
|
|
8
|
+
attr_reader :messages
|
|
9
|
+
|
|
10
|
+
def initialize(http)
|
|
11
|
+
super
|
|
12
|
+
@messages = SandboxMessages.new(http)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def list(**options) = api_get("/sandboxes", options: options)
|
|
16
|
+
|
|
17
|
+
def get(id, **options) = api_get("/sandboxes/#{seg(id)}", options: options)
|
|
18
|
+
|
|
19
|
+
# Fields: name, project_label, color (#rrggbb).
|
|
20
|
+
def create(params = nil, **kw)
|
|
21
|
+
body, options = split(params, kw)
|
|
22
|
+
api_post("/sandboxes", body, options: options)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Renaming never changes the address or the credentials.
|
|
26
|
+
def update(id, params = nil, **kw)
|
|
27
|
+
body, options = split(params, kw)
|
|
28
|
+
api_patch("/sandboxes/#{seg(id)}", body, options: options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Deletes the sandbox and every message in it.
|
|
32
|
+
def remove(id, **options) = api_delete("/sandboxes/#{seg(id)}", options: options)
|
|
33
|
+
|
|
34
|
+
# SMTP host, port, username and password to point a project at.
|
|
35
|
+
def credentials(id, **options) = api_get("/sandboxes/#{seg(id)}/credentials", options: options)
|
|
36
|
+
|
|
37
|
+
# Replace BOTH the SMTP password and the +sc_sbx_+ token. The token is shown only here.
|
|
38
|
+
def reset_credentials(id, **options) = api_post("/sandboxes/#{seg(id)}/credentials", nil, options: options)
|
|
39
|
+
|
|
40
|
+
# Send to a sandbox through its JSON door, with the sandbox's own
|
|
41
|
+
# +sc_sbx_...+ token. Same fields as emails.send; nothing is delivered.
|
|
42
|
+
def send(sandbox_token, params = nil, **kw)
|
|
43
|
+
body, options = split(params, kw)
|
|
44
|
+
api_post("/sandbox/emails", body, options: options, bearer: sandbox_token)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class SandboxMessages < Base
|
|
49
|
+
# Fields: search, limit.
|
|
50
|
+
def list(sandbox_id, params = nil, **kw)
|
|
51
|
+
query, options = split(params, kw)
|
|
52
|
+
api_get("/sandboxes/#{seg(sandbox_id)}/messages", query: query, options: options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def get(sandbox_id, message_id, **options) = api_get("/sandboxes/#{seg(sandbox_id)}/messages/#{seg(message_id)}", options: options)
|
|
56
|
+
|
|
57
|
+
# The message's raw MIME source.
|
|
58
|
+
def raw(sandbox_id, message_id, **options)
|
|
59
|
+
@http.call("GET", "/sandboxes/#{seg(sandbox_id)}/messages/#{seg(message_id)}/raw", as: :text, options: options)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def remove(sandbox_id, message_id, **options) = api_delete("/sandboxes/#{seg(sandbox_id)}/messages/#{seg(message_id)}", options: options)
|
|
63
|
+
|
|
64
|
+
# Empty the sandbox. { "deleted" => n }.
|
|
65
|
+
def clear(sandbox_id, **options) = api_delete("/sandboxes/#{seg(sandbox_id)}/messages", options: options)
|
|
66
|
+
|
|
67
|
+
# { "updated" => n }.
|
|
68
|
+
def mark_all_read(sandbox_id, **options) = api_post("/sandboxes/#{seg(sandbox_id)}/read-all", nil, options: options)
|
|
69
|
+
|
|
70
|
+
# Poll until a message arrives that the block accepts (default: any
|
|
71
|
+
# message trapped after this call started). Handy in end-to-end tests.
|
|
72
|
+
# +timeout:+ and +interval:+ are seconds; on timeout +error.name+ is "timeout".
|
|
73
|
+
#
|
|
74
|
+
# msg, error = client.sandboxes.messages.wait_for(box["id"]) { |m| m["subject"] == "Hi" }
|
|
75
|
+
def wait_for(sandbox_id, timeout: 30, interval: 1, &match)
|
|
76
|
+
started = Time.now
|
|
77
|
+
deadline = started + timeout
|
|
78
|
+
match ||= lambda do |m|
|
|
79
|
+
Time.iso8601(m["trappedAt"].to_s) >= started - 1
|
|
80
|
+
rescue ArgumentError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
loop do
|
|
84
|
+
page = list(sandbox_id, limit: 50)
|
|
85
|
+
return page unless page.ok?
|
|
86
|
+
|
|
87
|
+
found = (page.data || []).find { |m| match.call(m) }
|
|
88
|
+
return get(sandbox_id, found["id"]) if found
|
|
89
|
+
|
|
90
|
+
if Time.now >= deadline
|
|
91
|
+
return Result.new(nil, ErrorResponse.new(name: "timeout", status_code: nil,
|
|
92
|
+
message: "No matching message reached sandbox #{sandbox_id} within #{timeout} s."), nil)
|
|
93
|
+
end
|
|
94
|
+
sleep(interval) if interval.positive?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendClipper
|
|
4
|
+
module Resources
|
|
5
|
+
class Templates < Base
|
|
6
|
+
attr_reader :tags, :assets
|
|
7
|
+
|
|
8
|
+
def initialize(http)
|
|
9
|
+
super
|
|
10
|
+
@tags = TemplateTags.new(http)
|
|
11
|
+
@assets = TemplateAssets.new(http)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Fields: tag (a tag id), project (a domain id).
|
|
15
|
+
def list(params = nil, **kw)
|
|
16
|
+
query, options = split(params, kw)
|
|
17
|
+
api_get("/templates", query: query, options: options)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# By uuid or public id (+tpl_...+).
|
|
21
|
+
def get(id, **options) = api_get("/templates/#{seg(id)}", options: options)
|
|
22
|
+
|
|
23
|
+
# The rendered HTML, tags left in place.
|
|
24
|
+
def html(id, **options) = @http.call("GET", "/templates/#{seg(id)}/html", as: :text, options: options)
|
|
25
|
+
|
|
26
|
+
# A new draft. Publish it before sending with it. Fields: name, subject,
|
|
27
|
+
# preheader, blocks, project_domain_id, key.
|
|
28
|
+
def create(params = nil, **kw)
|
|
29
|
+
body, options = split(params, kw)
|
|
30
|
+
api_post("/templates", body, options: options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def update(id, params = nil, **kw)
|
|
34
|
+
body, options = split(params, kw)
|
|
35
|
+
api_patch("/templates/#{seg(id)}", body, options: options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def remove(id, **options) = api_delete("/templates/#{seg(id)}", options: options)
|
|
39
|
+
|
|
40
|
+
def duplicate(id, **options) = api_post("/templates/#{seg(id)}/duplicate", nil, options: options)
|
|
41
|
+
|
|
42
|
+
def publish(id, **options) = api_post("/templates/#{seg(id)}/publish", nil, options: options)
|
|
43
|
+
|
|
44
|
+
def unpublish(id, **options) = api_post("/templates/#{seg(id)}/unpublish", nil, options: options)
|
|
45
|
+
|
|
46
|
+
# Render with +variables+ without sending anything.
|
|
47
|
+
def preview(id, variables = {}, **options)
|
|
48
|
+
api_post("/templates/#{seg(id)}/preview", { "variables" => variables.to_h }, options: options)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Replace the template's tags with these tag ids.
|
|
52
|
+
def set_tags(id, tag_ids, **options) = api_put("/templates/#{seg(id)}/tags", { "tagIds" => tag_ids.to_a }, options: options)
|
|
53
|
+
|
|
54
|
+
# The projects (domains) templates can belong to.
|
|
55
|
+
def projects(**options) = api_get("/templates/projects", options: options)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class TemplateTags < Base
|
|
59
|
+
# Every tag, each with its templateCount.
|
|
60
|
+
def list(**options) = api_get("/templates/tags", options: options)
|
|
61
|
+
|
|
62
|
+
# Fields: name, color (gray, red, orange, amber, green, teal, blue, purple, pink).
|
|
63
|
+
def create(params = nil, **kw)
|
|
64
|
+
body, options = split(params, kw)
|
|
65
|
+
api_post("/templates/tags", body, options: options)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def update(id, params = nil, **kw)
|
|
69
|
+
body, options = split(params, kw)
|
|
70
|
+
api_patch("/templates/tags/#{seg(id)}", body, options: options)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# { "deleted" => true, "templateCount" => n }: the tag is taken off n templates.
|
|
74
|
+
def remove(id, **options) = api_delete("/templates/tags/#{seg(id)}", options: options)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class TemplateAssets < Base
|
|
78
|
+
def list(**options) = api_get("/templates/assets", options: options)
|
|
79
|
+
|
|
80
|
+
# Upload an image a template can show and get its public URL. +data:+ is
|
|
81
|
+
# the file's bytes (base64-encoded for you); +mime_type:+ is image/png,
|
|
82
|
+
# image/jpeg, image/gif or image/webp.
|
|
83
|
+
#
|
|
84
|
+
# client.templates.assets.upload(data: File.binread("logo.png"), mime_type: "image/png", name: "logo")
|
|
85
|
+
def upload(data:, mime_type:, name: nil, **options)
|
|
86
|
+
body = { "mimeType" => mime_type, "base64" => [data.to_s].pack("m0") }
|
|
87
|
+
body["name"] = name if name && !name.empty?
|
|
88
|
+
api_post("/templates/assets", body, options: options)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendClipper
|
|
4
|
+
module Resources
|
|
5
|
+
class Webhooks < Base
|
|
6
|
+
attr_reader :dispatches
|
|
7
|
+
|
|
8
|
+
def initialize(http)
|
|
9
|
+
super
|
|
10
|
+
@dispatches = WebhookDispatches.new(http)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def list(**options) = api_get("/webhook-endpoints", options: options)
|
|
14
|
+
|
|
15
|
+
# Fields: url, events, allow_insecure_http. The response carries the
|
|
16
|
+
# signing "secret" once: store it.
|
|
17
|
+
def create(params = nil, **kw)
|
|
18
|
+
body, options = split(params, kw)
|
|
19
|
+
api_post("/webhook-endpoints", body, options: options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def update(id, params = nil, **kw)
|
|
23
|
+
body, options = split(params, kw)
|
|
24
|
+
api_patch("/webhook-endpoints/#{seg(id)}", body, options: options)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def remove(id, **options) = api_delete("/webhook-endpoints/#{seg(id)}", options: options)
|
|
28
|
+
|
|
29
|
+
# A new secret. The old one keeps signing alongside it for the grace window.
|
|
30
|
+
def rotate_secret(id, **options) = api_post("/webhook-endpoints/#{seg(id)}/rotate-secret", nil, options: options)
|
|
31
|
+
|
|
32
|
+
# Recent delivery attempts. Fields: endpoint_id, final_only, limit.
|
|
33
|
+
def deliveries(params = nil, **kw)
|
|
34
|
+
query, options = split(params, kw)
|
|
35
|
+
api_get("/webhook-deliveries", query: query, options: options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Verify a delivery's signature and return the parsed event. Makes no
|
|
39
|
+
# request. Same as SendClipper.verify_webhook.
|
|
40
|
+
def verify(payload, headers, secret, **kw) = SendClipper.verify_webhook(payload, headers, secret, **kw)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class WebhookDispatches < Base
|
|
44
|
+
# Undelivered events: dead letters by default, or state: "retrying".
|
|
45
|
+
# Fields: state, endpoint_id, cursor, limit.
|
|
46
|
+
def list(params = nil, **kw)
|
|
47
|
+
query, options = split(params, kw)
|
|
48
|
+
api_get("/webhook-dispatches", query: query, options: options)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def each(params = nil, **kw, &block)
|
|
52
|
+
query, options = split(params, kw)
|
|
53
|
+
query.delete("cursor")
|
|
54
|
+
enum = Paging.cursor("data") { |c| api_get("/webhook-dispatches", query: query.merge("cursor" => c), options: options) }
|
|
55
|
+
block ? enum.each(&block) : enum
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get(id, **options) = api_get("/webhook-dispatches/#{seg(id)}", options: options)
|
|
59
|
+
|
|
60
|
+
# One more immediate attempt for a dead-lettered event.
|
|
61
|
+
def replay(id, **options) = api_post("/webhook-dispatches/#{seg(id)}/replay", nil, options: options)
|
|
62
|
+
|
|
63
|
+
# Replay every dead letter (optionally of one endpoint), up to the server's cap.
|
|
64
|
+
def replay_all(params = nil, **kw)
|
|
65
|
+
body, options = split(params, kw)
|
|
66
|
+
api_post("/webhook-dispatches/replay-all", body, options: options)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SendClipper
|
|
4
|
+
module Resources
|
|
5
|
+
class Workspace < Base
|
|
6
|
+
# The workspace this key belongs to.
|
|
7
|
+
def get(**options) = api_get("/workspace", options: options)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class ApiKeys < Base
|
|
11
|
+
# The workspace's keys, masked. Keys are created and revoked in the dashboard only.
|
|
12
|
+
def list(**options) = api_get("/api-keys", options: options)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class Automations < Base
|
|
16
|
+
# Start a Live automation with a +manual+ trigger for one contact.
|
|
17
|
+
def enroll(automation_id, email, **options)
|
|
18
|
+
api_post("/automations/#{seg(automation_id)}/enrollments", { "email" => email }, options: options)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|