Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

This client uses API v2, for v1 refer to [this documentation](https://mailtrap.docs.apiary.io/)


## Prerequisites

To get the most out of this official Mailtrap.io Ruby SDK:
Expand Down Expand Up @@ -144,6 +143,30 @@ better flexibility in that regard. Go to your _Mailtrap account_ → _Email Send
→ _Sending Domains_ → _Your domain_ → _SMTP/API Settings_ to find the SMTP
configuration example.

### Verifying webhook signatures

Mailtrap signs every outbound webhook with HMAC-SHA256 and sends the
lowercase hex digest in the `Mailtrap-Signature` header. Verify the signature
against the raw request body using the `signing_secret` returned when you
created the webhook:

```ruby
require 'mailtrap'

# `raw_body` must be the unparsed request body bytes — do NOT re-serialize
# the parsed JSON, as that may reorder keys and invalidate the signature.
valid = Mailtrap::Webhooks.verify_signature(
payload: raw_body,
signature: request.headers['Mailtrap-Signature'],
signing_secret: ENV.fetch('MAILTRAP_WEBHOOK_SIGNING_SECRET')
)

head :unauthorized unless valid
```

The helper performs a constant-time comparison and returns `false` (rather
than raising) for empty, missing, or malformed signatures.

### Multiple Mailtrap Clients

You can configure two Mailtrap clients to operate simultaneously. This setup is
Expand Down Expand Up @@ -176,7 +199,7 @@ Email API:

- Full Email Sending – [`full.rb`](examples/full.rb)
- Batch Sending – [`batch.rb`](examples/batch.rb)
- Sending Domains API – [`sending_domains_api.rb`](examples/sending_domains_api.rb)
- Sending Domains API – [`sending_domains_api.rb`](examples/sending_domains_api.rb)
- Sending Stats API – [`stats_api.rb`](examples/stats_api.rb)
- Email Logs API – [`email_logs_api.rb`](examples/email_logs_api.rb)
- Webhooks API – [`webhooks_api.rb`](examples/webhooks_api.rb)
Expand All @@ -199,6 +222,7 @@ General:
- Billing API – [`billing_api.rb`](examples/billing_api.rb)
- Templates API – [`email_templates_api.rb`](examples/email_templates_api.rb)
- Action Mailer – [`action_mailer.rb`](examples/action_mailer.rb)
- Verifying webhook signatures – [`webhooks_signature_verification.rb`](examples/webhooks_signature_verification.rb)

## Migration guide v1 → v2

Expand All @@ -213,14 +237,14 @@ Bug reports and pull requests are welcome on [GitHub](https://github.com/railswa
## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run
`rake spec` to run the tests. You can also run `bin/console` for an interactive
`rake spec` to run the tests. You can also run `bin/console` for an interactive
prompt that will allow you to experiment.

To install this gem onto your local machine, run `bundle exec rake install`.

To release a new version, update the version number in `version.rb`, and then
run `bundle exec rake release`, which will create a git tag for the version,
push git commits and the created tag, and push the `.gem` file to
push git commits and the created tag, and push the `.gem` file to
[rubygems.org](https://rubygems.org).

To run the documentation server, first generate the documentation with
Expand All @@ -241,4 +265,4 @@ Everyone interacting in the Mailtrap project's codebases, issue trackers, chat r

## Compatibility with previous releases

Versions of this package up to 2.0.2 were an [unofficial client](https://github.com/vchin/mailtrap-client) developed by [@vchin](https://github.com/vchin). Package version 3 is a completely new package.
Versions of this package up to 2.0.2 were an [unofficial client](https://github.com/vchin/mailtrap-client) developed by [@vchin](https://github.com/vchin). Package version 3 is a completely new package.
19 changes: 19 additions & 0 deletions examples/webhooks_signature_verification.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
require 'mailtrap'

Comment thread
Rabsztok marked this conversation as resolved.
# --- Direct verification (e.g. for unit tests or custom routers) ----------
payload = '{"event":"delivery","message_id":"abc-123"}'
signing_secret = '8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e'
signature = OpenSSL::HMAC.hexdigest('SHA256', signing_secret, payload)

Mailtrap::Webhooks.verify_signature(
payload: payload,
signature: signature,
signing_secret: signing_secret
)
# => true

# Bad input never raises — it returns false:
Mailtrap::Webhooks.verify_signature(payload: payload, signature: 'not-hex', signing_secret: signing_secret)
# => false
Mailtrap::Webhooks.verify_signature(payload: payload, signature: '', signing_secret: signing_secret)
# => false
1 change: 1 addition & 0 deletions lib/mailtrap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
require_relative 'mailtrap/sandbox_attachments_api'
require_relative 'mailtrap/stats_api'
require_relative 'mailtrap/webhooks_api'
require_relative 'mailtrap/webhooks'

module Mailtrap
# @!macro api_errors
Expand Down
49 changes: 49 additions & 0 deletions lib/mailtrap/webhooks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

require 'openssl'

module Mailtrap
# Helpers for working with inbound Mailtrap webhooks.
#
# @see https://docs.mailtrap.io/email-api-smtp/advanced/webhooks#verifying-the-signature
module Webhooks
# Hex-encoded HMAC-SHA256 signature length.
SIGNATURE_HEX_LENGTH = 64

# Verifies the HMAC-SHA256 signature of a Mailtrap webhook payload.
#
# Mailtrap signs every outbound webhook by computing
# `HMAC-SHA256(signing_secret, raw_request_body)` and sending the lowercase
# hex digest in the `Mailtrap-Signature` HTTP header. Compute the same
# digest on your side and compare it in constant time.
#
# The comparison is performed with {OpenSSL.fixed_length_secure_compare} to
# avoid timing side-channels.
#
# The method never raises on inputs that could plausibly arrive over the
# wire (empty strings, wrong-length signatures, non-hex characters, missing
# secret) — it simply returns `false`. This makes it safe to call directly
# from a controller without rescuing.
#
# @param payload [String] The raw request body, exactly as received.
# **Do not** parse and re-serialize the JSON — re-encoding may reorder
# keys or alter whitespace and invalidate the signature.
# @param signature [String] The value of the `Mailtrap-Signature` HTTP
# header (lowercase hex string).
# @param signing_secret [String] The webhook's `signing_secret`, returned
# by {WebhooksAPI#create} on webhook creation.
# @return [Boolean] `true` if the signature is valid for the given payload
# and secret, `false` otherwise.
def self.verify_signature(payload:, signature:, signing_secret:)
return false unless [payload, signature, signing_secret].all? { |v| v.is_a?(String) && !v.empty? }
return false if signature.bytesize != SIGNATURE_HEX_LENGTH

expected = OpenSSL::HMAC.hexdigest('SHA256', signing_secret, payload)

OpenSSL.fixed_length_secure_compare(expected, signature)
rescue ArgumentError
# fixed_length_secure_compare raises ArgumentError on length mismatch
false
end
end
end
154 changes: 154 additions & 0 deletions spec/mailtrap/webhooks_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# frozen_string_literal: true

RSpec.describe Mailtrap::Webhooks do
# ---------------------------------------------------------------------------
# Cross-SDK fixture
#
# The (payload, signing_secret, expected_signature) triple below is the
# canonical fixture shared verbatim by every official Mailtrap SDK
# (mailtrap-ruby, mailtrap-python, mailtrap-php, mailtrap-nodejs,
# mailtrap-java, mailtrap-dotnet). Any change here MUST be mirrored in the
# equivalent test files in the other SDKs so the helpers stay byte-for-byte
# compatible across languages.
# ---------------------------------------------------------------------------
let(:fixture_payload) do
'{"event":"delivery","sending_stream":"transactional","category":"welcome",' \
'"message_id":"a8b1d8f6-1f8d-4a3c-9b2e-1a2b3c4d5e6f",' \
'"email":"recipient@example.com",' \
'"event_id":"f1e2d3c4-b5a6-7890-1234-567890abcdef",' \
'"timestamp":1716070000}'
end
let(:fixture_signing_secret) { '8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e' }
let(:fixture_expected_signature) { '6d262e2611cd09be1f948382b5c611d63b0e585c4c9c5e40139d6ac3876d5433' }

describe '.verify_signature' do
# --- 1. Valid signature for given payload + secret ----------------------
context 'with a valid signature, payload and secret' do
it 'returns true' do
result = described_class.verify_signature(
payload: fixture_payload,
signature: fixture_expected_signature,
signing_secret: fixture_signing_secret
)

expect(result).to be true
end
end

# --- 2. Wrong secret ----------------------------------------------------
context 'with a wrong signing secret' do
it 'returns false' do
result = described_class.verify_signature(
payload: fixture_payload,
signature: fixture_expected_signature,
signing_secret: 'ffffffffffffffffffffffffffffffff'
)

expect(result).to be false
end
end

# --- 3. Payload tampered (one byte changed) -----------------------------
context 'when the payload is tampered with' do
it 'returns false' do
tampered = fixture_payload.sub('delivery', 'Delivery')

result = described_class.verify_signature(
payload: tampered,
signature: fixture_expected_signature,
signing_secret: fixture_signing_secret
)

expect(result).to be false
end
end

# --- 4. Signature with wrong length -------------------------------------
context 'with a signature of the wrong length' do
it 'returns false without raising' do
too_short = fixture_expected_signature[0..30]

result = described_class.verify_signature(
payload: fixture_payload,
signature: too_short,
signing_secret: fixture_signing_secret
)

expect(result).to be false
end
end

# --- 5. Signature with non-hex characters -------------------------------
context 'with non-hex characters in the signature' do
it 'returns false without raising' do
not_hex = 'z' * Mailtrap::Webhooks::SIGNATURE_HEX_LENGTH

result = described_class.verify_signature(
payload: fixture_payload,
signature: not_hex,
signing_secret: fixture_signing_secret
)

expect(result).to be false
end
end

# --- 6. Empty signature string ------------------------------------------
context 'with an empty signature' do
it 'returns false' do
result = described_class.verify_signature(
payload: fixture_payload,
signature: '',
signing_secret: fixture_signing_secret
)

expect(result).to be false
end
end

# --- 7. Empty signing_secret --------------------------------------------
context 'with an empty signing secret' do
it 'returns false' do
result = described_class.verify_signature(
payload: fixture_payload,
signature: fixture_expected_signature,
signing_secret: ''
)

expect(result).to be false
end
end

# --- 8. Empty payload + non-empty signature -----------------------------
context 'with an empty payload but a non-empty signature' do
it 'returns false' do
result = described_class.verify_signature(
payload: '',
signature: fixture_expected_signature,
signing_secret: fixture_signing_secret
)

expect(result).to be false
end
end

# --- 9. Known-good cross-SDK fixture ------------------------------------
context 'when verifying the shared cross-SDK fixture' do
it 'matches the hardcoded HMAC-SHA256 digest' do
# Recompute the digest in-place so a regression in OpenSSL or the
# fixture itself fails loudly: this is the byte-for-byte contract
# every other Mailtrap SDK must satisfy.
computed = OpenSSL::HMAC.hexdigest('SHA256', fixture_signing_secret, fixture_payload)

expect(computed).to eq(fixture_expected_signature)
expect(
described_class.verify_signature(
payload: fixture_payload,
signature: fixture_expected_signature,
signing_secret: fixture_signing_secret
)
).to be true
end
end
end
end
Loading