mailbox-service is the platform's email and messaging broker — the daemon that both listens for incoming messages and sends outgoing ones. On the inbound side it watches a mailbox or message queue you have registered and, every time something arrives, announces it on the internal event bus for your playbooks to act on. On the outbound side it sends email on demand — literal text or a rendered template, threaded into an existing conversation when you want it to be. And it does not stop at the doorstep: playbooks can work a registered mailbox's folders directly, listing them, pulling messages on demand, moving, flagging, and filing. One daemon, two directions: it is the bridge between your automations and the outside world of email and message queues.
Good to know. mailbox-service does not read or interpret the messages it delivers. When a message arrives it emits a single event carrying the contents, adding only mechanical conveniences — a JSON broker frame arrives pre-parsed, MQTT topic segments pre-captured. Your playbooks decide what to do next, typically by handing the body to the AI daemon to extract the figures you care about. Think of it as the platform's postbox, stamp drawer, and filing cabinet — not its reading desk.
Binions is event-driven: its daemons talk over a fast internal event bus, and everything that happens is described as an event riding in a standard envelope. mailbox-service turns the messaging world into events your playbooks understand, in both directions.
Inbound. You register a source — an IMAP mailbox, an MQTT or AMQP queue, or another supported feed — under a short name (an alias). From then on, a listener watches that source on your behalf. Every time a message lands, the daemon emits one event — Fact.Mail.Received — stamped with the alias it came in on and carrying the message itself. Your business playbooks trigger on that event, filter it (by alias, by folder, by sender, by subject), and run whatever workflow the message calls for.
invoices, support, sensors — and that name is what your playbooks filter on, and what appears on every event.body_json, and MQTT topic segments can be captured into topic_params. Interpreting the content — pulling an order number out of an email, say — is a separate step you compose in the playbook.Fact.Mail.Received events the listener emits, so one set of playbooks serves both.mail.send with the recipient, subject, and body — literal text, or an inline template rendered with values from the run — and the daemon hands it to your SMTP server and reports the outcome on the event bus. On a broker-protocol mailbox the same verb publishes instead: give it a payload and a target (topic / channel / routing key) and the frame goes out over that mailbox's broker.A message arriving is just another event, and sending one is just another action. That means an email, an MQTT sensor reading, and an AMQP job all flow through the same playbooks — and a reply, a notification, or a confirmation email is one more step in the same workflow.
| What it is | The email & messaging broker — inbound listener, on-demand folder worker, and outbound sender in one daemon |
| Playbook prefix | mail. |
| Inbound protocols | IMAP (email); MQTT, AMQP, Redis pub/sub, WebSocket, SSE — plus Kafka in kafka-enabled builds |
| Outbound | Email over SMTP (STARTTLS on 587, or implicit TLS on 465); on a broker mailbox mail.send publishes frames instead |
| The event you react to | Fact.Mail.Received — one per incoming message, delivered live or pulled with mail.fetch, stamped with its alias and folder |
| Attachments | Carried on the event: small files inline (base64, up to 1 MiB by default), mid-size files offloaded to object storage with a storage: {bucket, key} reference (up to 8 MiB), larger ones as metadata only — every attachment always carries its SHA-256, and the first is mirrored flat as first_attachment beside an attachments_count |
| Operations | Eight — register / unregister a source, send (literal or templated, thread-aware), and five IMAP folder operations: list folders, fetch, move, mark, append |
| Service | binions-mailbox.service with a dedicated redis-binions-mailbox.service |
| Health endpoint | /health/live, /health/ready, /metrics |
In a playbook you address the daemon with the lowercase verb form run: mail.<operation>, which the platform turns into the daemon's internal Action.Mail.<Verb>. There are eight operations — two manage the sources you watch, one sends, and five work the folders of a registered IMAP mailbox:
| Operation | What it does | Arguments |
|---|---|---|
mail.register_mailbox | Start watching a source under a named alias. The listener stays up and emits Fact.Mail.Received for each message that arrives. Typically a provisioning step. | alias, protocol (defaults to imap — the Protocols table below lists the transports), plus the connection settings for that protocol in a block named after it; an MQTT source may add a topic_pattern inside its mqtt: settings |
mail.unregister_mailbox | Stop watching a source and tear down its listener. The alias stops producing events. | alias |
mail.send | Send an email through your SMTP server — a literal or template-rendered body, optionally threaded as a reply — or, on a broker-protocol mailbox, publish a frame to that broker. A business step, usually the final action in a workflow. | e-mail: from_alias, to, subject, then body_text / body_html or template_text / template_html with vars; optional in_reply_to and references · broker publish: payload plus target (topic / channel / routing key) |
mail.list_folders | List the folders of a registered IMAP mailbox. | alias |
mail.fetch | Pull a folder's newest messages on demand. Each message is emitted as an ordinary Fact.Mail.Received; a Fact.Mail.Fetched summary then closes the batch. | alias, folder (default INBOX), limit (newest N — default 10, max 100), unseen_only, since (YYYY-MM-DD) |
mail.move | Move a message to another folder — the server's native UID MOVE where available, an automatic copy-and-expunge fallback where not. | alias, uid, from_folder (default INBOX), to_folder |
mail.mark | Set — or, with unset: true, clear — standard flags on a message. | alias, uid, folder, flags (seen · answered · flagged · deleted · draft), unset |
mail.append | Store a finished message into a folder (IMAP APPEND). It files, it does not send. | alias, folder, then subject + body_text or raw_b64; to is informational only |
Mind the prefix. The verb prefix for this daemon is
mail.— for examplemail.register_mailbox,mail.send, andmail.fetch. It is notmailbox.*. The domain isAction.Mail.RegisterMailbox,Action.Mail.Send,Action.Mail.Fetch, and so on.
Extracting structured data from a message's contents is not a mailbox operation. Compose it in your playbook instead: take the body from a Fact.Mail.Received event and call ai.extract on the AI daemon — see aiinjector-service. The mailbox delivers the message; the AI daemon reads it.
Five operations — mail.list_folders, mail.fetch, mail.move, mail.mark, and mail.append — treat a registered IMAP mailbox as a place your playbooks can work in, not just listen at. They are IMAP-only, and each runs over a short ad-hoc session of its own — connect, do the job, disconnect — entirely separate from the live listener. In particular, mail.fetch never moves the listener's high-water mark, so pulling in history cannot cause fresh mail to be skipped.
The heart of the family is mail.fetch: it explodes a folder on demand into the same Fact.Mail.Received events the live listener emits — same envelope, same attachment policy and offloading, same duplicate handling — so pull and push share playbooks. The per-message events come first; the operation then closes with one Fact.Mail.Fetched summary (alias, folder, count, batch_id), and that summary is what the calling step's saga completes on. And since Fact.Mail.Received carries its folder on both paths, a filter like folder.eq: "Faktury" selects the right messages whether they were pushed or pulled.
Failure is never quiet here: a folder operation that cannot do its job emits Fact.Mail.OperationFailed carrying the op that failed and the reason — an ordinary event you can trigger an alerting playbook on.
mail.sendFor transactional mail whose content depends on data, mail.send accepts an inline template instead of a literal body: template_text and template_html carry the template source, and the values you pass under vars fill it in. A template and its literal counterpart are mutually exclusive — give body_text or template_text, never both (and likewise for HTML). Loops and conditionals live in the template, so the playbook itself stays flat; HTML output is autoescaped as it renders; and it is the same template engine that renders showman-service's pages. Rendering is deliberately strict: a variable the template references but vars does not supply fails the send loudly — an invoice never leaves half-rendered.
To make a reply land in the conversation it answers, set in_reply_to — feed it ${trigger.envelope.message_id} from the mail you are replying to — and, to carry a longer chain, references, which defaults to [in_reply_to] when omitted. Example 5 below shows the full pattern.
A provisioning playbook that runs once at first boot and tells mailbox-service to watch an inbox for incoming invoices. The IMAP password is pulled from a secret rather than written in the file:
name: provision-invoices-mailbox
trigger:
event: Fact.System.Boot
filter: { component.eq: playbook-service }
steps:
- run: mail.register_mailbox
with:
alias: invoices
protocol: imap
host: imap.example.com
port: 993
username: invoices@example.com
password: "${secret.invoices_imap_password}"
From now on, every email that arrives in that inbox produces a Fact.Mail.Received event stamped via: invoices. Registration is durable — the listener is re-established after a restart — so you set this up once and forget about it.
The classic inbound flow: an email lands, the AI daemon pulls typed figures out of it, and the database daemon writes them down. Notice that mailbox-service's only part here is having registered the mailbox above and emitting the trigger — the reading and the storing are separate daemons you compose in a playbook using triggers and steps (see Playbook anatomy for the full step vocabulary):
name: invoice-intake
trigger:
event: Fact.Mail.Received
filter:
via.eq: invoices
from.email.endswith: "@supplier.com"
steps:
- id: invoice
run: ai.extract
with:
text: "${trigger.body_text}"
fields:
- { name: invoice_no, type: text }
- { name: total, type: decimal }
- { name: due_date, type: date }
- run: database.write
with:
table: invoices
row:
number: "${steps.invoice.result.invoice_no}"
total: "${steps.invoice.result.total}"
due: "${steps.invoice.result.due_date}"
Every matching email now flows straight into your database with no manual step — and because the extracted fields are typed (decimal, date), a value the model cannot coerce fails loudly instead of storing junk. To send a confirmation back, you would add one more mail.send step to the end of the same playbook — or reply in the sender's own thread, as Example 5 shows.
The same verb registers a message-queue source; you just set protocol: mqtt and give the broker settings. Here the daemon subscribes to two topics on an MQTT broker and turns every message into a Fact.Mail.Received event under the alias sensors; the optional topic_pattern names the moving parts of those topics so every event arrives with them already captured:
name: provision-sensors-feed
trigger:
event: Fact.System.Boot
filter: { component.eq: playbook-service }
steps:
- run: mail.register_mailbox
with:
alias: sensors
protocol: mqtt
mqtt:
host: mqtt.example.com
port: 1883
client_id: binions-sensors
username: binions
password: "${secret.mqtt_password}"
keep_alive_secs: 30
topic_pattern: "factory/+line/+metric"
topics:
- factory/line1/temperature
- factory/line1/pressure
The broker settings live in a block named after the protocol — here mqtt: with host, port, client_id, username, password, keep_alive_secs and the optional topic_pattern — while the topics to follow are listed under a top-level topics: key. There is no single broker_url, no subscribe_topics, and no per-subscription qos field — use the fields shown above. The topic_pattern is validated at registration time: a named segment (+line, +metric) captures one topic level, a trailing #rest names a multi-level tail, and an anonymous + matches a level without capturing it. Every message whose topic matches the pattern carries the captured values on its event as topic_params; a message on a non-matching topic simply omits the field.
The folder operations shine in pull-based workflows. Here a schedule named invoice-folder-scan (registered once with scheduler.register_schedule — see scheduler-service) drives an hourly sweep of an IMAP folder. The first playbook pulls up to 20 unread messages from Faktury; each becomes an ordinary Fact.Mail.Received, so the second playbook — which would look exactly the same for listener-delivered mail — extracts, persists, and then leaves its audit trail in the mailbox itself: flag as seen, move to Faktury/Zrobione:
name: fetch-invoice-folder-hourly
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: invoice-folder-scan
steps:
- id: pull
run: mail.fetch
with:
alias: crm-inbox
folder: "Faktury"
unseen_only: true
limit: 20
---
name: process-one-invoice-mail
trigger:
event: Fact.Mail.Received
filter:
via.eq: crm-inbox
folder.eq: "Faktury"
steps:
- id: extract
run: ai.extract
with:
text: ${trigger.envelope.body_text}
fields:
- { name: no, type: text }
- { name: total, type: decimal }
- { name: sender, type: text }
- id: persist
run: database.write
with:
table: invoices
row:
no: ${steps.extract.result.no}
total: ${steps.extract.result.total}
sender: ${steps.extract.result.sender}
- id: mark_seen
run: mail.mark
with:
alias: crm-inbox
uid: ${trigger.uid}
folder: ${trigger.folder}
flags: [seen]
- id: archive
run: mail.move
with:
alias: crm-inbox
uid: ${trigger.uid}
from_folder: ${trigger.folder}
to_folder: "Faktury/Zrobione"
The pull step's saga completes on the closing Fact.Mail.Fetched summary, after the batch's per-message events have gone out. Had any of the folder work failed — a folder renamed, a UID gone — the daemon would have said so with a Fact.Mail.OperationFailed rather than staying silent.
A customer writes in about an order; the reply carries an HTML table of their order lines, rendered from an inline template, and lands in their thread rather than starting a new one. Note the threading pair — in_reply_to takes the incoming message_id, and references passes the chain along (omitted, it would default to [in_reply_to]):
name: reply-order-summary-in-thread
trigger:
event: Fact.Mail.Received
filter:
via.eq: crm-inbox
subject.contains: "zamowienie"
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: crm-outbound
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>Dzien dobry {{ customer }},</p>
<p>pozycje zamowienia {{ order_no }}:</p>
<table border="1" cellpadding="4">
<tr><th>Pozycja</th><th>Ilosc</th><th>Cena</th></tr>
{% for r in rows %}
<tr><td>{{ r.item }}</td><td>{{ r.qty }}</td><td>{{ r.price }}</td></tr>
{% endfor %}
</table>
template_text: |
Dzien dobry {{ customer }},
pozycje zamowienia {{ order_no }}:
{% for r in rows %}- {{ r.item }} x{{ r.qty }} po {{ r.price }}
{% endfor %}
The template does the shaping — the loop over rows lives in the template, not the playbook — the HTML variant is autoescaped, and a plain-text alternative rides along for clients that prefer it. If the template referenced a variable that vars did not supply, the send would fail loudly instead of delivering a half-rendered reply.
mailbox-service is a multi-provider broker: a single set of operations works across seven inbound transports plus outbound email, and the protocol field on mail.register_mailbox selects which one a given source uses. That keeps your playbooks identical in shape whether a message originates as an email, a queue message, or a live feed frame.
| Protocol | Direction | What it is for | Key settings |
|---|---|---|---|
| IMAP (default) | Inbound | Watching an email inbox — the most common source. Also the protocol behind the five folder operations. | host, port (usually 993), username, password |
| MQTT | Inbound | Subscribing to a lightweight pub/sub broker — ideal for IoT and sensor feeds. | host, port, client_id, username, password, keep_alive_secs, topics, optional topic_pattern |
| AMQP | Inbound | Consuming from a message-queue broker — for queue-based integration between systems. | Broker connection and the queue to consume |
| Redis | Inbound | Subscribing to Redis pub/sub channels. | url (full connection URL) |
| WebSocket | Inbound | Following a live ws:///wss:// feed — e.g. an exchange market stream. Entries in topics are sent verbatim as frames right after connect (how exchanges expect subscriptions). | url, optional headers |
| SSE | Inbound | Following a server-sent-events stream. | url, optional headers |
| Kafka | Inbound | Consuming a Kafka topic (available in kafka-enabled builds). | brokers, group_id, optional auto_offset_reset |
| SMTP | Outbound | Sending email via mail.send. | Server, port, and credentials in the [smtp] config block |
If you omit protocol when registering, it defaults to imap. Outbound email always goes over SMTP, configured once per mailbox rather than per mail.send call — so a send step only needs the sending alias, the recipient, the subject, and a body. That body is either the literal text you give (body_text for plain text, body_html for HTML, or both) or an inline template (template_text / template_html) rendered with your vars, as described under Operations. Outbound over a broker uses the same verb with payload + target: on an MQTT / AMQP / Redis / WebSocket mailbox (and Kafka in kafka-enabled builds) the frame is published on that mailbox's connection, and the resulting Fact.Mail.Sent is stamped with the transport used and the target it went to.
Broker messages are usually machine payloads, so Fact.Mail.Received does the routine decoding for you. When a frame's payload is JSON — declared by its content type, or sniffed because the body starts with { or [ — and no larger than 256 KiB, the event carries it pre-parsed as body_json alongside the raw body. A playbook reads ${trigger.body_json.temp} directly — no AI step just to reach a number. Combined with the topic_params captured by an MQTT topic_pattern (Example 3), an IoT flow becomes a filter and a write:
name: line-temperature-to-sql
trigger:
event: Fact.Mail.Received
filter:
via.eq: sensors
topic_params.metric.eq: temperature
steps:
- run: database.write
with:
table: sensor_readings
row:
line: ${trigger.topic_params.line}
temp: ${trigger.body_json.temp}
The line id comes from the topic, the reading from the pre-parsed JSON body (here a frame like {"temp": 61.4}) — no string surgery, no extraction model, just dot-paths.
Configuration lives in the daemon's application.toml. The [redis] block points at the dedicated Redis instance that holds the daemon's state and registered mailboxes; [healthcheck] exposes the local health and metrics server; the mailbox/broker and [smtp] blocks hold the connection details for the sources you watch and the server you send through; and an optional [otel] block enables distributed tracing. Credentials are always read from password or secret files, never written inline.
# /opt/binions/mailbox-service/config/application.toml
[redis]
port = 6397
password_file = "/etc/binions/secrets/mailbox-redis.pass"
[healthcheck]
listen_addr = "localhost:9107"
# Outbound email server used by mail.send
[smtp]
host = "smtp.example.com"
port = 587 # 587 STARTTLS, or 465 for implicit TLS
username = "binions@example.com"
password_file = "/etc/binions/secrets/smtp.pass"
from = "binions@example.com"
# Optional: send traces to a collector for end-to-end visibility
# [otel]
# endpoint = "http://collector.example.com:4317"
The inbound sources you watch are normally created at runtime with mail.register_mailbox (as in the examples above), so the bulk of the file is the Redis connection, the health endpoint, and your outbound SMTP server. As with every daemon, secrets are referenced by file — see Secrets & credentials for how the secret files are laid out and how ${secret.…} references resolve inside playbooks.
mailbox-service emits events in the Fact.Mail.* and Fact.Mailbox.* families. The one your playbooks react to most is Fact.Mail.Received; the folder and send operations answer with their own facts (which is how a calling step's saga knows it is done); and the rest report what the daemon did or observed, useful for observability and troubleshooting.
Fact.Mail.ReceivedEvery attachment on an incoming message is described on the event, and its bytes travel by one of three routes, chosen by size:
content_b64, ready for the next step to decode.storage: {bucket, key} reference instead of the bytes, and a follow-up data.download (with as_b64: true for binaries) fetches them when needed.Because a single attachment is by far the most common shape, the event also mirrors the first attachment as a flat first_attachment object, next to an attachments_count. The mirror is refreshed after the size policy above has run, so its content_b64 or storage reference always matches the corresponding entry of the attachments array — a playbook that wants "the PDF on this mail" reads ${trigger.first_attachment.storage.key} (or ${trigger.first_attachment.content_b64} for an inline one) without indexing into an array, typically behind a has_attachments: true trigger filter.
Every attachment always carries its SHA-256, so a consumer can verify an offloaded object end-to-end against the digest reported by the storage daemon. Thresholds and the target bucket live in the daemon's [attachments] config block; the offload route additionally needs the storage daemon's peer connection configured (a [peer_redis] entry) — without it, offload-tier attachments degrade gracefully to metadata only and the daemon says so at startup.
| Event | Meaning |
|---|---|
Fact.Mail.Received | A message arrived — delivered by a live listener or pulled by mail.fetch, in the identical shape either way. This is the event your business playbooks trigger on; it carries the alias (via), the folder, the message uid, sender, subject, and body — plus body_json for JSON broker frames, topic_params for pattern-matching MQTT topics, and the attachments described above. |
Fact.Mail.Fetched | The summary that closes a mail.fetch batch — alias, folder, count, batch_id — emitted after the batch's per-message Fact.Mail.Received events. This is the fact the fetch step's saga completes on. |
Fact.Mail.Sent | An outbound message left successfully via mail.send — an email through SMTP, or a frame published to a broker (stamped with the transport and target). |
Fact.Mail.FoldersListed | The response to mail.list_folders — the mailbox's folder list. |
Fact.Mail.Moved | A message changed folders. The fact echoes the method used — the server's native UID MOVE or the copy-and-expunge fallback. |
Fact.Mail.Marked | Flags were set (or cleared) on a message via mail.mark. |
Fact.Mail.Appended | A message was stored into a folder with mail.append. |
Fact.Mail.OperationFailed | A folder operation could not do its job — carries the op and the reason. Loud by design, and an ordinary trigger event, so an alerting playbook can react to it. |
Fact.Mailbox.Registered | A source was registered and its listener started. |
Fact.Mailbox.Unregistered | A source was unregistered and its listener stopped. |
Fact.Mailbox.Disconnected | A source's connection dropped. The listener reconnects on its own (see below); this event lets you see when it happened. |
Every event rides in the platform's standard envelope with a correlation id, so a received message, the AI step that read it, the database write, and any reply email all share one thread you can follow end to end. For the full anatomy of that envelope, see The event envelope.
The daemon runs as binions-mailbox.service under a dedicated, unprivileged mailboxsvc user, alongside its own redis-binions-mailbox.service which holds its state and registered mailboxes. Bring both up together:
sudo systemctl enable --now redis-binions-mailbox binions-mailbox
systemctl status binions-mailbox
The service is a Type=notify unit with a watchdog (WatchdogSec=30): it must report liveness within its watchdog window or systemd restarts it, so a wedged listener heals itself. Its memory is capped at MemoryMax=1G, comfortably within the platform's lightweight budget. Check health directly over the configured health endpoint:
curl -s http://localhost:9107/health/ready
curl -s http://localhost:9107/health/live
curl -s http://localhost:9107/metrics
Inbound connections do not stay up forever — a mail server restarts, a network blips, a broker is briefly unreachable. When an IMAP connection drops, the listener reconnects on its own, backing off between attempts so a flapping server is not hammered, and resumes watching once the connection is restored. Each drop is reported as a Fact.Mailbox.Disconnected event, so you have a record of when connectivity wobbled even though recovery is automatic.
If messages stop arriving. Confirm the source is still registered, check the credentials in its secret file are current, and look for recent
Fact.Mailbox.Disconnectedevents that have not been followed by a reconnection. For send problems, verify the[smtp]settings and that the SMTP port (587 or 465) is reachable from the host. And for the folder operations, the answer is always on the bus: a failed one leaves aFact.Mail.OperationFailednaming theopand thereason.