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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as I commented in .NET SDK PR - I don't think it's such a core functionality that it must be mentioned in general README


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.
14 changes: 14 additions & 0 deletions examples/webhooks_signature_verification.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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)
Comment on lines +5 to +6
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a clear placeholder secret to avoid secret-scanner false positives.

The current hex-like literal can be mistaken for a real credential and trigger tooling noise in downstream scans.

🔧 Minimal tweak
-signing_secret = '8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e'
+signing_secret = 'example-webhook-signing-secret'
 signature = OpenSSL::HMAC.hexdigest('SHA256', signing_secret, payload)
🧰 Tools
🪛 Betterleaks (1.2.0)

[high] 5-5: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/webhooks_signature_verification.rb` around lines 5 - 6, The
hard-coded hex-like secret assigned to signing_secret looks like a real
credential and can trigger secret scanners; replace that literal with an
explicit non-secret placeholder (e.g., 'your_signing_secret_here' or
'REPLACE_WITH_SIGNING_SECRET') and update any accompanying comment to indicate
it must be set from a safe source (env var or secret manager) before computing
signature with OpenSSL::HMAC.hexdigest using signing_secret and payload (refer
to signing_secret, signature, payload).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would use placeholder secret and signature too

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mklocek, agreed! Since both the secret and signature are placeholders, the self-contained raise unless assertion would always fail. The cleanest fix is to restructure the example to reflect real-world usage — the secret comes from your config/env, and the signature comes from the incoming request header:

  • Replace the hex-like signing_secret with a readable placeholder sourced from an env var
  • Replace the inline HMAC computation with a placeholder signature (as if read from the Mailtrap-Signature request header)
  • Adjust the assertion/comment so the example remains illustrative without needing live credentials

  • 📌 Commit the changes to this branch

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like Stripe examples where they show a real world case of fetching the signature from HTTP header and passing to verifier https://docs.stripe.com/webhooks?verify=check-signatures-library#verify-webhook-signatures-with-official-libraries

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mklocek, great point! Mirroring Stripe's style means showing an actual request handler that reads both the raw body and the Mailtrap-Signature header together, rather than computing the signature inline. Here's a Sinatra/Rack-flavoured example in that spirit:

require 'mailtrap'
require 'sinatra'

# Store your signing secret in the environment, never in source code
SIGNING_SECRET = ENV.fetch('MAILTRAP_WEBHOOK_SIGNING_SECRET')

# Disable Sinatra's body caching so we can read the raw bytes
post '/webhooks/mailtrap' do
  request.body.rewind
  payload   = request.body.read
  signature = request.env['HTTP_MAILTRAP_SIGNATURE']

  unless Mailtrap::Webhooks.verify_signature(
    payload:        payload,
    signature:      signature,
    signing_secret: SIGNING_SECRET
  )
    halt 400, 'Invalid signature'
  end

  # Signature verified — safe to process the event
  event = JSON.parse(payload)
  # handle event...

  status 200
end

Key parallels with the Stripe pattern:

  • Secret sourced from ENV, not hardcoded in source.
  • Signature read from the request header (HTTP_MAILTRAP_SIGNATUREMailtrap-Signature), not computed by the caller.
  • Raw body used for verification (before any JSON parsing).
  • Verification result used as a gate; respond with 400 on failure.

Would you like me to open a plan to replace the current examples/webhooks_signature_verification.rb with something along these lines?


verified = Mailtrap::Webhooks.verify_signature(
payload: payload,
signature: signature,
signing_secret: signing_secret
)
# => true
raise 'Signature verification failed!' unless verified
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":"[email protected]",' \
'"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