Skip to content

Ruby SDK

A Ruby client for the API that does the encryption the Mailcycle apps do, on your machine. With it a program can create addresses that receive mail, open what arrives, and send. The recovery phrase never leaves the machine it runs on.

It needs Ruby 3.1 or later. Its one dependency is the openssl gem, version 3.0 or later, which Ruby 3.1 already ships.

Terminal window
gem install mailcycle

Or in a Gemfile:

gem "mailcycle"
require "mailcycle"
mc = Mailcycle.sign_in(ENV.fetch("MAILCYCLE_PHRASE"))

This is the same handshake the apps use: nothing secret crosses the wire, and it works on every plan.

With an API key instead, on Operator and up:

mc = Mailcycle.with_api_key(
ENV.fetch("MAILCYCLE_API_KEY"),
phrase: ENV["MAILCYCLE_PHRASE"] # nil to manage without opening mail
)

Without the phrase a client can manage addresses, devices and metadata, but cannot open mail or create an address that receives it, because the address’s key is derived from the phrase. With both, the phrase is checked against the key’s account before anything is opened.

Each sign_in opens a 30-day session. Keep mc.session_token somewhere private and pick it up with Mailcycle.resume(token, phrase) rather than signing in on every run.

Each of these also takes api_url: and timeout: (in seconds).

address = mc.addresses.create(prefix: "signup", label: "Test run 42")
all = mc.addresses.list

The public half of the address’s key goes up with the request so the mail server can seal what arrives; the private half stays on your machine. The label is sealed locally, and the server stores it as ciphertext.

With no prefix: or local_part:, the server rolls a name like quiet_falcon. Pass style: to choose the kind:

address = mc.addresses.create(style: :person) # maya.holt

The styles are :person, :business, :team and :neutral, the default, as symbols, strings or the constants in Mailcycle::NameStyle. style: is ignored when prefix: or local_part: is given.

To pick a name before creating, roll a few:

names = mc.addresses.roll_names(:business) # up to 8, like harbor-supply42
address = mc.addresses.create(local_part: names.first)

A rolled name works as local_part: on every plan; any other name needs Scale and up. The names are free when rolled, but someone can take one before you create it, and then create is refused. Pass domain: to roll for another domain; leaving it out uses the one create would.

messages = mc.messages.all(address.id)
messages.each do |message|
puts "#{message.from_address} #{message.subject}"
end
data = mc.messages.attachment(messages.first, 0) # a binary String, opened locally

A message that will not open, because there is no phrase or it is the wrong one, comes back with opened false and empty content rather than an error, so one unreadable message does not hide the rest of a page.

message.html is the HTML as the sender wrote it. message.safe_html has every remote image, stylesheet and embed removed, as the apps do it, and is the one to render; message.blocked lists what was taken out.

code = mc.messages.wait_for(address_id: address.id, subject: "verification")

Listens on the event stream where it is available and polls the address otherwise, and returns the message opened. If nothing matching arrives in time it raises a Mailcycle::ApiError with code "timeout". Mail received from since: on counts, even if it landed before the stream was up; it defaults to now, so to wait for a reply to something you are about to send, take the time before sending.

since = Time.now
mc.messages.send(from: address.email_address, to: "bot@example.com", subject: "Ping", text: "Hi")
reply = mc.messages.wait_for(address_id: address.id, subject: "Ping", since: since)
mc.messages.send(from: address.email_address, to: "someone@example.com", subject: "Hello", text: "Hi")

Pass reply_to: the message you are answering to thread a reply. A session can send from Personal up; an API key on Scale and up. Free receives only.

send returns a Mailcycle::SendResult: message_id is the Message-ID header, and sent_copy_id is the id of the copy in Sent, which the other message calls take, or nil when the address keeps none.

Attachments take raw bytes and the client encodes them. A message can carry up to ten files, 3.5 MB between them. Give a file a content_id to show it inline, where the HTML says cid: and that id. Program and script files, like .exe and .js, are refused.

files = [
Mailcycle::OutgoingAttachment.new(filename: "invoice.pdf", content: File.binread("invoice.pdf"),
mime_type: "application/pdf"),
Mailcycle::OutgoingAttachment.new(filename: "logo.png", content: File.binread("logo.png"),
mime_type: "image/png", content_id: "logo")
]
mc.messages.send(from: address.email_address, to: "someone@example.com", subject: "Your invoice",
text: "Attached.", html: '<img src="cid:logo"><p>Attached.</p>', attachments: files)
stream = mc.events.stream
stream.each do |event|
puts event.event_type
end

Events carry ids, never content, and are not replayed after a drop, so on_reconnect:, passed to stream, is where to refetch what you are showing. each runs until the stream is closed; next_event(timeout: 30) reads one event with a deadline and returns nil if none comes, and close ends it.

To check a webhook came from Mailcycle, pass the body exactly as it arrived:

genuine = Mailcycle.verify_webhook_signature(secret, raw_body, header)
created = mc.api_keys.create("Deploy script")
puts created.token # shown once; keep it somewhere private
mc.api_keys.list.each do |key|
puts "#{key.id} #{key.name} ...#{key.hint}"
end
mc.api_keys.revoke(created.api_key.id)

Listing, creating and revoking keys need a signed-in session, so a leaked key cannot make more of itself. The name is sealed on your machine, and the server stores it as ciphertext. A name that will not open comes back as nil.

sign_out on a client built with an API key does nothing. A key is not a session; it works until it is revoked.

created = mc.webhooks.create("https://example.com/hooks/mail", ["message.received"], "Orders")
secret = created.secret # shown once, for checking signatures
mc.webhooks.update(created.webhook.id, enabled: false)
mc.webhooks.test(created.webhook.id)
page = mc.webhooks.deliveries(created.webhook.id)
page.deliveries.each do |delivery|
puts "#{delivery.event_type} #{delivery.status} #{delivery.response_status}"
end

Webhooks are on Operator and up. list returns the webhooks with their names opened, and every event type one can ask for. rotate_secret returns a new secret once, and redeliver sends a logged event again with the same event id. Adding, changing, rotating and removing need a signed-in session; an API key can list, test, read deliveries and redeliver.

mc.sessions.list.each do |session|
puts "#{session.id} #{session.expires_at} #{session.current}"
end
mc.sessions.revoke_others

revoke(id) ends one session. All three need a signed-in session.

mc.delete_account

Erases the account and everything in it at once, with no grace period. Its addresses are retired and never issued again. It needs a signed-in session. If the account balance still holds money you paid in, it is refused with balance_held: ask for the balance back through support first.

mc.subscription returns the plan, renewal date and balance, and mc.plans lists every plan.

# The text of the device's QR code, or the eight digits it shows.
device = mc.devices.pair("MC1:12345678:...", "Kitchen Pi")
mc.devices.assign(device.id, address.id) # hands the keys over too
mc.devices.unassign(device.id, address.id)
mc.devices.hand_over_keys(device.id) # again, on its own

This is what the app’s Add device screen does, done by a script. It needs the phrase and a signed-in session. The name is sealed on your machine.

Each device has a wrap secret, and its address keys are sealed under it. The account keeps a copy in the device’s sealed profile, so keys can be handed over again when an address is assigned or taken off.

There are two ways the secret gets here. The QR code carries the device’s own secret, so it never passes through Mailcycle. The eight digits carry no secret, so the client makes one and seals it to a key the device published. The server relays that key, so it could put its own in its place at pairing and read the keys that follow. The QR path does not have that gap. Use it where you can.

A device on an older build publishes no key. Pairing it by its digits fails with an error that says to pair it in the app, or pass its QR code instead.

Everything this gem raises is a Mailcycle::Error. An API failure is a Mailcycle::ApiError carrying the HTTP status and the API’s own code in code, which is what to match on rather than the message.

begin
mc.webhooks.list
rescue Mailcycle::ApiError => e
raise unless e.code == "plan_required"
# Webhooks are on Operator and up.
end

It is checked against the published test vectors, including the refusals. SHA-256, HMAC-SHA256, PBKDF2, ChaCha20 and X25519 all come from OpenSSL, through Ruby’s own openssl.

© Northlab Studios Ltd, trading as Mailcycle. Registered in England and Wales, company no. 17462734. Registered office: 167-169 Great Portland Street, London W1W 5PF.