Class: Mailkube::Rails::WebhooksController

Inherits:
ActionController::Base
  • Object
show all
Defined in:
lib/mailkube/rails/webhooks_controller.rb,
sig/mailkube/rails.rbs

Overview

The inbound entry point: verify a delivery, then publish it.

This file is not required by lib/mailkube/rails.rb. It is reached through the route an application writes, so a worker that only sends mail never loads ActionController.

No engine, deliberately

A mountable engine would force a path on every host application and drag isolate_namespace plus generator machinery along for one endpoint. Consumers write two lines instead, which also means they choose the path, the constraints and the middleware:

require "mailkube/rails/webhooks_controller"
post "/webhooks/mailkube", to: Mailkube::Rails::WebhooksController.action(:create)

The .action(:create) form rather than the "controller#action" string: a gem's lib/ is on $LOAD_PATH but is not an autoload path, so the string form would resolve to a constant Zeitwerk has never been told about and raise at the first request. The explicit require is what makes the constant exist, and is the reason this file can stay off the boot path.

This differs from the Laravel integration, where a package registering a route is completely idiomatic and the route is registered for you behind an off-by-default setting.

It contains no cryptography

Verification is the SDK's, in one call. This class adapts Rails' request object to it and publishes the result. See .rules/INTEGRATION_CONTRACT.md.

Constant Summary collapse

NOTIFICATION =

The notification this controller publishes on a verified delivery.

Returns:

  • (String)
"webhook.mailkube"

Instance Method Summary collapse

Instance Method Details

#createvoid

This method returns an undefined value.

Receive one webhook delivery.

Answers 204 on success, 400 when verification fails, and 500 when no secret is configured.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/mailkube/rails/webhooks_controller.rb', line 50

def create
  secret = Config.webhook_secret(::Rails.application.config)
  # 500, not 4xx. The sender is behaving correctly and the fault is entirely local, so this
  # must read as an outage: the platform then keeps retrying, and the deliveries that arrive
  # while the secret is missing are not silently discarded.
  return head(:internal_server_error) if secret.nil?

  event = Mailkube::Webhooks.verify(
    payload: request.raw_post, headers: header_hash, secret: secret, **tolerance_argument
  )
  ActiveSupport::Notifications.instrument(NOTIFICATION, event: event)
  head :no_content
rescue Mailkube::SignatureVerificationError, Mailkube::Error
  # A malformed or unverifiable delivery is the sender's problem and a retry cannot fix it,
  # so it is refused rather than retried. The reason is deliberately not reported: an
  # endpoint that distinguishes "bad signature" from "stale timestamp" is an oracle.
  head :bad_request
end

#header_hashHash{String => String}

Convert the Rack environment into the plain Hash the SDK reads headers from.

request.headers cannot be passed through, which is worth stating because it is the obvious thing to write and it fails twice over. ActionDispatch::Http::Headers includes Enumerable and nothing else, so it has no transform_keys and the SDK's normalization raises NoMethodError on it. And its each delegates to the Rack environment, so converting it to a Hash yields HTTP_X_WEBHOOK_ID rather than X-Webhook-Id — which downcases to something the SDK's lookup will never match, turning every delivery into a signature failure with no clue as to why.

The conversion is generic rather than a list of the three headers the SDK reads today. Naming them would put a copy of the signature scheme's header set in this repository, and the copy would silently stop working the day the scheme grows a fourth.

Returns:

  • (Hash{String => String})

    the request headers, in x-webhook-id form.



98
99
100
101
102
103
104
105
106
# File 'lib/mailkube/rails/webhooks_controller.rb', line 98

def header_hash
  headers = {} #: Hash[String, String]
  request.env.each do |name, value|
    next unless name.is_a?(String) && name.start_with?("HTTP_") && value.is_a?(String)

    headers[name.delete_prefix("HTTP_").downcase.tr("_", "-")] = value
  end
  headers
end

#tolerance_argumentHash{Symbol => Integer}

The freshness window, as a splattable hash, or empty to accept the SDK's default.

Only the optional argument travels in a splat: the three required ones are named at the call site so Steep checks them against the SDK's own signature. Omitted rather than passed as nil, because nil would be a value the SDK has to interpret rather than an absent one.

Returns:

  • (Hash{Symbol => Integer})

    {tolerance: n}, or empty.



78
79
80
81
# File 'lib/mailkube/rails/webhooks_controller.rb', line 78

def tolerance_argument
  tolerance = Config.webhook_tolerance(::Rails.application.config)
  tolerance.nil? ? {} : { tolerance: tolerance }
end