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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +201 -0
  3. data/NOTICE +11 -0
  4. data/README.md +323 -0
  5. data/lib/mailkube/client.rb +52 -0
  6. data/lib/mailkube/config.rb +89 -0
  7. data/lib/mailkube/errors.rb +132 -0
  8. data/lib/mailkube/events/contexts.rb +129 -0
  9. data/lib/mailkube/events/envelopes.rb +96 -0
  10. data/lib/mailkube/events/node.rb +62 -0
  11. data/lib/mailkube/events/payloads.rb +88 -0
  12. data/lib/mailkube/events/registry.rb +26 -0
  13. data/lib/mailkube/logging.rb +113 -0
  14. data/lib/mailkube/net_http_adapter.rb +123 -0
  15. data/lib/mailkube/resources/emails.rb +81 -0
  16. data/lib/mailkube/resources/scheduled_email_requests.rb +78 -0
  17. data/lib/mailkube/resources/scheduled_emails.rb +142 -0
  18. data/lib/mailkube/serialization.rb +98 -0
  19. data/lib/mailkube/transport.rb +190 -0
  20. data/lib/mailkube/types/scheduled_acks.rb +57 -0
  21. data/lib/mailkube/types/scheduled_emails.rb +96 -0
  22. data/lib/mailkube/types.rb +54 -0
  23. data/lib/mailkube/version.rb +15 -0
  24. data/lib/mailkube/webhooks.rb +152 -0
  25. data/lib/mailkube.rb +68 -0
  26. data/sig/mailkube/client.rbs +9 -0
  27. data/sig/mailkube/config.rbs +14 -0
  28. data/sig/mailkube/errors.rbs +78 -0
  29. data/sig/mailkube/events/contexts.rbs +60 -0
  30. data/sig/mailkube/events/envelopes.rbs +60 -0
  31. data/sig/mailkube/events/node.rbs +32 -0
  32. data/sig/mailkube/events/payloads.rbs +54 -0
  33. data/sig/mailkube/events/registry.rbs +6 -0
  34. data/sig/mailkube/logging.rbs +26 -0
  35. data/sig/mailkube/net_http_adapter.rbs +15 -0
  36. data/sig/mailkube/resources/emails.rbs +18 -0
  37. data/sig/mailkube/resources/scheduled_email_requests.rbs +19 -0
  38. data/sig/mailkube/resources/scheduled_emails.rbs +32 -0
  39. data/sig/mailkube/serialization.rbs +11 -0
  40. data/sig/mailkube/transport.rbs +34 -0
  41. data/sig/mailkube/types/scheduled_acks.rbs +30 -0
  42. data/sig/mailkube/types/scheduled_emails.rbs +53 -0
  43. data/sig/mailkube/types.rbs +44 -0
  44. data/sig/mailkube/webhooks.rbs +30 -0
  45. data/sig/mailkube.rbs +36 -0
  46. 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