mailkube 1.0.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 +201 -0
- data/NOTICE +11 -0
- data/README.md +323 -0
- data/lib/mailkube/client.rb +52 -0
- data/lib/mailkube/config.rb +89 -0
- data/lib/mailkube/errors.rb +132 -0
- data/lib/mailkube/events/contexts.rb +129 -0
- data/lib/mailkube/events/envelopes.rb +96 -0
- data/lib/mailkube/events/node.rb +62 -0
- data/lib/mailkube/events/payloads.rb +88 -0
- data/lib/mailkube/events/registry.rb +26 -0
- data/lib/mailkube/logging.rb +113 -0
- data/lib/mailkube/net_http_adapter.rb +123 -0
- data/lib/mailkube/resources/emails.rb +81 -0
- data/lib/mailkube/resources/scheduled_email_requests.rb +78 -0
- data/lib/mailkube/resources/scheduled_emails.rb +142 -0
- data/lib/mailkube/serialization.rb +98 -0
- data/lib/mailkube/transport.rb +190 -0
- data/lib/mailkube/types/scheduled_acks.rb +57 -0
- data/lib/mailkube/types/scheduled_emails.rb +96 -0
- data/lib/mailkube/types.rb +54 -0
- data/lib/mailkube/version.rb +15 -0
- data/lib/mailkube/webhooks.rb +152 -0
- data/lib/mailkube.rb +68 -0
- data/sig/mailkube/client.rbs +9 -0
- data/sig/mailkube/config.rbs +14 -0
- data/sig/mailkube/errors.rbs +78 -0
- data/sig/mailkube/events/contexts.rbs +60 -0
- data/sig/mailkube/events/envelopes.rbs +60 -0
- data/sig/mailkube/events/node.rbs +32 -0
- data/sig/mailkube/events/payloads.rbs +54 -0
- data/sig/mailkube/events/registry.rbs +6 -0
- data/sig/mailkube/logging.rbs +26 -0
- data/sig/mailkube/net_http_adapter.rbs +15 -0
- data/sig/mailkube/resources/emails.rbs +18 -0
- data/sig/mailkube/resources/scheduled_email_requests.rbs +19 -0
- data/sig/mailkube/resources/scheduled_emails.rbs +32 -0
- data/sig/mailkube/serialization.rbs +11 -0
- data/sig/mailkube/transport.rbs +34 -0
- data/sig/mailkube/types/scheduled_acks.rbs +30 -0
- data/sig/mailkube/types/scheduled_emails.rbs +53 -0
- data/sig/mailkube/types.rbs +44 -0
- data/sig/mailkube/webhooks.rbs +30 -0
- data/sig/mailkube.rbs +36 -0
- metadata +88 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
# Opt-in request logging: silent by default, and never a secret on the way out.
|
|
5
|
+
#
|
|
6
|
+
# Deliberately **not** built on stdlib `Logger`. `logger` stopped being a default gem in Ruby
|
|
7
|
+
# 4.0, so `require "logger"` inside this gem raises under Bundler on a Ruby this gem supports,
|
|
8
|
+
# and declaring it would cost the gem its zero-dependency claim — the same trap that keeps
|
|
9
|
+
# `Base64` out of {Serialization}. A library also has no business installing handlers, levels or
|
|
10
|
+
# formatters on its host's behalf.
|
|
11
|
+
#
|
|
12
|
+
# So the sink is any object responding to `#write(String)`: `$stderr`, a `File`, a `StringIO`,
|
|
13
|
+
# or a two-line shim over your application's own logger. That is the same dependency-inversion
|
|
14
|
+
# seam `Client.new(http:)` already uses, and it is what makes `Mailkube.enable_logging(device:
|
|
15
|
+
# Rails.logger)` work without this gem knowing what Rails is.
|
|
16
|
+
#
|
|
17
|
+
# This is the one piece of mutable module state in the gem. {enable!} and {disable!} are
|
|
18
|
+
# boot-time calls from the main thread; assigning one object reference is atomic, and no request
|
|
19
|
+
# path ever writes here.
|
|
20
|
+
module Logging
|
|
21
|
+
# Environment variable that turns logging on without a code change. It holds a **level**.
|
|
22
|
+
ENV_LEVEL = "MAILKUBE_LOG"
|
|
23
|
+
# The `MAILKUBE_LOG` values verbose enough to let this SDK's records through.
|
|
24
|
+
#
|
|
25
|
+
# `MAILKUBE_LOG` is a level, not a flag, in every mailkube SDK. This one emits exactly one
|
|
26
|
+
# class of record, the request/response trace, and that record is debug-level — so a level
|
|
27
|
+
# more selective than debug must silence it. `MAILKUBE_LOG=warning` is a working way to say
|
|
28
|
+
# "logs, but not from the SDK", and it has to keep working here or the variable means
|
|
29
|
+
# something different in Ruby than in python, node, Go and PHP.
|
|
30
|
+
VERBOSE_LEVELS = %w[trace debug all].freeze
|
|
31
|
+
# The headers whose values are masked before anything is written.
|
|
32
|
+
SENSITIVE_HEADERS = %w[authorization idempotency-key].freeze
|
|
33
|
+
# What a masked header value is replaced with.
|
|
34
|
+
REDACTION = "***"
|
|
35
|
+
|
|
36
|
+
@device = nil
|
|
37
|
+
|
|
38
|
+
# @return [#write, nil] where SDK logging is written, or nil when it is off.
|
|
39
|
+
def self.device = @device
|
|
40
|
+
|
|
41
|
+
# Turn logging on.
|
|
42
|
+
#
|
|
43
|
+
# @param device [#write] where to write; anything responding to `#write(String)`.
|
|
44
|
+
# @return [#write] the device now in use.
|
|
45
|
+
def self.enable!(device: $stderr) = @device = device
|
|
46
|
+
|
|
47
|
+
# Turn logging back off. The inverse of {enable!}, for a suite that turned it on.
|
|
48
|
+
#
|
|
49
|
+
# @return [nil] always.
|
|
50
|
+
def self.disable! = @device = nil
|
|
51
|
+
|
|
52
|
+
# Turn logging on from the environment, when `MAILKUBE_LOG` names a level of {VERBOSE_LEVELS}.
|
|
53
|
+
#
|
|
54
|
+
# Called once from `mailkube.rb`, so a deployment can turn logging on without a code change.
|
|
55
|
+
# It takes the environment as an argument rather than reading `ENV` inline because a bare `if`
|
|
56
|
+
# at the bottom of a file runs exactly once at load: no spec could reach its second branch, and
|
|
57
|
+
# the branch-coverage gate would then be paying for a line nobody can test.
|
|
58
|
+
#
|
|
59
|
+
# An unrecognized value leaves logging **off** rather than raising. This runs at `require`
|
|
60
|
+
# time, and no environment variable should be able to make `require "mailkube"` fail.
|
|
61
|
+
#
|
|
62
|
+
# @param env [Hash] the environment to read.
|
|
63
|
+
# @return [#write, nil] the device now in use, or nil when the level does not admit this SDK.
|
|
64
|
+
def self.enable_from_env(env = ENV)
|
|
65
|
+
level = env[ENV_LEVEL]
|
|
66
|
+
return nil unless VERBOSE_LEVELS.include?(level.to_s.strip.downcase)
|
|
67
|
+
|
|
68
|
+
enable!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A copy of `headers` with every secret value masked, safe to write anywhere.
|
|
72
|
+
#
|
|
73
|
+
# @param headers [Hash{String => String}] the headers about to be logged.
|
|
74
|
+
# @return [Hash{String => String}] the redacted copy; the caller's hash is not modified.
|
|
75
|
+
#
|
|
76
|
+
# Accumulated into an annotated hash rather than returned from `to_h { [k, v] }`, for the same
|
|
77
|
+
# reason as {Serialization.query}: Steep types a two-element array literal in block-body
|
|
78
|
+
# position as `Array[String]`, not as the tuple `to_h` declares.
|
|
79
|
+
def self.redact(headers)
|
|
80
|
+
masked = {} #: Hash[String, String]
|
|
81
|
+
headers.each { |name, value| masked[name] = SENSITIVE_HEADERS.include?(name.downcase) ? REDACTION : value }
|
|
82
|
+
masked
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Log one outgoing request, with its headers redacted.
|
|
86
|
+
#
|
|
87
|
+
# The `&.` is not a nil-check bolted onto a log line: it **is** "silent by default", and it is
|
|
88
|
+
# why {redact} costs nothing for the callers who never enable logging — which is what lets this
|
|
89
|
+
# call sit on the request path at all.
|
|
90
|
+
#
|
|
91
|
+
# @param method [String] the HTTP method.
|
|
92
|
+
# @param url [String] the absolute URL.
|
|
93
|
+
# @param headers [Hash{String => String}] the merged request headers.
|
|
94
|
+
# @return [void]
|
|
95
|
+
def self.request(method, url, headers)
|
|
96
|
+
@device&.write("mailkube > #{method} #{url} #{redact(headers)}\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Log one response, including the server's request id when it sent one.
|
|
100
|
+
#
|
|
101
|
+
# The id is what makes a support ticket traceable: the customer finds the failing call in
|
|
102
|
+
# their own logs and quotes the same value the API recorded. It is an id, not content — no
|
|
103
|
+
# recipient, subject or body reaches a log record here or anywhere else in this module.
|
|
104
|
+
#
|
|
105
|
+
# @param status [Integer] the HTTP status code.
|
|
106
|
+
# @param url [String] the absolute URL.
|
|
107
|
+
# @param request_id [String, nil] the `X-Request-Id` header, when the response carried one.
|
|
108
|
+
# @return [void]
|
|
109
|
+
def self.response(status, url, request_id = nil)
|
|
110
|
+
@device&.write("mailkube < #{status} #{url}#{request_id.nil? ? "" : " request_id=#{request_id}"}\n")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Mailkube
|
|
10
|
+
# The default HTTP adapter, and the only file in this gem that touches `Net::HTTP`.
|
|
11
|
+
#
|
|
12
|
+
# ## The adapter contract
|
|
13
|
+
#
|
|
14
|
+
# An adapter responds to `#call(method:, url:, headers:, body:)` and returns an
|
|
15
|
+
# {HttpResponse}. It raises {ConnectionError} when the request never produced a response, and
|
|
16
|
+
# raises nothing else: mapping an HTTP status to an exception is {Transport}'s job, so a
|
|
17
|
+
# replacement adapter never has to know the API's error envelope.
|
|
18
|
+
#
|
|
19
|
+
# Pass your own through `Client.new(http:)` to route through a proxy, add instrumentation, or
|
|
20
|
+
# drive the client from a test.
|
|
21
|
+
#
|
|
22
|
+
# ## Why a fresh connection per request
|
|
23
|
+
#
|
|
24
|
+
# `Net::HTTP.start` opens a connection, yields, and closes it. Sharing one `Net::HTTP` instance
|
|
25
|
+
# across callers is not a crash bug, it is **response cross-talk**: two threads interleave on
|
|
26
|
+
# one socket and one receives the other's body. Inside a single process that is a
|
|
27
|
+
# confidentiality bug, and it is exactly what `spec/concurrency_spec.rb` exists to catch.
|
|
28
|
+
#
|
|
29
|
+
# Connection reuse is therefore left to whoever needs it, with the reason stated: a correct
|
|
30
|
+
# pool has to be safe under threads **and** under a fiber scheduler, which is more than a
|
|
31
|
+
# scaffold should assume on a caller's behalf.
|
|
32
|
+
class NetHttpAdapter
|
|
33
|
+
# The HTTP verbs this adapter can issue, mapped to their `Net::HTTP` request classes.
|
|
34
|
+
#
|
|
35
|
+
# A frozen table rather than `const_get` on a caller-supplied string, which would turn a
|
|
36
|
+
# method name into an arbitrary constant lookup.
|
|
37
|
+
METHODS = {
|
|
38
|
+
"GET" => Net::HTTP::Get,
|
|
39
|
+
"POST" => Net::HTTP::Post,
|
|
40
|
+
"PATCH" => Net::HTTP::Patch,
|
|
41
|
+
"DELETE" => Net::HTTP::Delete
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# Errors `Net::HTTP` raises when no response was produced. Each becomes a {ConnectionError}.
|
|
45
|
+
TRANSPORT_ERRORS = [
|
|
46
|
+
IOError,
|
|
47
|
+
SocketError,
|
|
48
|
+
SystemCallError,
|
|
49
|
+
Timeout::Error,
|
|
50
|
+
OpenSSL::SSL::SSLError,
|
|
51
|
+
Net::HTTPBadResponse,
|
|
52
|
+
Net::ProtocolError
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
# @param timeout [Integer, Float] the open and read timeout in seconds.
|
|
56
|
+
def initialize(timeout: Config::DEFAULT_TIMEOUT)
|
|
57
|
+
@timeout = timeout
|
|
58
|
+
freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Perform one HTTP round trip.
|
|
62
|
+
#
|
|
63
|
+
# @param method [String] the HTTP method; must be a key of {METHODS}.
|
|
64
|
+
# @param url [String] the absolute URL to request.
|
|
65
|
+
# @param headers [Hash{String => String}] the request headers.
|
|
66
|
+
# @param body [String, nil] the already-serialized request body.
|
|
67
|
+
# @return [HttpResponse] the response.
|
|
68
|
+
# @raise [ConnectionError] when the request produced no response.
|
|
69
|
+
def call(method:, url:, headers:, body: nil)
|
|
70
|
+
uri = parse_url(url)
|
|
71
|
+
# `Config#build_url` only ever produces a URL with a host, but this adapter is public and
|
|
72
|
+
# can be driven directly, so the guard is real rather than defensive noise.
|
|
73
|
+
host = uri.hostname
|
|
74
|
+
raise ConfigurationError, "URL has no host: #{url.inspect}" if host.nil?
|
|
75
|
+
|
|
76
|
+
request = build_request(method, uri, headers, body)
|
|
77
|
+
|
|
78
|
+
Net::HTTP.start(host, uri.port, use_ssl: uri.scheme == "https",
|
|
79
|
+
open_timeout: @timeout, read_timeout: @timeout) do |http|
|
|
80
|
+
to_response(http.request(request))
|
|
81
|
+
end
|
|
82
|
+
rescue *TRANSPORT_ERRORS => e
|
|
83
|
+
raise ConnectionError, "#{e.class}: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Parse the URL, keeping `URI`'s own exception out of the caller's rescue clauses.
|
|
89
|
+
#
|
|
90
|
+
# Everything this adapter can refuse already raises {ConfigurationError} — an unsupported verb,
|
|
91
|
+
# a URL with no host — and a malformed URL was the one case where a foreign type escaped
|
|
92
|
+
# instead. `rescue Mailkube::Error` is the documented way to catch anything this gem raises,
|
|
93
|
+
# so a bare `URI::InvalidURIError` reaching a caller made that promise false. The adapter is
|
|
94
|
+
# public and can be driven directly, so this is reachable without going through
|
|
95
|
+
# {Config#build_url}, which already maps the same two exceptions the same way.
|
|
96
|
+
#
|
|
97
|
+
# @param url [String] the URL to parse.
|
|
98
|
+
# @return [URI::Generic] the parsed URL.
|
|
99
|
+
# @raise [ConfigurationError] when the URL cannot be parsed.
|
|
100
|
+
def parse_url(url)
|
|
101
|
+
URI.parse(url)
|
|
102
|
+
rescue URI::InvalidURIError, URI::InvalidComponentError => e
|
|
103
|
+
raise ConfigurationError, "invalid URL #{url.inspect}: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Net::HTTPRequest] the request object for a method, URL, headers and body.
|
|
107
|
+
def build_request(method, uri, headers, body)
|
|
108
|
+
request_class = METHODS.fetch(method.upcase) do
|
|
109
|
+
raise ConfigurationError, "unsupported HTTP method #{method.inspect}"
|
|
110
|
+
end
|
|
111
|
+
request = request_class.new(uri)
|
|
112
|
+
headers.each { |name, value| request[name] = value }
|
|
113
|
+
request.body = body unless body.nil?
|
|
114
|
+
request
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [HttpResponse] the adapter-neutral view of a `Net::HTTPResponse`.
|
|
118
|
+
def to_response(raw)
|
|
119
|
+
headers = raw.each_header.to_h { |name, value| [name.downcase, value] }
|
|
120
|
+
HttpResponse.new(status: raw.code.to_i, headers: headers, body: raw.body.to_s)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Resources
|
|
5
|
+
# The `emails` namespace, reached as `client.emails`.
|
|
6
|
+
#
|
|
7
|
+
# This is the worked example every new resource copies. Note what it does *not* do: it holds
|
|
8
|
+
# no configuration, performs no I/O, and never requires `net/http`. It depends only on an
|
|
9
|
+
# object responding to the one verb it calls.
|
|
10
|
+
class Emails
|
|
11
|
+
# @param transport [#send_email] the transport performing this resource's requests.
|
|
12
|
+
def initialize(transport)
|
|
13
|
+
@transport = transport
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Send an email.
|
|
18
|
+
#
|
|
19
|
+
# Supply `html` and/or `text` for a raw send, or `template_id` for a saved template.
|
|
20
|
+
# `idempotency_key` travels as the `Idempotency-Key` header rather than in the body.
|
|
21
|
+
# Passing `scheduled_at` schedules the send instead of delivering it now; the result then
|
|
22
|
+
# reports {Email#scheduled?}.
|
|
23
|
+
#
|
|
24
|
+
# This method shadows `Object#send` **on this object only**, which is deliberate: every
|
|
25
|
+
# mailkube SDK spells the verb `emails.send`, and a Ruby-only name would break that. Use
|
|
26
|
+
# `__send__` if you need reflective dispatch on a resource.
|
|
27
|
+
#
|
|
28
|
+
# @param from [String] the sender address, optionally with a display name.
|
|
29
|
+
# @param to [String, Array<String>] the recipient address or addresses.
|
|
30
|
+
# @param subject [String] the subject line.
|
|
31
|
+
# @param html [String, nil] the HTML body, for a raw-content send.
|
|
32
|
+
# @param text [String, nil] the plain-text body, for a raw-content send.
|
|
33
|
+
# @param cc [Array<String>, nil] carbon-copy recipients.
|
|
34
|
+
# @param bcc [Array<String>, nil] blind carbon-copy recipients.
|
|
35
|
+
# @param reply_to [String, Array<String>, nil] the Reply-To addresses.
|
|
36
|
+
# @param headers [Hash{String => String}, nil] custom message headers.
|
|
37
|
+
# @param attachments [Array<Attachment>, nil] the file attachments.
|
|
38
|
+
# @param tags [Array<Tag>, nil] free-form name/value tags forwarded to the server.
|
|
39
|
+
# @param template_id [String, nil] the UUID of a saved template to render.
|
|
40
|
+
# @param template_version [String, nil] a template version number, or "latest".
|
|
41
|
+
# @param variables [Hash, nil] values for the template's placeholders.
|
|
42
|
+
# @param topic [String, nil] the mailing-list topic slug this send is attributed to.
|
|
43
|
+
# @param idempotency_key [String, nil] sent as the `Idempotency-Key` header.
|
|
44
|
+
# @param scheduled_at [Time, String, nil] schedules the send instead of sending now.
|
|
45
|
+
# @param batch_id [String, nil] groups several scheduled sends.
|
|
46
|
+
# @return [Email] the accepted-send result.
|
|
47
|
+
# @raise [APIError] on any non-2xx response.
|
|
48
|
+
# @raise [ConnectionError] on a transport failure or timeout.
|
|
49
|
+
def send(from:, to:, subject:, html: nil, text: nil, cc: nil, bcc: nil, reply_to: nil, headers: nil,
|
|
50
|
+
attachments: nil, tags: nil, template_id: nil, template_version: nil, variables: nil,
|
|
51
|
+
topic: nil, idempotency_key: nil, scheduled_at: nil, batch_id: nil)
|
|
52
|
+
# One hash literal, then a single `compact`, rather than a chain of `body["x"] = x if x`.
|
|
53
|
+
# That keeps this method's cyclomatic complexity at 1 no matter how many optional fields
|
|
54
|
+
# the API grows, and is why an unset field is absent from the wire rather than null.
|
|
55
|
+
body = {
|
|
56
|
+
"from" => from, "to" => to, "subject" => subject,
|
|
57
|
+
"html" => html, "text" => text, "cc" => cc, "bcc" => bcc,
|
|
58
|
+
"reply_to" => reply_to, "headers" => headers,
|
|
59
|
+
"attachments" => Serialization.encode_attachments(attachments),
|
|
60
|
+
"tags" => Serialization.encode_tags(tags),
|
|
61
|
+
"template_id" => template_id, "template_version" => template_version,
|
|
62
|
+
"variables" => variables, "topic" => topic,
|
|
63
|
+
"scheduled_at" => Serialization.to_iso(scheduled_at), "batch_id" => batch_id
|
|
64
|
+
}.compact
|
|
65
|
+
|
|
66
|
+
@transport.send_email(RequestSpec.new(path: "emails", body: body, headers: idempotency(idempotency_key)))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Build the body as one hash literal and drop the nils in a single pass, rather than a
|
|
72
|
+
# chain of `body["x"] = x if x`. That keeps this method's cyclomatic complexity at 1 no
|
|
73
|
+
# matter how many optional fields the API grows, and is why an unset field is absent from
|
|
74
|
+
# the wire rather than sent as null.
|
|
75
|
+
#
|
|
76
|
+
# @param key [String, nil] the caller's idempotency key.
|
|
77
|
+
# @return [Hash{String => String}] the per-request headers.
|
|
78
|
+
def idempotency(key) = key.nil? ? {} : { "Idempotency-Key" => key }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Resources
|
|
5
|
+
# The request builders for the `scheduled-emails` routes.
|
|
6
|
+
#
|
|
7
|
+
# Standalone builders, one per verb, exactly as the contract requires — and here they earn
|
|
8
|
+
# their keep twice over. Eight verbs across two collections differ only in a path constant and
|
|
9
|
+
# a method string; written inline they would be eight near-identical bodies and the duplication
|
|
10
|
+
# gate would say so. Funnelled through {item} they are one-liners, and the escaping rule is
|
|
11
|
+
# applied in exactly one place.
|
|
12
|
+
module ScheduledEmailRequests
|
|
13
|
+
# The collection path.
|
|
14
|
+
COLLECTION = "scheduled-emails"
|
|
15
|
+
# The batch sub-collection path. Sub-resources mirror sub-paths, so this is a path rather
|
|
16
|
+
# than a suffix bolted onto a verb name.
|
|
17
|
+
BATCHES = "scheduled-emails/batches"
|
|
18
|
+
|
|
19
|
+
# The one item request every single-resource verb is built from.
|
|
20
|
+
#
|
|
21
|
+
# @param base [String] the collection path.
|
|
22
|
+
# @param identifier [String] the id or batch label to interpolate.
|
|
23
|
+
# @param method [String] the HTTP method.
|
|
24
|
+
# @param body [Hash, nil] the JSON body, or nil for a body-less request.
|
|
25
|
+
# @return [RequestSpec] the request.
|
|
26
|
+
def self.item(base, identifier, method, body = nil)
|
|
27
|
+
RequestSpec.new(path: "#{base}/#{Serialization.escape_segment(identifier)}", method: method, body: body)
|
|
28
|
+
end
|
|
29
|
+
private_class_method :item
|
|
30
|
+
|
|
31
|
+
# Build the listing request.
|
|
32
|
+
#
|
|
33
|
+
# Filters are named here rather than splatted so the five the API supports exist in exactly
|
|
34
|
+
# one place. An omitted filter is dropped by {Serialization.query}, and no filters at all
|
|
35
|
+
# yields no query string rather than a bare `?`.
|
|
36
|
+
#
|
|
37
|
+
# @param status [String, Array<String>, nil] one status, or several.
|
|
38
|
+
# @param batch_id [String, nil] only emails grouped under this batch label.
|
|
39
|
+
# @param scheduled_at_gte [Time, String, nil] only emails due at or after this instant.
|
|
40
|
+
# @param scheduled_at_lte [Time, String, nil] only emails due at or before this instant.
|
|
41
|
+
# @param page [Integer, nil] the 1-based page number.
|
|
42
|
+
# @return [RequestSpec] the listing request.
|
|
43
|
+
def self.list(status: nil, batch_id: nil, scheduled_at_gte: nil, scheduled_at_lte: nil, page: nil)
|
|
44
|
+
filters = { status: status, batch_id: batch_id, scheduled_at_gte: scheduled_at_gte,
|
|
45
|
+
scheduled_at_lte: scheduled_at_lte, page: page }
|
|
46
|
+
RequestSpec.new(path: COLLECTION, method: "GET", params: Serialization.query(filters))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param url [String] an absolute page link the API issued; it carries its own query.
|
|
50
|
+
# @return [RequestSpec] the request for that page.
|
|
51
|
+
def self.page(url) = RequestSpec.new(path: url, method: "GET")
|
|
52
|
+
|
|
53
|
+
# @param email_id [String] the scheduled email's id.
|
|
54
|
+
# @return [RequestSpec] the retrieval request.
|
|
55
|
+
def self.get(email_id) = item(COLLECTION, email_id, "GET")
|
|
56
|
+
|
|
57
|
+
# @param email_id [String] the scheduled email's id.
|
|
58
|
+
# @param body [Hash] the new due time, and optionally a batch to move the email into.
|
|
59
|
+
# @return [RequestSpec] the reschedule request.
|
|
60
|
+
def self.update(email_id, body) = item(COLLECTION, email_id, "PATCH", body)
|
|
61
|
+
|
|
62
|
+
# @param email_id [String] the scheduled email's id.
|
|
63
|
+
# @return [RequestSpec] the cancellation request.
|
|
64
|
+
def self.cancel(email_id) = item(COLLECTION, email_id, "DELETE")
|
|
65
|
+
|
|
66
|
+
# @param batch_id [String] the batch label.
|
|
67
|
+
# @param body [Hash] the new due time. There is deliberately no `batch_id` in it: the batch
|
|
68
|
+
# is identified by the path, and the server rejects a second one in the body rather than
|
|
69
|
+
# let it decide which batch actually moves.
|
|
70
|
+
# @return [RequestSpec] the batch reschedule request.
|
|
71
|
+
def self.batch_update(batch_id, body) = item(BATCHES, batch_id, "PATCH", body)
|
|
72
|
+
|
|
73
|
+
# @param batch_id [String] the batch label.
|
|
74
|
+
# @return [RequestSpec] the batch cancellation request.
|
|
75
|
+
def self.batch_cancel(batch_id) = item(BATCHES, batch_id, "DELETE")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Resources
|
|
5
|
+
# Batch operations, reached as `client.scheduled_emails.batches`.
|
|
6
|
+
#
|
|
7
|
+
# A sibling resource sharing the enclosing namespace's transport, not a method group: the wire
|
|
8
|
+
# has a `scheduled-emails/batches/{id}` sub-path, so the SDK has a sub-namespace. A
|
|
9
|
+
# `update_batch` suffix would flatten a structure the API actually has.
|
|
10
|
+
class ScheduledEmailBatches
|
|
11
|
+
# @param transport [#request_json] the transport performing this resource's requests.
|
|
12
|
+
def initialize(transport)
|
|
13
|
+
@transport = transport
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Reschedule every pending email in a batch.
|
|
18
|
+
#
|
|
19
|
+
# @param batch_id [String] the batch label.
|
|
20
|
+
# @param scheduled_at [Time, String] the new due time, in the future and within the plan's
|
|
21
|
+
# scheduling horizon. ISO-8601 with an offset, or a Time.
|
|
22
|
+
# @return [ScheduledEmailBatchUpdate] how many emails moved, and where to.
|
|
23
|
+
# @raise [APIError] on any non-2xx response.
|
|
24
|
+
def update(batch_id, scheduled_at:)
|
|
25
|
+
body = { "scheduled_at" => Serialization.to_iso(scheduled_at) }
|
|
26
|
+
ScheduledEmailBatchUpdate.from(@transport.request_json(ScheduledEmailRequests.batch_update(batch_id, body)))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Cancel every pending email in a batch.
|
|
30
|
+
#
|
|
31
|
+
# An unknown batch is a no-op reporting a count of 0 rather than a 404, so a count of 0 is
|
|
32
|
+
# not a failure.
|
|
33
|
+
#
|
|
34
|
+
# @param batch_id [String] the batch label.
|
|
35
|
+
# @return [ScheduledEmailBatchCancel] how many emails were cancelled.
|
|
36
|
+
# @raise [APIError] on any non-2xx response.
|
|
37
|
+
def cancel(batch_id)
|
|
38
|
+
ScheduledEmailBatchCancel.from(@transport.request_json(ScheduledEmailRequests.batch_cancel(batch_id)))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The `scheduled_emails` namespace, reached as `client.scheduled_emails`.
|
|
43
|
+
#
|
|
44
|
+
# Sends made with `scheduled_at:` are manageable here until they are due. A sent email has left
|
|
45
|
+
# the collection, so `status: "sent"` is a validation error rather than an empty result.
|
|
46
|
+
class ScheduledEmails
|
|
47
|
+
# @return [ScheduledEmailBatches] the batch operations.
|
|
48
|
+
attr_reader :batches
|
|
49
|
+
|
|
50
|
+
# @param transport [#request_json] the transport performing this resource's requests.
|
|
51
|
+
def initialize(transport)
|
|
52
|
+
@transport = transport
|
|
53
|
+
@batches = ScheduledEmailBatches.new(transport)
|
|
54
|
+
freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# List one page of scheduled emails.
|
|
58
|
+
#
|
|
59
|
+
# Every filter is optional and an omitted one never reaches the wire. A list of statuses
|
|
60
|
+
# becomes one comma-joined parameter rather than a repeated one. The listing is scoped
|
|
61
|
+
# server-side to a rolling window around now, so a bound in the direction that can never
|
|
62
|
+
# match is rejected rather than silently returning nothing.
|
|
63
|
+
#
|
|
64
|
+
# @param status [String, Array<String>, nil] one status, or several. Only `scheduled`,
|
|
65
|
+
# `canceled` and `failed` can be listed.
|
|
66
|
+
# @param batch_id [String, nil] only emails grouped under this batch label.
|
|
67
|
+
# @param scheduled_at_gte [Time, String, nil] only emails due at or after this instant.
|
|
68
|
+
# @param scheduled_at_lte [Time, String, nil] only emails due at or before this instant.
|
|
69
|
+
# @param page [Integer, nil] the 1-based page number to fetch.
|
|
70
|
+
# @return [ScheduledEmailPage] one page, plus its pagination block.
|
|
71
|
+
# @raise [APIError] on any non-2xx response.
|
|
72
|
+
def list(status: nil, batch_id: nil, scheduled_at_gte: nil, scheduled_at_lte: nil, page: nil)
|
|
73
|
+
fetch_page(ScheduledEmailRequests.list(status: status, batch_id: batch_id,
|
|
74
|
+
scheduled_at_gte: scheduled_at_gte,
|
|
75
|
+
scheduled_at_lte: scheduled_at_lte, page: page))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Iterate every scheduled email matching the filters, across every page.
|
|
79
|
+
#
|
|
80
|
+
# Lazy: no request is made until the enumerator is iterated, and abandoning it early costs
|
|
81
|
+
# nothing. Pages advance by **following the server's `next` link**, never by incrementing a
|
|
82
|
+
# counter, so the server stays free to change its pagination scheme. The link is fetched
|
|
83
|
+
# through {Config#build_url}, which refuses one off the configured origin — every request
|
|
84
|
+
# carries the API key, so a link naming a foreign host must not be followed.
|
|
85
|
+
#
|
|
86
|
+
# @param (see #list)
|
|
87
|
+
# @return [Enumerator<ScheduledEmail>] every matching scheduled email, page after page.
|
|
88
|
+
def iter_all(status: nil, batch_id: nil, scheduled_at_gte: nil, scheduled_at_lte: nil, page: nil)
|
|
89
|
+
Enumerator.new do |yielder|
|
|
90
|
+
current = list(status: status, batch_id: batch_id, scheduled_at_gte: scheduled_at_gte,
|
|
91
|
+
scheduled_at_lte: scheduled_at_lte, page: page)
|
|
92
|
+
loop do
|
|
93
|
+
current.data.each { |item| yielder << item }
|
|
94
|
+
link = current.pagination.steps.next
|
|
95
|
+
break if link.nil?
|
|
96
|
+
|
|
97
|
+
current = fetch_page(ScheduledEmailRequests.page(link))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Retrieve one scheduled email.
|
|
103
|
+
#
|
|
104
|
+
# @param email_id [String] the id the scheduled-send acknowledgement returned.
|
|
105
|
+
# @return [ScheduledEmail] the scheduled email.
|
|
106
|
+
# @raise [NotFoundError] when no such scheduled email exists, which is also what an id
|
|
107
|
+
# belonging to another organization reports.
|
|
108
|
+
def get(email_id)
|
|
109
|
+
ScheduledEmail.from(@transport.request_json(ScheduledEmailRequests.get(email_id)))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Reschedule one scheduled email, optionally moving it into or out of a batch.
|
|
113
|
+
#
|
|
114
|
+
# The content of a scheduled email is immutable; only its due time and batch can change.
|
|
115
|
+
#
|
|
116
|
+
# @param email_id [String] the id the scheduled-send acknowledgement returned.
|
|
117
|
+
# @param scheduled_at [Time, String] the new due time.
|
|
118
|
+
# @param batch_id [String, nil] a batch to move the email into.
|
|
119
|
+
# @return [ScheduledEmail] the rescheduled email.
|
|
120
|
+
# @raise [InvalidRequestError] when the email is no longer pending.
|
|
121
|
+
def update(email_id, scheduled_at:, batch_id: nil)
|
|
122
|
+
body = { "scheduled_at" => Serialization.to_iso(scheduled_at), "batch_id" => batch_id }.compact
|
|
123
|
+
ScheduledEmail.from(@transport.request_json(ScheduledEmailRequests.update(email_id, body)))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Cancel one scheduled email before it is sent.
|
|
127
|
+
#
|
|
128
|
+
# @param email_id [String] the id the scheduled-send acknowledgement returned.
|
|
129
|
+
# @return [CanceledScheduledEmail] the cancellation acknowledgement.
|
|
130
|
+
# @raise [InvalidRequestError] when the email is no longer pending.
|
|
131
|
+
def cancel(email_id)
|
|
132
|
+
CanceledScheduledEmail.from(@transport.request_json(ScheduledEmailRequests.cancel(email_id)))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# @param spec [RequestSpec] the page request.
|
|
138
|
+
# @return [ScheduledEmailPage] the parsed page.
|
|
139
|
+
def fetch_page(spec) = ScheduledEmailPage.from(@transport.request_json(spec))
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "erb/util"
|
|
5
|
+
|
|
6
|
+
module Mailkube
|
|
7
|
+
# How a Ruby value becomes JSON, a query-string parameter, or a path segment.
|
|
8
|
+
#
|
|
9
|
+
# One home for every "how does this go on the wire" decision, shared by every resource. Nothing
|
|
10
|
+
# here validates: the server is the authority on what a value means, and its error names are
|
|
11
|
+
# richer than anything the SDK would reproduce. These functions only make values transmissible.
|
|
12
|
+
module Serialization
|
|
13
|
+
# Render an instant for a JSON body, passing an already-formatted string through.
|
|
14
|
+
#
|
|
15
|
+
# Returns nil for nil on purpose: that is what lets a request body be one hash literal followed
|
|
16
|
+
# by a single `compact`, and why an unset field is absent from the wire rather than sent as
|
|
17
|
+
# null. Compare {query_value}, which can never return nil.
|
|
18
|
+
#
|
|
19
|
+
# @param value [Time, String, nil] the caller's instant.
|
|
20
|
+
# @return [String, nil] the ISO-8601 rendering, or nil.
|
|
21
|
+
def self.to_iso(value) = value.is_a?(Time) ? value.iso8601 : value
|
|
22
|
+
|
|
23
|
+
# Render one query-string parameter, **always** as a String.
|
|
24
|
+
#
|
|
25
|
+
# A list becomes a comma-joined value rather than a repeated parameter: the API accepts both,
|
|
26
|
+
# and a flat `Hash[String, String]` keeps the transport seam simple in every SDK that mirrors
|
|
27
|
+
# this design. A query string has no types, which is why this cannot just call {to_iso}: that
|
|
28
|
+
# would hand back the Integer `2` for `page: 2`.
|
|
29
|
+
#
|
|
30
|
+
# @param value [Object] a scalar, a Time, or an array of either.
|
|
31
|
+
# @return [String] the parameter's string form.
|
|
32
|
+
def self.query_value(value)
|
|
33
|
+
return value.map { |item| query_scalar(item) }.join(",") if value.is_a?(Array)
|
|
34
|
+
|
|
35
|
+
query_scalar(value)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param value [Object] one scalar parameter value.
|
|
39
|
+
# @return [String] its string form.
|
|
40
|
+
def self.query_scalar(value) = value.is_a?(Time) ? value.iso8601 : value.to_s
|
|
41
|
+
private_class_method :query_scalar
|
|
42
|
+
|
|
43
|
+
# Render a whole filter set for the query string, dropping the filters the caller omitted.
|
|
44
|
+
#
|
|
45
|
+
# `compact` is what makes both wire rules true in one pass, at a cyclomatic complexity of 1:
|
|
46
|
+
# an omitted filter never reaches the wire, and no filters at all yields an empty hash, which
|
|
47
|
+
# {Config#build_url} turns into no query string rather than a bare `?`.
|
|
48
|
+
#
|
|
49
|
+
# @param filters [Hash{Symbol => Object}] the caller's filters, nils included.
|
|
50
|
+
# @return [Hash{String => String}] the query parameters.
|
|
51
|
+
#
|
|
52
|
+
# Accumulated into an annotated hash rather than returned from `to_h { [k, v] }`, because Steep
|
|
53
|
+
# infers a two-element array literal in block-body position as `Array[String]`, not as the
|
|
54
|
+
# `[String, String]` tuple `to_h`'s signature demands, and reports a `BlockBodyTypeMismatch`
|
|
55
|
+
# that no annotation on the block can settle.
|
|
56
|
+
def self.query(filters)
|
|
57
|
+
rendered = {} #: Hash[String, String]
|
|
58
|
+
filters.compact.each { |name, value| rendered[name.to_s] = query_value(value) }
|
|
59
|
+
rendered
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Escape one interpolated path segment.
|
|
63
|
+
#
|
|
64
|
+
# `ERB::Util.url_encode`, and specifically **not** `CGI.escape` or
|
|
65
|
+
# `URI.encode_www_form_component`: those render a space as `+`, which is the form-encoding rule
|
|
66
|
+
# for a query string, not the percent-encoding rule for a path segment. It is also not
|
|
67
|
+
# cosmetic — an identifier carrying an encoded `/` or `?` would otherwise re-target the request
|
|
68
|
+
# at a different route. `erb` is a default gem on every supported Ruby, so this costs no
|
|
69
|
+
# dependency.
|
|
70
|
+
#
|
|
71
|
+
# @param value [String] the identifier to interpolate.
|
|
72
|
+
# @return [String] the percent-encoded segment.
|
|
73
|
+
def self.escape_segment(value) = ERB::Util.url_encode(value)
|
|
74
|
+
|
|
75
|
+
# @param attachments [Array<Attachment>, nil] the attachments as supplied.
|
|
76
|
+
# @return [Array<Hash>, nil] JSON-serializable attachments, or nil when there are none.
|
|
77
|
+
def self.encode_attachments(attachments)
|
|
78
|
+
return nil if attachments.nil? || attachments.empty?
|
|
79
|
+
|
|
80
|
+
attachments.map do |item|
|
|
81
|
+
# `[bytes].pack("m0")` rather than `Base64.strict_encode64`: `base64` is a bundled gem from
|
|
82
|
+
# Ruby 3.4, so requiring it without declaring it fails under Bundler, and declaring it would
|
|
83
|
+
# cost this gem its zero-dependency claim.
|
|
84
|
+
entry = { "filename" => item.filename, "content" => [item.content].pack("m0") }
|
|
85
|
+
entry["content_type"] = item.content_type unless item.content_type.nil?
|
|
86
|
+
entry
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @param tags [Array<Tag>, nil] the tags as supplied.
|
|
91
|
+
# @return [Array<Hash>, nil] JSON-serializable tags, or nil when there are none.
|
|
92
|
+
def self.encode_tags(tags)
|
|
93
|
+
return nil if tags.nil? || tags.empty?
|
|
94
|
+
|
|
95
|
+
tags.map { |tag| { "name" => tag.name, "value" => tag.value } }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|