ctx.email sends mail. It has one verb. It does not read mail and reading a mailbox is not part of this capability at all (framework/email-types.ts).
#Sending
ctx.email.send(credentialName, message) sends one message and returns { messageId }. The message is { to, subject, body, attachments? }, where to is one address or an array and attachments is a list of file names.
The SMTP settings are resolved from the vault under credentialName. The engine reads host, user, password, from, port and security. A missing name or a missing field is refused with EMAIL_CREDENTIAL_MISSING before any network call and the message names the field that is absent. The security policy must be tls or starttls; cleartext is refused with EMAIL_TLS_REQUIRED, also before any transport is created. There is no fallback to plaintext.
Attachments resolve through the ctx.files jail, so the automation has to declare 'files' as well as 'email'. Without the files facade, an attachment is refused with EMAIL_FILES_REQUIRED. The bytes are read as bytes, not as text.
const messageId = await ctx.email.send('mail-smtp', {
to: customer.email,
subject: `Overdue reminder for ${customer.id}`,
body: `Your outstanding balance is ${customer.balance}.`,
attachments: ['out/reminder.csv'],
});
ctx.log.info(`Sent ${messageId.messageId}`);
#Where receiving lives
There is no read, no IMAP, no poll and no inbox-as-queue in ctx.email. The contract is send-only and a recorded taxonomy ruling kept reading out of the Robot. Reading a mailbox happens at the Orchestrator seam instead, as the mailbox watcher, which saves attachments and triages them; the Robot stays a pure function of its staged input. The mailbox page covers that path.
#What state this capability is really in
The send engine is finished. It was proved against a scratch vault and a loopback TLS capture server and the capability audit recorded that engine as WORKING.
The live audit row for email-send still reads UNPROVEN and it cannot read otherwise by construction. The probe targets the vault credential named mail, whose shape is [password, username], while the SMTP path needs [host, user, password, from]. The engine refuses with EMAIL_CREDENTIAL_MISSING on host, before a transport exists. That is a provisioning gap in the estate, not an engine gap. Separately, one real send was proved live through an additive untyped vault asset mail-smtp carrying all four fields and the SMTP server accepted it, returning a message id. That proof does not move the audit row.
The receive row, email-receive, is also UNPROVEN in the audit: the watcher lane was live, but no audit loop can hold the live IMAP IDLE session, so the row is reported at that honest word.
Two limits are worth knowing. The facade returns only messageId, so the transport's own accepted, rejected and response lines are discarded and are not observable through ctx.email. And a subject that carries non-ASCII text arrives as an RFC 2047 encoded word, because the MIME encoder rewrites the header; an ASCII subject stays byte-literal.
The vault's credential type was later widened to declare host, user, from, port and security, so a typed credential can now carry the SMTP field names. The live mail-smtp asset is still untyped and re-sealing it with the proper type is an operator-owned act.
#Things that catch people out
Declare 'email' and 'files' when you attach anything.
A credential name is not a value: the automation passes the name and the engine resolves the settings. A literal host or password in automation code has nowhere to go.
The once-guard matters. A send that a retry policy re-runs could put a second message on the wire, so an automation that must send at most once should set retry: { maxAttempts: 1 }.