Binions talks to email and message-queue systems through one daemon — the mailbox-service — driven entirely from your playbooks. Point it at an inbox or a broker and it watches for new messages; ask it and it lists, fetches, files and flags messages on demand; ask it to send and it delivers over SMTP — with templated bodies and threaded replies when you need them. This page is the integration view: which protocols are supported, the events and verbs you wire together, and copy-paste examples. For the full daemon reference — internals, per-verb arguments, health checks, tuning — see the mailbox-service page.
Everything in this integration — inbound and outbound, email and message queues — uses playbook steps under one verb prefix: the mail. domain, eight verbs in total. There is no separate “mqtt” or “amqp” verb; the transport is chosen by a protocol: field on the mailbox you register.
The mailbox-service is a small broker that hides seven very different transports behind a single, consistent model. Whether a message arrives from an IMAP inbox, an MQTT topic, an AMQP queue, or a Redis, WebSocket, SSE or Kafka feed, it lands in your playbooks as the same event — Fact.Mail.Received — so you write the business logic once and reuse it across sources.
| Daemon | mailbox-service (full reference at /daemons/mailbox-service) |
| Playbook prefix | mail. — eight verbs: register_mailbox, unregister_mailbox, send, list_folders, fetch, move, mark, append |
| Inbound protocols | IMAP (default), MQTT, AMQP, Redis, WebSocket, SSE, Kafka |
| Outbound | SMTP — STARTTLS (587) or implicit TLS (465); literal or templated bodies, threaded replies |
| Inbound event | Fact.Mail.Received — the same shape from the live listener and from an on-demand mail.fetch |
| Failure signal | Fact.Mail.OperationFailed — folder operations fail loudly, as an event you can react to |
| Credentials | Always via ${secret.…} in a provisioning playbook — never inline |
A mailbox is just a named source you register once. The name — the alias — is how every later playbook refers to it, and how you tell incoming messages apart. The mailbox carries a protocol: field that selects the transport, plus a settings block for that transport:
imap (the default). The daemon keeps a long-lived connection open and watches a folder. New messages are fetched, parsed into a clean structure (sender, subject, bodies, attachments), and emitted as Fact.Mail.Received. An IMAP mailbox is also two-way: the same alias can be listed, fetched from, filed and flagged on demand — the filing-cabinet section below.mqtt. The daemon subscribes to one or more topics on an MQTT broker. Each message published to a matching topic becomes a Fact.Mail.Received event — with the topic’s segments captured as named parameters if you ask for them (below).amqp. The daemon consumes from a queue on an AMQP broker (RabbitMQ-style, AMQP 0.9.1). Each delivery becomes a Fact.Mail.Received event.redis, ws, sse, kafka. Streaming system-to-system feeds — covered in their own section below.Because every transport collapses into the same event, your business playbooks never care where a message came from — they filter on the via field (the alias) and on message fields like from or subject. The same mailbox can also carry an smtp: block so you can send from that alias later.
The pattern. Register a source once (provisioning) → messages flow in as
Fact.Mail.Received→ a business playbook matches the event and runs the steps you want — extract, store, notify, reply, file. Register and react are two separate playbooks.
Provisioning playbooks run on every boot and re-register your sources idempotently. Below, an IMAP inbox is registered under the alias biuro, with an SMTP block so the same alias can also send. The password is pulled from a secret file, never written into the playbook:
name: register-office-mailbox
description: Register the office inbox on every boot (idempotent).
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: mail.register_mailbox
with:
alias: biuro
protocol: imap # default — could be omitted
idle: true # keep an inbound listener open
imap:
host: imap.example.com
port: 993 # IMAPS
username: biuro@example.com
password: ${secret.OFFICE_MAIL_PASSWORD}
mailbox_folder: INBOX
smtp: # optional — only if this alias also sends
host: smtp.example.com
port: 465 # implicit TLS; use 587 for STARTTLS
username: biuro@example.com
password: ${secret.OFFICE_MAIL_PASSWORD}
The daemon stores the configuration, spawns the inbound listener, and emits Fact.Mailbox.Registered. From then on, every new message in the folder produces a Fact.Mail.Received event carrying via: biuro.
Secrets stay out of playbooks. Reference every password as
${secret.KEY}. The value lives in a protected secret file on the host, readable only by the playbook service. See Secrets for how to create and manage these keys.
A business playbook triggers on Fact.Mail.Received and narrows down with a filter. Filters are simple field comparisons joined with AND — useful operators include .eq, .contains, .startswith, .endswith, .in, and the bare has_attachments — and a reserved or: key takes a list of alternative branches for the “subject says faktura or invoice” kind of match. Here we catch invoices arriving at the biuro alias from a known supplier domain, pull the figures out of the body with AI, and write a row to the database:
name: invoice-to-database
description: Extract invoice fields from an email and store them.
trigger:
event: Fact.Mail.Received
filter:
via.eq: biuro
from.email.endswith: "@supplier.com"
steps:
- id: extract
run: ai.extract # see /daemons/aiinjector-service
with:
text: ${trigger.envelope.body_text}
fields:
- name: supplier_name
type: text
hint: "company issuing the invoice"
- name: invoice_number
type: text
hint: "invoice or document number"
- name: amount_gross
type: decimal
hint: "total amount including tax"
- name: currency
type: text
hint: "ISO 4217 code (GBP/EUR/USD)"
- id: write_invoice
run: database.write # see /daemons/database-service
with:
table: invoices
row:
from_email: ${trigger.envelope.from.email}
subject: ${trigger.subject}
supplier: ${steps.extract.result.supplier_name}
invoice_no: ${steps.extract.result.invoice_number}
amount_gross: ${steps.extract.result.amount_gross}
currency: ${steps.extract.result.currency}
Steps pass data forward with interpolation: ${trigger.…} reads fields from the event that fired the playbook, and ${steps.<id>.…} reads the output of an earlier step. A Fact.Mail.Received event exposes via, from (with .email / .name), subject, folder, has_attachments, a flat mirror of the first attachment (first_attachment, attachments_count), and the parsed message under envelope (body_text / body_html, message_id, references). Broker sources add body_json and — for MQTT — topic_params; both are covered further down.
Structured extraction is not a mail verb: pass the body to ai.extract, as above — there is no separate mail.extract_data. The reply is a JSON object keyed by your field names, and ai.extract is the one verb whose output nests under result (${steps.extract.result.…}); see aiinjector-service.
The live listener answers “tell me when something arrives”. IMAP mailboxes go further: the same registered alias can be worked on demand, from any playbook, in short-lived sessions that never disturb the listener’s own position in the inbox:
mail.list_folders — discover the folders an account actually has (Fact.Mail.FoldersListed).mail.fetch — pull a folder’s messages on demand: folder defaults to INBOX, limit takes the newest N (up to 100), unseen_only and a since date (YYYY-MM-DD) narrow further. Every pulled message is emitted as a normal Fact.Mail.Received — identical shape to the live listener, same attachment handling, already-seen messages deduplicated — so one business playbook serves both push and pull. A summary Fact.Mail.Fetched (alias, folder, count, batch_id) closes the operation, and that summary is what the calling step completes on.mail.move / mail.mark — file a handled message into another folder and set or clear its flags (seen, answered, flagged, deleted, draft). The mailbox itself becomes a live picture of process state.mail.append — store a message into a folder without sending it (give it a subject and body, or a complete raw message): drop confirmations and records where people already look.Every folder operation that fails emits a loud Fact.Mail.OperationFailed carrying the op and a reason — react to it from an alerting playbook instead of digging through logs. These verbs are IMAP-only: brokers and feeds have no folders.
The two playbooks below pair a scheduled pull with a per-message processor that leaves its audit trail in the mailbox — extract, persist, mark seen, move to a done-folder. The cadence itself is one scheduler.register_schedule call (see scheduler-service):
name: fetch-invoice-folder-hourly
description: |
Every hour, pull up to 20 unread messages from Invoices/. Each message
becomes its own Fact.Mail.Received; this playbook's step completes on
the SUMMARY Fact.Mail.Fetched.
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: invoice-folder-scan
steps:
- id: pull
run: mail.fetch
with:
alias: biuro
folder: "Invoices"
unseen_only: true
limit: 20
---
name: process-one-invoice-mail
description: |
PER-MESSAGE — extract the invoice, persist it, then leave an audit
trail IN the mailbox: flag \Seen and move to Invoices/Processed.
trigger:
event: Fact.Mail.Received
filter:
via.eq: biuro
folder.eq: "Invoices"
steps:
- id: extract
run: ai.extract
with:
text: ${trigger.envelope.body_text}
fields:
- { name: invoice_no, type: text }
- { name: total, type: decimal }
- id: persist
run: database.write
with:
table: invoices
row:
invoice_no: ${steps.extract.result.invoice_no}
total: ${steps.extract.result.total}
- id: mark_seen
run: mail.mark
with:
alias: biuro
uid: ${trigger.uid}
folder: ${trigger.folder}
flags: [seen]
- id: archive
run: mail.move
with:
alias: biuro
uid: ${trigger.uid}
from_folder: ${trigger.folder}
to_folder: "Invoices/Processed"
Note that the second playbook does not care whether a message arrived through the live listener or a mail.fetch pull — Fact.Mail.Received carries folder on both paths, so the same filter works either way.
Attachments are parsed into envelope.attachments[]. The daemon’s attachment policy (configured on the mailbox-service) decides whether the bytes ride along in the event or are offloaded to the platform’s file store; an offloaded file announces itself as Fact.Data.Transported, with keys landing under a mail/ prefix. Because the single-attachment case is so common, Fact.Mail.Received also mirrors attachments[0] as a flat first_attachment block plus an attachments_count — and the mirror is refreshed after the offload policy runs, so what it says about content and storage always matches the real array entry.
Offloaded attachments compose with the whole file bus: data.parse turns text-layer PDFs and DOCX into text without leaving the platform (no OCR — scanned documents need an external parser), data.move files them into archive buckets, and on S3-compatible stores data.presign mints time-limited download links. See datatransporter-service for the file-bus verbs. The classic chain — a PDF invoice, from mail to SQL:
name: parse-offloaded-invoice-pdf
description: |
An offloaded PDF attachment lands in the file store; parse its text
layer in-platform, extract typed fields, persist to SQL.
trigger:
event: Fact.Data.Transported
filter:
key.startswith: "mail/"
key.endswith: ".pdf"
steps:
- id: parse
run: data.parse
with:
bucket: ${trigger.bucket}
key: ${trigger.key}
- id: extract
run: ai.extract
with:
text: ${steps.parse.text}
fields:
- { name: invoice_no, type: text }
- { name: total, type: decimal }
- id: persist
run: database.write
with:
table: invoices
row:
invoice_no: ${steps.extract.result.invoice_no}
total: ${steps.extract.result.total}
source_key: ${trigger.key}
Sending uses mail.send against an alias that has an smtp: block (registered as in the IMAP example above). The simplest shape supplies the body literally as body_text and/or body_html:
name: acknowledge-invoice
description: Reply to confirm an invoice was received.
trigger:
event: Fact.Mail.Received
filter:
via.eq: biuro
from.email.endswith: "@supplier.com"
steps:
- id: reply
run: mail.send
with:
from_alias: biuro
to:
- ${trigger.envelope.from.email}
subject: "Re: ${trigger.subject}"
body_text: |
Thanks — we received your invoice and it is being processed.
On success the daemon emits Fact.Mail.Sent with the final message_id, the SMTP server response code, and the recipient count. Trying to send from an alias that has no smtp: block (for example an MQTT-only mailbox) returns a clear error rather than failing silently.
For anything beyond a fixed sentence, hand the body to the template engine instead: supply template_text and/or template_html (each replaces its literal counterpart — give one or the other per format, never both) together with a vars map. Templates are Jinja-style — the same engine showman uses to render pages — so loops and conditionals live in the template, not in playbook syntax, and HTML output is autoescaped so interpolated values cannot break your markup.
Templates are strict. Referencing a variable you did not pass in
varsfails the step loudly instead of sending a half-rendered message — an invoice never leaves with a blank where the amount belongs.
Replies can also land in the customer’s existing thread: set in_reply_to to the message id you are answering (${trigger.envelope.message_id}) and carry the original chain across with references — leave it out and it defaults to just the in_reply_to id. Put together, a templated order summary that threads correctly:
name: reply-order-summary-in-thread
description: |
A customer mail with an order number arrives; the reply carries an
HTML table of their order lines and lands IN THEIR THREAD.
trigger:
event: Fact.Mail.Received
filter:
via.eq: biuro
subject.contains: "order"
steps:
- id: order_no
run: ai.extract
with:
text: ${trigger.subject}
fields:
- { name: order_no, type: text }
- id: lines
run: database.query
with:
table: order_lines
where:
order_no: ${steps.order_no.result.order_no}
limit: 200
- id: reply
run: mail.send
with:
from_alias: biuro
to:
- ${trigger.envelope.from.email}
subject: "Re: ${trigger.subject}"
in_reply_to: ${trigger.envelope.message_id}
references: ${trigger.envelope.references}
vars:
customer: ${trigger.envelope.from.name}
order_no: ${steps.order_no.result.order_no}
rows: ${steps.lines.rows}
template_html: |
<p>Hello {{ customer }},</p>
<p>the lines on order {{ order_no }}:</p>
<table border="1" cellpadding="4">
<tr><th>Item</th><th>Qty</th><th>Price</th></tr>
{% for r in rows %}
<tr><td>{{ r.item }}</td><td>{{ r.qty }}</td><td>{{ r.price }}</td></tr>
{% endfor %}
</table>
template_text: |
Hello {{ customer }},
the lines on order {{ order_no }}:
{% for r in rows %}- {{ r.item }} x{{ r.qty }} at {{ r.price }}
{% endfor %}
Message brokers are registered exactly like an email inbox — same mail.register_mailbox verb, a different protocol: and settings block. This is the multi-provider broker idea: business playbooks downstream are identical whether the data originated from email, MQTT, or AMQP.
Broker traffic is usually machine-readable already, and the platform meets it halfway: a payload that declares a JSON content type — or simply looks like JSON — arrives pre-parsed as body_json (up to 256 KiB) alongside the raw body_text. Playbooks read fields directly — ${trigger.body_json.temp} — no AI detour for well-formed frames.
Set protocol: mqtt and provide an mqtt: block. The topics to subscribe to live in a top-level topics: list on the mailbox — not inside the mqtt: block. MQTT wildcards work: + matches one path segment, # matches the rest:
name: register-sensor-broker
description: Subscribe to a fleet of MQTT sensors as one source.
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: mail.register_mailbox
with:
alias: factory-sensors
protocol: mqtt
mqtt:
host: broker.iot.local
port: 1883 # 8883 for TLS
client_id: binions-sensor-ingest
username: binions-sensor
password: ${secret.MQTT_SENSOR_PASSWORD}
keep_alive_secs: 30
topic_pattern: sensors/+device/+metric # name the segments
topics: # top-level list, NOT inside mqtt:
- sensors/+/temperature
- sensors/+/humidity
Each message published to a matching topic becomes a Fact.Mail.Received event with via: factory-sensors. The decoded payload is available as body_text — and pre-parsed as body_json when it is JSON — and a source field records the originating topic.
Where topics: tells the broker what to deliver, topic_pattern (inside the mqtt: settings block, validated at registration) tells Binions what the topic segments mean: +device captures one segment under that name, a trailing #rest captures the remainder, and a bare + still matches but captures nothing. Each incoming message then carries its captured segments as topic_params, so playbooks filter and read them with zero string parsing; messages on topics that do not match the pattern simply omit the field:
name: store-press-temperature
description: One row per temperature reading from the press-7 device.
trigger:
event: Fact.Mail.Received
filter:
via.eq: factory-sensors
topic_params.device.eq: press-7
topic_params.metric.eq: temperature
steps:
- id: persist
run: database.write
with:
table: sensor_readings
row:
device: ${trigger.topic_params.device}
temp: ${trigger.body_json.temp}
Set protocol: amqp and provide an amqp: block. You can give an explicit uri:, or let the daemon build the connection from host, port, username, password, and vhost. The daemon consumes from a queue — named in the amqp.queue field, or taken from the first entry of the top-level topics: list — and emits one Fact.Mail.Received per delivery, just like MQTT.
The same register verb that connects an inbox also subscribes to live system-to-system feeds. Four more values of protocol: — each message or frame arrives as the same Fact.Mail.Received event (JSON frames pre-parsed as body_json, like any broker source), so the playbook side never changes:
| protocol | Settings block | Notes |
|---|---|---|
redis | redis: { url } | topics: are PSUBSCRIBE patterns. |
ws / wss | ws: { url, headers } | topics: are subscribe frames sent verbatim after connect — exchange-style feeds work out of the box. |
sse | sse: { url, headers } | Server-Sent Events with automatic last-event-id resume. |
kafka | kafka: { … } | Requires a build with the Kafka feature enabled (off in the standard package). |
# subscribe to an exchange price stream (provisioning)
- run: mail.register_mailbox
with:
alias: ticker
protocol: ws
ws: { url: "wss://stream.example.com/prices" }
topics: ['{"method":"SUBSCRIBE","params":["btcusdt@trade"]}']
# react to every frame (business)
# trigger: { event: Fact.Mail.Received, filter: { via.eq: ticker } }
Filter triggers by the alias (via) and, when one playbook handles several sources, by source.kind (imap / mqtt / amqp / redis / ws / sse / kafka). Redis, WebSocket and SSE support ship enabled in the standard package.
The mailbox-service emits a small, predictable set of facts. Inbound connections reconnect automatically with exponential backoff if they drop, so you never schedule reconnects yourself. Subscribe to any of these events in a playbook trigger (the platform-wide list lives in the event catalog):
| Event | When it fires |
|---|---|
Fact.Mail.Received | A message arrived on a registered source (any protocol) — pushed by the live listener or pulled by mail.fetch. Carries via, from, subject, folder, bodies (plus body_json for JSON broker frames), the attachment mirrors, and source metadata. |
Fact.Mail.Fetched | A mail.fetch finished exploding a folder: alias, folder, count, batch_id. This summary — not the per-message events — is what the fetch step completes on. |
Fact.Mail.Sent | An outbound mail.send succeeded. Carries the final message_id, server response code, and recipient count. |
Fact.Mail.FoldersListed / Moved / Marked / Appended | Each folder verb confirms with its own fact. |
Fact.Mail.OperationFailed | A folder operation failed — carries the op and a reason. Loud by design: point an alerting playbook at it. |
Fact.Mailbox.Registered | A mailbox was registered (or re-registered) and its listener started. |
Fact.Mailbox.Unregistered | A mailbox alias was removed and its listener stopped. |
Fact.Mailbox.Disconnected | An inbound connection dropped. The daemon reconnects on its own — this fact is mainly for visibility, so you can alert on a flaky link if you want to. |
via first. Always pin a business playbook to the alias it cares about (via.eq: …), then add field filters. This keeps unrelated sources from triggering it.mail.mark and mail.move — anyone opening the inbox then sees at a glance what has been handled, without asking the platform.Fact.Mail.OperationFailed. One small playbook that alerts ops on this event turns a quietly stuck folder flow into an immediate signal.${secret.…} reference. Rotating a secret then needs no playbook change.