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,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Mailkube
|
|
6
|
+
# Resolved client configuration: the key, the origin, the timeout and the default headers.
|
|
7
|
+
#
|
|
8
|
+
# @api private This is internal plumbing, not part of the supported surface. Configure the client
|
|
9
|
+
# through `Client.new` or the environment; both are documented in the README.
|
|
10
|
+
#
|
|
11
|
+
# This is the only place configuration is read, and the only place a URL is built. Keeping the
|
|
12
|
+
# origin guard here rather than in a resource protects every future link-following feature for
|
|
13
|
+
# free. Instances are frozen: a client that cannot be reconfigured after construction is a
|
|
14
|
+
# client that cannot develop a concurrency bug.
|
|
15
|
+
class Config
|
|
16
|
+
# Environment variable holding the API key.
|
|
17
|
+
ENV_API_KEY = "MAILKUBE_API_KEY"
|
|
18
|
+
# Environment variable overriding the API base URL.
|
|
19
|
+
ENV_BASE_URL = "MAILKUBE_BASE_URL"
|
|
20
|
+
# Per-request timeout in seconds when the caller does not set one.
|
|
21
|
+
DEFAULT_TIMEOUT = 30
|
|
22
|
+
|
|
23
|
+
# @return [String] the resolved API base URL, always ending in a slash.
|
|
24
|
+
attr_reader :base_url
|
|
25
|
+
# @return [Integer, Float] the per-request timeout in seconds.
|
|
26
|
+
attr_reader :timeout
|
|
27
|
+
|
|
28
|
+
# Resolve configuration from the arguments, then the environment, then the defaults.
|
|
29
|
+
#
|
|
30
|
+
# @param api_key [String, nil] the API key; falls back to `MAILKUBE_API_KEY`.
|
|
31
|
+
# @param base_url [String, nil] the API base URL; falls back to `MAILKUBE_BASE_URL`.
|
|
32
|
+
# @param timeout [Integer, Float] the per-request timeout in seconds.
|
|
33
|
+
# @raise [ConfigurationError] when no API key is available.
|
|
34
|
+
def initialize(api_key: nil, base_url: nil, timeout: DEFAULT_TIMEOUT)
|
|
35
|
+
key = api_key || ENV.fetch(ENV_API_KEY, nil)
|
|
36
|
+
raise ConfigurationError, "no API key provided: pass api_key: or set #{ENV_API_KEY}" if key.nil? || key.empty?
|
|
37
|
+
|
|
38
|
+
@api_key = key
|
|
39
|
+
@base_url = base_url || ENV.fetch(ENV_BASE_URL, nil) || Mailkube::DEFAULT_BASE_URL
|
|
40
|
+
@timeout = timeout
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The auth and non-browser identification headers sent on every request.
|
|
45
|
+
#
|
|
46
|
+
# The User-Agent is required: the API rejects a request without one. It reports
|
|
47
|
+
# Mailkube::VERSION, which the gemspec also reads, so it cannot drift from the released
|
|
48
|
+
# version.
|
|
49
|
+
#
|
|
50
|
+
# @return [Hash{String => String}] the default headers.
|
|
51
|
+
def default_headers
|
|
52
|
+
{
|
|
53
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
54
|
+
"User-Agent" => "mailkube-ruby/#{VERSION}",
|
|
55
|
+
"Content-Type" => "application/json",
|
|
56
|
+
"Accept" => "application/json"
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Join a relative path onto the base URL, attach the query, and refuse any absolute URL off
|
|
61
|
+
# the base URL's origin.
|
|
62
|
+
#
|
|
63
|
+
# Every request carries the Authorization header, so following a link that names a foreign
|
|
64
|
+
# host would hand that host the API key.
|
|
65
|
+
#
|
|
66
|
+
# The query is attached **after** the origin check and only when there is one, so an absolute
|
|
67
|
+
# page link the API issued keeps its own query untouched and an unfiltered listing produces no
|
|
68
|
+
# `?` at all. `URI.encode_www_form` — not {Serialization.escape_segment} — is correct here: a
|
|
69
|
+
# space in a query value is `+`, and a space in a path segment is `%20`.
|
|
70
|
+
#
|
|
71
|
+
# @param path [String] a relative path, or an absolute URL the API itself issued.
|
|
72
|
+
# @param params [Hash{String => String}] query parameters, already rendered to strings by
|
|
73
|
+
# {Serialization.query}.
|
|
74
|
+
# @return [String] the absolute URL to request.
|
|
75
|
+
# @raise [ConfigurationError] when the result is not on the configured origin.
|
|
76
|
+
def build_url(path, params = {})
|
|
77
|
+
base = URI.parse(@base_url)
|
|
78
|
+
resolved = base.merge(path)
|
|
79
|
+
unless resolved.scheme == base.scheme && resolved.host == base.host && resolved.port == base.port
|
|
80
|
+
raise ConfigurationError, "refusing to follow #{resolved}: it is not on the configured API origin"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
resolved.query = URI.encode_www_form(params) unless params.empty?
|
|
84
|
+
resolved.to_s
|
|
85
|
+
rescue URI::InvalidURIError, URI::InvalidComponentError => e
|
|
86
|
+
raise ConfigurationError, "invalid URL #{path.inspect}: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The error taxonomy: one base class, one class per category, and the table that selects them.
|
|
4
|
+
module Mailkube
|
|
5
|
+
# Base class for every error this gem raises.
|
|
6
|
+
#
|
|
7
|
+
# Rescue this to catch anything the SDK can raise, or a subclass to branch on the category.
|
|
8
|
+
# Ruby's idiom is an exception hierarchy rather than Go's sentinel values, so this mirrors the
|
|
9
|
+
# python, node and PHP SDKs one-for-one; the categories, and the status that selects each, are
|
|
10
|
+
# identical across all of them.
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Raised when no API key is available, or configuration is otherwise unusable.
|
|
14
|
+
class ConfigurationError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised on a transport-level failure with no HTTP response: DNS, TCP, TLS or timeout.
|
|
17
|
+
class ConnectionError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when a webhook signature or its timestamp cannot be verified.
|
|
20
|
+
class SignatureVerificationError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised for any non-2xx response, carrying the API's error envelope.
|
|
23
|
+
#
|
|
24
|
+
# Rescue a subclass to branch on the category, and read the attributes for the detail. The
|
|
25
|
+
# envelope's `name` stays a plain string, so a value this release has never heard of is
|
|
26
|
+
# reported verbatim rather than coerced.
|
|
27
|
+
class APIError < Error
|
|
28
|
+
# @return [String] the machine-readable name from the envelope, e.g. `quota_exceeded`.
|
|
29
|
+
attr_reader :error_name
|
|
30
|
+
# @return [Integer] the HTTP status code.
|
|
31
|
+
attr_reader :status_code
|
|
32
|
+
# @return [Hash] the decoded response body, or an empty hash.
|
|
33
|
+
attr_reader :body
|
|
34
|
+
# @return [Integer, nil] the Retry-After header in seconds, when the server sent one.
|
|
35
|
+
attr_reader :retry_after
|
|
36
|
+
# @return [String, nil] the server's request id, to quote to support.
|
|
37
|
+
attr_reader :request_id
|
|
38
|
+
|
|
39
|
+
# There is deliberately one base constructor taking the whole envelope, and no subclass
|
|
40
|
+
# overrides it. Eight subclasses each repeating an argument list is how a duplication gate
|
|
41
|
+
# starts failing and how two categories start reporting different fields.
|
|
42
|
+
def initialize(message = nil, error_name: "", status_code: 0, body: {}, retry_after: nil, request_id: nil)
|
|
43
|
+
super(message || (error_name.empty? ? "HTTP #{status_code}" : error_name))
|
|
44
|
+
@error_name = error_name
|
|
45
|
+
@status_code = status_code
|
|
46
|
+
@body = body
|
|
47
|
+
@retry_after = retry_after
|
|
48
|
+
@request_id = request_id
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# HTTP 400: the request envelope was invalid.
|
|
53
|
+
class BadRequestError < APIError; end
|
|
54
|
+
|
|
55
|
+
# HTTP 403: authentication failed, or the key is forbidden from this action.
|
|
56
|
+
class AuthenticationError < APIError; end
|
|
57
|
+
|
|
58
|
+
# HTTP 404: a referenced resource was not found.
|
|
59
|
+
class NotFoundError < APIError; end
|
|
60
|
+
|
|
61
|
+
# HTTP 409: an idempotency conflict. The same key was reused with a different payload.
|
|
62
|
+
class ConflictError < APIError; end
|
|
63
|
+
|
|
64
|
+
# HTTP 422: the request was rejected by a send-policy check.
|
|
65
|
+
class InvalidRequestError < APIError; end
|
|
66
|
+
|
|
67
|
+
# HTTP 429: the rate limit was exceeded. Read {APIError#retry_after} before retrying.
|
|
68
|
+
class RateLimitError < APIError; end
|
|
69
|
+
|
|
70
|
+
# HTTP 5xx: an unexpected server error. Safe to retry with backoff.
|
|
71
|
+
class ServerError < APIError; end
|
|
72
|
+
|
|
73
|
+
# The documented values of the error envelope's `name` field.
|
|
74
|
+
#
|
|
75
|
+
# These are constants for discoverability, **not** a closed set: {APIError#error_name} stays a
|
|
76
|
+
# plain String, so a name this release has never heard of is reported verbatim instead of
|
|
77
|
+
# crashing an older client. The list tracks the public error reference and the other SDKs; add a
|
|
78
|
+
# constant when the API adds a name.
|
|
79
|
+
module ErrorName
|
|
80
|
+
APPLICATION_ERROR = "application_error"
|
|
81
|
+
BODY_CONTENT_REJECTED = "body_content_rejected"
|
|
82
|
+
BROWSER_NOT_ALLOWED = "browser_not_allowed"
|
|
83
|
+
CONCURRENT_IDEMPOTENT_REQUESTS = "concurrent_idempotent_requests"
|
|
84
|
+
FROM_DOMAIN_NOT_ALLOWED = "from_domain_not_allowed"
|
|
85
|
+
INVALID_API_KEY = "invalid_api_key"
|
|
86
|
+
INVALID_ATTACHMENT = "invalid_attachment"
|
|
87
|
+
INVALID_FROM_ADDRESS = "invalid_from_address"
|
|
88
|
+
INVALID_IDEMPOTENCY_KEY = "invalid_idempotency_key"
|
|
89
|
+
INVALID_IDEMPOTENT_REQUEST = "invalid_idempotent_request"
|
|
90
|
+
INVALID_REQUEST_BODY = "invalid_request_body"
|
|
91
|
+
LINK_REPUTATION_BLOCKED = "link_reputation_blocked"
|
|
92
|
+
MAX_MESSAGE_SIZE_EXCEEDED = "max_message_size_exceeded"
|
|
93
|
+
MAX_RECIPIENTS_EXCEEDED = "max_recipients_exceeded"
|
|
94
|
+
METHOD_NOT_ALLOWED = "method_not_allowed"
|
|
95
|
+
MISSING_REQUIRED_FIELD = "missing_required_field"
|
|
96
|
+
MISSING_REQUIRED_VARIABLE = "missing_required_variable"
|
|
97
|
+
MISSING_USER_AGENT = "missing_user_agent"
|
|
98
|
+
NOT_ACCEPTABLE = "not_acceptable"
|
|
99
|
+
QUOTA_EXCEEDED = "quota_exceeded"
|
|
100
|
+
RATE_LIMIT_EXCEEDED = "rate_limit_exceeded"
|
|
101
|
+
SCHEDULED_EMAIL_NOT_FOUND = "scheduled_email_not_found"
|
|
102
|
+
SCHEDULED_EMAIL_NOT_PENDING = "scheduled_email_not_pending"
|
|
103
|
+
SCHEDULING_NOT_INCLUDED = "scheduling_not_included"
|
|
104
|
+
TEMPLATE_NOT_FOUND = "template_not_found"
|
|
105
|
+
TEMPLATE_NOT_PUBLISHED = "template_not_published"
|
|
106
|
+
TOPIC_DISABLED = "topic_disabled"
|
|
107
|
+
TOPIC_NOT_FOUND = "topic_not_found"
|
|
108
|
+
UNSUPPORTED_MEDIA_TYPE = "unsupported_media_type"
|
|
109
|
+
VALIDATION_ERROR = "validation_error"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Maps an HTTP status to its error class. Any other 5xx is {ServerError}; the rest {APIError}.
|
|
113
|
+
STATUS_ERRORS = {
|
|
114
|
+
400 => BadRequestError,
|
|
115
|
+
403 => AuthenticationError,
|
|
116
|
+
404 => NotFoundError,
|
|
117
|
+
409 => ConflictError,
|
|
118
|
+
422 => InvalidRequestError,
|
|
119
|
+
429 => RateLimitError
|
|
120
|
+
}.freeze
|
|
121
|
+
|
|
122
|
+
# Returns the error class for an HTTP status.
|
|
123
|
+
#
|
|
124
|
+
# @param status [Integer] the HTTP status code.
|
|
125
|
+
# @return [Class] the {APIError} subclass to raise.
|
|
126
|
+
def self.error_class_for(status)
|
|
127
|
+
return STATUS_ERRORS.fetch(status) if STATUS_ERRORS.key?(status)
|
|
128
|
+
return ServerError if status >= 500
|
|
129
|
+
|
|
130
|
+
APIError
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Events
|
|
5
|
+
# The message an event is about: the fields every `email.*` event carries.
|
|
6
|
+
#
|
|
7
|
+
# The shared base of all nine `email.*` payloads, mirroring the server, where one serializer
|
|
8
|
+
# supplies this block to every message event.
|
|
9
|
+
#
|
|
10
|
+
# Every field except the id and timestamp is nullable: they are denormalized from the send
|
|
11
|
+
# transaction, and there is a window in which an event can be emitted before that row exists.
|
|
12
|
+
class MessageContext < Node
|
|
13
|
+
# @return [String] the message's UUID.
|
|
14
|
+
def email_id = self["email_id"]
|
|
15
|
+
# @return [String] when the message was accepted.
|
|
16
|
+
def created_at = self["created_at"]
|
|
17
|
+
# @return [String, nil] the sending domain.
|
|
18
|
+
def domain = self["domain"]
|
|
19
|
+
# @return [String, nil] the subject line.
|
|
20
|
+
def subject = self["subject"]
|
|
21
|
+
# @return [Array<String>, nil] the recipients.
|
|
22
|
+
def to = self["to"]
|
|
23
|
+
# `from` is the wire key and is neither a Ruby keyword nor an Object method, so it needs no
|
|
24
|
+
# renaming — unlike Python, where `from_` is forced.
|
|
25
|
+
# @return [String, nil] the sender address.
|
|
26
|
+
def from = self["from"]
|
|
27
|
+
|
|
28
|
+
# Tags stay plain hashes, and {Mailkube::Tag} stays the one **send-side** type.
|
|
29
|
+
#
|
|
30
|
+
# `Tag` is a `Data` with fixed members, so building one here would silently drop an unknown
|
|
31
|
+
# key *inside* a tag — the preservation rule violated three levels down, where nothing on the
|
|
32
|
+
# send side would notice. A second inbound tag class would preserve the keys but cost the SDK
|
|
33
|
+
# family its one-tag-type story. A hash costs neither. The rule: `Tag` is what you construct,
|
|
34
|
+
# a hash is what you read.
|
|
35
|
+
#
|
|
36
|
+
# @return [Array<Hash{String => Object}>] the tags attached at send time, verbatim.
|
|
37
|
+
def tags = self["tags"] || []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A single-recipient delivery outcome (`email.delivered`, `email.sent`).
|
|
41
|
+
class DeliveryContext < Node
|
|
42
|
+
# @return [String] the recipient this outcome is about.
|
|
43
|
+
def recipient = self["recipient"]
|
|
44
|
+
# @return [String] when the outcome was recorded.
|
|
45
|
+
def timestamp = self["timestamp"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A delivery failure, carrying the receiving server's verdict.
|
|
49
|
+
#
|
|
50
|
+
# Subclasses {DeliveryContext} because it mirrors the server's own serializer inheritance: a
|
|
51
|
+
# failure *is* a delivery outcome plus a reason.
|
|
52
|
+
class FailureContext < DeliveryContext
|
|
53
|
+
# @return [Integer] the SMTP status code the receiving server returned.
|
|
54
|
+
def code = self["code"]
|
|
55
|
+
# Server-controlled, and deliberately a plain String: a closed set would turn a reason added
|
|
56
|
+
# later into a parse error on an already-released client.
|
|
57
|
+
# @return [String] the failure reason.
|
|
58
|
+
def reason = self["reason"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# An open interaction (`email.opened`).
|
|
62
|
+
#
|
|
63
|
+
# These nested keys are camelCase on the wire, unlike every other block here. The SDK mirrors
|
|
64
|
+
# the server rather than normalizing it.
|
|
65
|
+
class EngagementContext < Node
|
|
66
|
+
# @return [String] the opening client's IP address (wire key `ipAddress`).
|
|
67
|
+
def ip_address = self["ipAddress"]
|
|
68
|
+
# @return [String] the opening client's user agent (wire key `userAgent`).
|
|
69
|
+
def user_agent = self["userAgent"]
|
|
70
|
+
# @return [String] when the interaction was recorded.
|
|
71
|
+
def timestamp = self["timestamp"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A click interaction (`email.clicked`): an open, plus the link that was clicked.
|
|
75
|
+
class ClickContext < EngagementContext
|
|
76
|
+
# @return [String] the clicked URL.
|
|
77
|
+
def link = self["link"]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Recipients suppressed for a message (`email.suppressed`).
|
|
81
|
+
class SuppressionContext < Node
|
|
82
|
+
# @return [Array<String>] the suppressed recipients.
|
|
83
|
+
def recipients = self["recipients"]
|
|
84
|
+
# @return [String] when the suppression was applied.
|
|
85
|
+
def timestamp = self["timestamp"]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# When a scheduled send is due (`email.scheduled`).
|
|
89
|
+
#
|
|
90
|
+
# Unlike the engagement blocks, these keys are snake_case on the wire.
|
|
91
|
+
class ScheduledContext < Node
|
|
92
|
+
# @return [String] when the send is due.
|
|
93
|
+
def scheduled_at = self["scheduled_at"]
|
|
94
|
+
# @return [String, nil] the batch label the send was grouped under.
|
|
95
|
+
def batch_id = self["batch_id"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Why a scheduled send never went out (`email.failed`).
|
|
99
|
+
#
|
|
100
|
+
# Deliberately **not** a {FailureContext}: this is message-level, so there is no recipient and
|
|
101
|
+
# no SMTP code. `reason` stays a plain String for the same reason as everywhere else.
|
|
102
|
+
class SendFailureContext < Node
|
|
103
|
+
# @return [String] why the send failed, e.g. `mta_unreachable`.
|
|
104
|
+
def reason = self["reason"]
|
|
105
|
+
# @return [String] when the failure was recorded.
|
|
106
|
+
def timestamp = self["timestamp"]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A sending domain's state before a `domain.status` change.
|
|
110
|
+
class DomainStatusPrevious < Node
|
|
111
|
+
# @return [String] the previous status.
|
|
112
|
+
def status = self["status"]
|
|
113
|
+
# @return [String] the previous onboarding state.
|
|
114
|
+
def onboarding_state = self["onboarding_state"]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A webhook endpoint's state before a `webhook.status` change.
|
|
118
|
+
class WebhookStatusPrevious < Node
|
|
119
|
+
# Spelled `active?` rather than `is_active`: Ruby's predicate convention. The wire key stays
|
|
120
|
+
# `is_active`, and {Node#[]} reaches it under that name.
|
|
121
|
+
# @return [Boolean] whether the endpoint was active.
|
|
122
|
+
def active? = self["is_active"]
|
|
123
|
+
# @return [Boolean] whether the endpoint was deleted.
|
|
124
|
+
def deleted? = self["is_deleted"]
|
|
125
|
+
# @return [String] why the endpoint was disabled, e.g. `none`, `user`, `low_quality`.
|
|
126
|
+
def disabled_reason = self["disabled_reason"]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Events
|
|
5
|
+
# The envelope every webhook payload arrives in: a type, a timestamp, and a `data` block.
|
|
6
|
+
#
|
|
7
|
+
# There is deliberately **no `id`** here. The delivery id travels in the `X-Webhook-Id` header,
|
|
8
|
+
# is stable across retries, and is what you deduplicate on — so it belongs to the request, not
|
|
9
|
+
# to the parsed body.
|
|
10
|
+
#
|
|
11
|
+
# `data` is declared on each concrete subclass rather than here, which is what lets
|
|
12
|
+
# {UnknownEvent} hand back a raw Hash without conflicting with a typed sibling.
|
|
13
|
+
class Event < Node
|
|
14
|
+
# @return [String] the event type, e.g. `email.delivered`.
|
|
15
|
+
def type = self["type"]
|
|
16
|
+
# @return [String] when the event occurred.
|
|
17
|
+
def created_at = self["created_at"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A message left the platform for its recipient's server.
|
|
21
|
+
class EmailSentEvent < Event
|
|
22
|
+
# @return [SentData] the event payload.
|
|
23
|
+
def data = block(SentData, "data")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A recipient's server accepted the message.
|
|
27
|
+
class EmailDeliveredEvent < Event
|
|
28
|
+
# @return [DeliveredData] the event payload.
|
|
29
|
+
def data = block(DeliveredData, "data")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A recipient's server rejected the message permanently.
|
|
33
|
+
class EmailBouncedEvent < Event
|
|
34
|
+
# @return [BouncedData] the event payload.
|
|
35
|
+
def data = block(BouncedData, "data")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A recipient's server deferred the message; delivery will be retried.
|
|
39
|
+
class EmailDeliveryDelayedEvent < Event
|
|
40
|
+
# @return [DelayedData] the event payload.
|
|
41
|
+
def data = block(DelayedData, "data")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Recipients were suppressed rather than sent to.
|
|
45
|
+
class EmailSuppressedEvent < Event
|
|
46
|
+
# @return [SuppressedData] the event payload.
|
|
47
|
+
def data = block(SuppressedData, "data")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A send was accepted for later delivery.
|
|
51
|
+
class EmailScheduledEvent < Event
|
|
52
|
+
# @return [ScheduledData] the event payload.
|
|
53
|
+
def data = block(ScheduledData, "data")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A scheduled send never went out.
|
|
57
|
+
class EmailFailedEvent < Event
|
|
58
|
+
# @return [FailedData] the event payload.
|
|
59
|
+
def data = block(FailedData, "data")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A recipient opened the message.
|
|
63
|
+
class EmailOpenedEvent < Event
|
|
64
|
+
# @return [OpenedData] the event payload.
|
|
65
|
+
def data = block(OpenedData, "data")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A recipient clicked a link in the message.
|
|
69
|
+
class EmailClickedEvent < Event
|
|
70
|
+
# @return [ClickedData] the event payload.
|
|
71
|
+
def data = block(ClickedData, "data")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A sending domain's status or onboarding state changed.
|
|
75
|
+
class DomainStatusEvent < Event
|
|
76
|
+
# @return [DomainStatusData] the event payload.
|
|
77
|
+
def data = block(DomainStatusData, "data")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A webhook endpoint was enabled, disabled or deleted.
|
|
81
|
+
class WebhookStatusEvent < Event
|
|
82
|
+
# @return [WebhookStatusData] the event payload.
|
|
83
|
+
def data = block(WebhookStatusData, "data")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# An event type this release has never heard of.
|
|
87
|
+
#
|
|
88
|
+
# Not an error: the contract requires an unknown type to degrade to untyped access so that a
|
|
89
|
+
# receiver keeps working when the platform adds a type, with no SDK upgrade. {#data} is the raw
|
|
90
|
+
# hash, and {Node#[]} reaches anything inside it.
|
|
91
|
+
class UnknownEvent < Event
|
|
92
|
+
# @return [Hash{String => Object}] the payload, undecoded.
|
|
93
|
+
def data = self["data"] || {}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
# Typed models for inbound webhook payloads.
|
|
5
|
+
module Events
|
|
6
|
+
# A typed lens over one decoded JSON object from a webhook payload.
|
|
7
|
+
#
|
|
8
|
+
# Every other model in this gem is a `Data.define`, whose members are fixed — which is exactly
|
|
9
|
+
# why none of them can be used here. The contract **inverts** the response-model rule for
|
|
10
|
+
# events: unknown fields are *preserved*, not dropped, at every nesting depth, so a receiver
|
|
11
|
+
# that logs or forwards an event keeps fields this release predates. A `Data` drops them at
|
|
12
|
+
# parse time and offers no way to ask it not to.
|
|
13
|
+
#
|
|
14
|
+
# So the storage is inverted rather than fought. The decoded hash **is** this object's state,
|
|
15
|
+
# and a subclass adds one-line readers naming the fields this release knows. Nothing is copied,
|
|
16
|
+
# so nothing can be lost, and {#raw} round-trips to exactly what the server sent. Nested blocks
|
|
17
|
+
# are built on read, so a receiver that only touches `data.email_id` never allocates the rest.
|
|
18
|
+
#
|
|
19
|
+
# Readers are written out as plain `def`s rather than generated by a macro: Steep's
|
|
20
|
+
# `UndeclaredMethodDefinition` gate reads what is in `lib/`, and a generated reader is a method
|
|
21
|
+
# no signature can be matched against.
|
|
22
|
+
class Node
|
|
23
|
+
# @return [Hash{String => Object}] the verbatim decoded object, unknown fields included.
|
|
24
|
+
attr_reader :raw
|
|
25
|
+
|
|
26
|
+
# @param raw [Hash{String => Object}] the decoded JSON object for this block.
|
|
27
|
+
def initialize(raw)
|
|
28
|
+
@raw = raw
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Read any field by its wire name, whether or not this release models it.
|
|
33
|
+
#
|
|
34
|
+
# This is the forward-compatibility escape hatch the contract asks for: a field the platform
|
|
35
|
+
# adds after this gem ships is reachable here on the day it appears.
|
|
36
|
+
#
|
|
37
|
+
# @param key [String] the wire field name.
|
|
38
|
+
# @return [Object, nil] the value, or nil when the server did not send it.
|
|
39
|
+
def [](key) = @raw[key]
|
|
40
|
+
|
|
41
|
+
# @return [Hash{String => Object}] the verbatim decoded object, for logging or forwarding.
|
|
42
|
+
def to_h = @raw
|
|
43
|
+
|
|
44
|
+
# @param other [Object] the object to compare with.
|
|
45
|
+
# @return [Boolean] true when both are the same class over the same payload.
|
|
46
|
+
def ==(other) = other.instance_of?(self.class) && other.raw == @raw
|
|
47
|
+
alias eql? ==
|
|
48
|
+
|
|
49
|
+
# @return [Integer] a hash consistent with {#==}.
|
|
50
|
+
def hash = [self.class, @raw].hash
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Build a nested block, tolerating a server that omitted the key entirely.
|
|
55
|
+
#
|
|
56
|
+
# @param type [Class] the {Node} subclass modelling the nested object.
|
|
57
|
+
# @param key [String] the wire field holding it.
|
|
58
|
+
# @return [Node] the nested block.
|
|
59
|
+
def block(type, key) = type.new(self[key] || {})
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Events
|
|
5
|
+
# The `data` of `email.sent`: the message, plus the delivery attempt that left the platform.
|
|
6
|
+
class SentData < MessageContext
|
|
7
|
+
# @return [DeliveryContext] the send outcome.
|
|
8
|
+
def sent = block(DeliveryContext, "sent")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# The `data` of `email.delivered`: the message, plus the accepted delivery.
|
|
12
|
+
class DeliveredData < MessageContext
|
|
13
|
+
# @return [DeliveryContext] the delivery outcome.
|
|
14
|
+
def delivery = block(DeliveryContext, "delivery")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The `data` of `email.bounced`: the message, plus the receiving server's rejection.
|
|
18
|
+
class BouncedData < MessageContext
|
|
19
|
+
# @return [FailureContext] the bounce, with its SMTP code and reason.
|
|
20
|
+
def bounce = block(FailureContext, "bounce")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The `data` of `email.delivery_delayed`: the message, plus the deferral.
|
|
24
|
+
class DelayedData < MessageContext
|
|
25
|
+
# @return [FailureContext] the deferral, with its SMTP code and reason.
|
|
26
|
+
def delay = block(FailureContext, "delay")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The `data` of `email.suppressed`: the message, plus who was suppressed.
|
|
30
|
+
class SuppressedData < MessageContext
|
|
31
|
+
# @return [SuppressionContext] the suppressed recipients.
|
|
32
|
+
def suppression = block(SuppressionContext, "suppression")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The `data` of `email.scheduled`: the message, plus when it is due.
|
|
36
|
+
class ScheduledData < MessageContext
|
|
37
|
+
# @return [ScheduledContext] the due time and batch.
|
|
38
|
+
def scheduled = block(ScheduledContext, "scheduled")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The `data` of `email.failed`: the message, plus why it never went out.
|
|
42
|
+
class FailedData < MessageContext
|
|
43
|
+
# @return [SendFailureContext] the message-level failure.
|
|
44
|
+
def failed = block(SendFailureContext, "failed")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The `data` of `email.opened`: the message, plus the open.
|
|
48
|
+
class OpenedData < MessageContext
|
|
49
|
+
# @return [EngagementContext] the open.
|
|
50
|
+
def open = block(EngagementContext, "open")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The `data` of `email.clicked`: the message, plus the click.
|
|
54
|
+
class ClickedData < MessageContext
|
|
55
|
+
# @return [ClickContext] the click, including the link.
|
|
56
|
+
def click = block(ClickContext, "click")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The `data` of `domain.status`: a sending domain's new state, and its previous one.
|
|
60
|
+
#
|
|
61
|
+
# Not a {MessageContext}: a domain lifecycle event is about the domain, not about a message.
|
|
62
|
+
# `status` and `onboarding_state` are server-controlled strings, not enums.
|
|
63
|
+
class DomainStatusData < Node
|
|
64
|
+
# @return [String] the domain.
|
|
65
|
+
def domain = self["domain"]
|
|
66
|
+
# @return [String] the new status.
|
|
67
|
+
def status = self["status"]
|
|
68
|
+
# @return [String] the new onboarding state.
|
|
69
|
+
def onboarding_state = self["onboarding_state"]
|
|
70
|
+
# @return [DomainStatusPrevious] the state before this change.
|
|
71
|
+
def previous = block(DomainStatusPrevious, "previous")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The `data` of `webhook.status`: an endpoint's new state, and its previous one.
|
|
75
|
+
class WebhookStatusData < Node
|
|
76
|
+
# @return [String] the endpoint's URL.
|
|
77
|
+
def endpoint_url = self["endpoint_url"]
|
|
78
|
+
# @return [Boolean] whether the endpoint is now active.
|
|
79
|
+
def active? = self["is_active"]
|
|
80
|
+
# @return [Boolean] whether the endpoint is now deleted.
|
|
81
|
+
def deleted? = self["is_deleted"]
|
|
82
|
+
# @return [String] why the endpoint was disabled.
|
|
83
|
+
def disabled_reason = self["disabled_reason"]
|
|
84
|
+
# @return [WebhookStatusPrevious] the state before this change.
|
|
85
|
+
def previous = block(WebhookStatusPrevious, "previous")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailkube
|
|
4
|
+
module Events
|
|
5
|
+
# Every event type this release knows, mapped to the envelope that models it.
|
|
6
|
+
#
|
|
7
|
+
# **This constant is the catalogue.** The known-type set is derived from it (`REGISTRY.keys`)
|
|
8
|
+
# and never written out beside it: a hand-maintained parallel list that missed an entry would
|
|
9
|
+
# route a wired-up event to {UnknownEvent} at runtime, silently, with no test failing.
|
|
10
|
+
#
|
|
11
|
+
# Registering an event is adding one row here. There is no second place to update.
|
|
12
|
+
REGISTRY = {
|
|
13
|
+
"email.sent" => EmailSentEvent,
|
|
14
|
+
"email.delivered" => EmailDeliveredEvent,
|
|
15
|
+
"email.bounced" => EmailBouncedEvent,
|
|
16
|
+
"email.delivery_delayed" => EmailDeliveryDelayedEvent,
|
|
17
|
+
"email.suppressed" => EmailSuppressedEvent,
|
|
18
|
+
"email.scheduled" => EmailScheduledEvent,
|
|
19
|
+
"email.failed" => EmailFailedEvent,
|
|
20
|
+
"email.opened" => EmailOpenedEvent,
|
|
21
|
+
"email.clicked" => EmailClickedEvent,
|
|
22
|
+
"domain.status" => DomainStatusEvent,
|
|
23
|
+
"webhook.status" => WebhookStatusEvent
|
|
24
|
+
}.freeze
|
|
25
|
+
end
|
|
26
|
+
end
|