Ruby Email SDK: Turn Inbox Replies into Background Jobs

9 min read

Use the Robotomail Ruby email SDK to list a mailbox, send with a sender name, verify webhook signatures on raw bytes, and queue replies as jobs.

John Joubert

John Joubert

Founder, Robotomail

Ruby Email SDK: Turn Inbox Replies into Background Jobs
Table of contents

A Ruby email SDK is useful for exactly two things: giving your Ruby process a mailbox it can send from, and turning inbound replies into work your app can queue. The Robotomail Ruby SDK does both over the HTTP API at https://api.robotomail.com/v1, and this guide walks the full loop: install from Bundler, list your existing mailbox, send a message with a friendly From name, then verify the inbound webhook signature against the raw request bytes before handing the event to your own background worker with deduplication.

What the SDK does not do is run jobs for you. There is no Rails engine, no built-in worker, and no automatic retry on sends. Those are your application's responsibility, and the design below reflects that.

Install the Ruby email SDK

Runtime is Ruby 3.1 or newer. The SDK repositories are public MIT releases, but as of this writing there is no published RubyGems package, so install from the tested v0.1.0 tag instead of a registry command:

# Gemfile
gem "robotomail", git: "https://github.com/robotomail/robotomail-ruby", tag: "v0.1.0"
bundle install

Pinning the tag matters. A git: source without a tag tracks the default branch, and you do not want a mail integration to drift on bundle update.

Authentication is a server-side API key read from the environment:

export ROBOTOMAIL_API_KEY="rm_your_api_key"

Keep that in your process environment or secrets manager, never in the repo. Mailbox-scoped keys work for sending and reading within their own mailbox; account-level operations and attachment uploads need a full-access key. The authentication docs cover key scopes in detail.

First request: list the mailbox

Start with the mailbox your account already has rather than creating a new one. This single request confirms the gem loads, the key is valid, and the network path works:

require "robotomail"

mail = Robotomail::Client.new # reads ROBOTOMAIL_API_KEY
mail.list_mailboxes["mailboxes"].each { |box| puts box["fullAddress"] }

Two things to internalize now, because they cause most of the early bugs:

  1. Responses are string-keyed hashes. The SDK does not symbolize keys and does not wrap results in structs. box["fullAddress"] works, box[:full_address] returns nil.
  2. Request fields are camelCase, webhook payload fields are snake_case. That is the API's shape, not a quirk of the gem. You send bodyText; you receive body_text.

Grab the mailbox ID from that list and keep it in config:

mailbox = mail.list_mailboxes["mailboxes"].first
MAILBOX_ID = mailbox["id"]

Send from the mailbox with a sender name

A send body requires to, subject, and bodyText. Add displayName to control the friendly From name, inReplyTo with the inbound RFC Message-ID when you are replying, and attachments for previously uploaded attachment IDs.

For your first send, address it to the verified owner on your account. New accounts are constrained to the owner address until the account is set up for wider sending, so sending to a random external inbox is the fastest way to get a confusing rejection.

response = mail.send_message(
  MAILBOX_ID,
  {
    "to" => ["owner@example.com"],      # the verified owner address on your account
    "subject" => "Order #A-4521 delivery update?",
    "bodyText" => "Hi, checking on the status of order A-4521.",
    "displayName" => "Shopping Agent"
  }
)

puts response["id"]

The same call over HTTP, if you want to confirm the shape outside Ruby:

curl -X POST https://api.robotomail.com/v1/mailboxes/mbx_shopping/messages \
  -H "Authorization: Bearer rm_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"to": ["owner@example.com"], "subject": "Order #A-4521 delivery update?", "bodyText": "Hi, checking on the status of order A-4521."}'

The SDK does not retry sends. If a send times out or the connection drops mid-request, do not loop and retry blindly, you will duplicate the message. List the mailbox's messages and check whether your subject already landed before trying again. Same rule applies to streaming: the SDK will not reconnect an SSE stream for you.

Verify inbound webhook signatures against raw bytes

Register a webhook as its own resource, not inline on the mailbox, and inbound mail arrives as { event, timestamp, data }:

{
  "event": "message.received",
  "data": {
    "message_id": "...", "mailbox_id": "...", "mailbox_address": "shopping-agent@robotomail.co",
    "from": "vendor@example.com", "subject": "Re: ...", "body_text": "...",
    "thread_id": "...", "received_at": "2026-04-17T10:00:00.000Z"
  }
}

The signature is computed over the exact bytes we sent. If you parse JSON first and re-serialize, key order and whitespace change and the digest will never match. Read the raw body, verify, then parse:

require "openssl"
require "json"
require "sinatra"

SIGNING_SECRET  = ENV.fetch("ROBOTOMAIL_WEBHOOK_SECRET")
# Keep the header name in one constant; the exact name is listed in the webhook docs.
SIGNATURE_HEADER = ENV.fetch("ROBOTOMAIL_SIGNATURE_HEADER")

post "/webhooks/robotomail" do
  raw = request.body.read
  request.body.rewind

  expected = OpenSSL::HMAC.hexdigest("SHA256", SIGNING_SECRET, raw)
  provided = request.env[SIGNATURE_HEADER].to_s

  halt 401 unless OpenSSL.secure_compare(expected, provided)

  payload = JSON.parse(raw)
  halt 200 unless payload["event"] == "message.received"

  enqueue(payload["data"])
  status 200
end

Use a constant-time comparison (OpenSSL.secure_compare, or Rack::Utils.secure_compare) rather than ==. And note what this check does and does not give you: it proves the body came from a holder of your signing secret. It is not timestamp-based replay protection, and the SDK does not provide any. Replay safety comes from the deduplication step below, which is where it belongs anyway, since your handler needs to be idempotent regardless.

Two Rack-level gotchas worth checking before you debug the crypto:

  • Middleware or a proxy that rewrites or recompresses the body will break verification. Verify as early in the stack as you can.
  • Some frameworks consume the body before your handler sees it. Read from the raw input stream and rewind.

Check the exact header name and registration flow in the webhook concepts docs and the receive and reply guide before you ship.

Hand the event to your own background worker

Your webhook endpoint should do three things and nothing else: verify, record, enqueue. Parsing an email, calling a model, and sending a reply all take longer than you want to hold an HTTP connection open for, and a slow endpoint turns into redelivery, which turns into duplicate replies.

Deduplicate on message_id with a uniqueness constraint in your database. Let the database be the arbiter, not an exists? check, which races under concurrent redelivery:

def enqueue(data)
  begin
    ProcessedMessage.create!(message_id: data["message_id"])
  rescue ActiveRecord::RecordNotUnique
    return # already handled or in flight; drop the duplicate
  end

  InboundReplyWorker.perform_async(data)
end
class InboundReplyWorker
  include Sidekiq::Worker

  def perform(data)
    reply_body = Agent.draft_reply(data["body_text"])

    Robotomail::Client.new.send_message(
      data["mailbox_id"],
      {
        "to" => [data["from"]],
        "subject" => "Re: #{data["subject"]}",
        "bodyText" => reply_body,
        "displayName" => "Shopping Agent",
        "inReplyTo" => data["message_id"]
      }
    )
  end
end

The data hash is already JSON-safe string keys, so it serializes cleanly into Sidekiq, Resque, or ActiveJob arguments with no massaging. Threading holds because inReplyTo carries the inbound RFC Message-ID; the outbound reply stays in the same thread_id.

If your worker can itself fail halfway through (drafted a reply, send timed out), record completion separately from the dedupe insert. A processed_at column you set after a confirmed send gives your retry logic something honest to read.

For the wider picture on where this pattern fits, see how to give your AI agent an email address, or the language-agnostic walkthrough of parsing an inbound email webhook.

Troubleshooting

NoMethodError or nil on response fields. Responses are string-keyed hashes. Use box["fullAddress"], not symbols, and do not expect method-style accessors.

401 on every request. ROBOTOMAIL_API_KEY is unset in the process environment, or you are using a mailbox-scoped key for an account-level call. Attachment uploads and account operations need a full-access key.

Send rejected for the recipient. Early on, sends are limited to the verified owner address on the account. Test against that address first, then widen.

Signature mismatch on a payload that looks fine. You parsed before verifying, or middleware touched the body. Compute the HMAC over request.body.read output, not over JSON.generate(parsed).

Duplicate replies going out. Your handler is not idempotent. Add the unique index on message_id and return early on the uniqueness violation.

Ambiguous send after a timeout. The SDK does not retry. List the mailbox's messages and confirm whether the message was accepted before sending again.

bundle install cannot resolve the gem. Check the git: and tag: values in the Gemfile. There is no registry package to fall back to as of this writing.

FAQ

Is the Ruby SDK on RubyGems?

Not as of this writing. Install it from the public repository at the tested v0.1.0 tag using Bundler's git: source, as shown above. The SDK reference lists the current install instructions and method signatures.

Does the SDK ship a Rails integration or a job runner?

No. It is an HTTP client. Mounting a controller or Rack endpoint, verifying signatures, and enqueueing to Sidekiq, Resque, or ActiveJob is application code you own. The examples above are deliberately plain Rack and Sidekiq so you can port them to whatever you already run.

How do I stop duplicate webhook deliveries from sending duplicate emails?

Enforce uniqueness on message_id in your datastore and treat the uniqueness violation as "already handled". Do not rely on an in-memory set or a read-then-write check, both fail under concurrent redelivery.

Can I use a mailbox-scoped API key for this?

Yes, for sending from and reading that one mailbox, which is what a single-agent worker needs. Account-level operations and attachment uploads require a full-access key.

Ready to wire this up? Create a mailbox and start with Robotomail, then point your webhook at the Rack endpoint above and watch the first reply land in your job queue.

Give your AI agent a real email address

Create a mailbox, connect your agent and test a conversation. Send, receive and retrieve the thread through one API.

Related posts