ai.oz0/email
For: tenant administrators and plugin users.
Use ai.oz0/email to send reports, replies and files produced by tools. Every message passes through the edge, which enforces the mail rules independently of plugin code and model decisions:
- Your own domains only, by default. A recipient outside the tenant's mail domains is refused, and the message is not sent. A tenant has no mail domains until an admin names them, so nothing is sent until then.
- A person approves each send, unless the tenant's tool policy allows it. The approval shows the recipients, the subject and the body, so the person reads what will leave.
- The AI label goes at the start of each body, in the tenant's words (AI labels), and every
message has
Auto-Submitted: auto-generated, so out-of-office replies stay away. - Once per call, once recorded. The Message-ID comes from the call's idempotency key, and a call the platform retries sends nothing again once its first try is recorded. A first try that was sent but not recorded (through Microsoft Graph, mostly) can send again on a retry: see Limits.
- Attachments are the job's own files: up to four files the job's tools wrote, by their
oz0-file:URIs. Bytes from anywhere else are refused. - Every message sent is recorded in the audit trail as
mail.sent.
It sends only: it reads no mailbox.
| Function | Reads or writes | What it does |
|---|---|---|
oz0_email.send | writes (open world), waits for approval | Sends one message: to and cc (at most 50 together), subject, text, an optional html and up to 4 attachments with their attachment_names. Returns the Message-ID, the transport, how many recipients and their domains, the size and each attachment's SHA-256 |
oz0_email.check | reads | The same arguments: which recipients the tenant's rules allow and why the others are refused, the sender, whether the way out is ready, and the message as it would go, with the label. Sends nothing |
Install
oz0 plugin install hub:ai.oz0/email --accept-reads mail
oz0 tenant mail-domains add acme acme.se --reason "agents mail our own staff"
--accept-reads mail is the grant to send mail through the edge, which a tenant admin gives at install; without it
the install is refused and says so. oz0 plugin revoke-reads email mail takes it back, and the plugin's next call
sends nothing. Your agents send mail by naming oz0_email.send in their tools and their plugin's
uses:. Or they hand the work to the plugin's delegate agent email, which calls only
check and send; its eval replays a recorded answer offline.
The hub is not public yet; until it is, install from a checkout of the repository: oz0 plugin install plugins/email --accept-reads mail.
The tenant's mail domains
oz0 tenant mail-domains list acme
oz0 tenant mail-domains add acme acme.se ops.acme.se --reason "the pilot's two teams"
oz0 tenant mail-domains remove acme ops.acme.se --reason "the ops team left the pilot"
Each domain counts alone: ops.acme.se is a domain of its own, and no wildcard is taken. The domains are a
governance setting (mail_domains): every change takes a
reason, and a domain added waits for another admin while the tenant has four eyes on. A removal applies at once.
Other domains
oz0 tenant mail-outside acme on --reason "the support agent answers customers"
oz0 tenant mail-outside acme off --reason "pilot ended"
Off by default. On lets agents' mail go to any domain, such as customers' and suppliers'. Turning it on loosens a
governance control (mail_outside): a reason, and a second admin under four eyes. Whether and when your organisation
may mail people outside it, for example under marketing law, is for your organisation to decide: the switch decides
nothing about it.
The sender
oz0 tenant mail-sender acme [email protected]
oz0 tenant mail-sender acme --clear
The sender must be in the tenant's mail domains; removing its domain clears the sender. Through Microsoft Graph, agents' mail goes from the sender's mailbox. Through the operator's relay, it goes from the sender only when the operator lets the sender's domain through the relay for that tenant, naming the tenant and the domain:
orchestrator-zero server start ... --smtp-from [email protected] --smtp-sender-domains acme=acme.se --smtp-sender-domains acme=ops.acme.se
Otherwise, and when no sender is set, it goes from the relay's own address (--smtp-from), with the tenant's sender
as Reply-To, so replies still reach the tenant.
A tenant names its mail domains itself, and two tenants may name the
same one, so this is how the relay's operator keeps a tenant from sending as a domain that is not its own, the
operator's or another tenant's included: a domain listed for tenant acme counts for acme alone.
check says which address a message would go from and where replies would go. Each domain is listed exactly: a
subdomain is listed on its own.
Receiving servers check the From domain against what the sending service signs for. For DMARC to pass, the From domain must align with the SPF domain of the envelope sender or with the DKIM signature's domain: publish SPF and DKIM for your relay (or, with Microsoft Graph, for your Microsoft 365 domain) under the sender's domain. A sender the relay does not sign for lands in spam or is rejected. Who the sender of an agent's mail is in law is for your organisation to decide; the platform sends from the address you set.
How mail leaves
The tenant picks one with the plugin's setting EMAIL_TRANSPORT.
Through the operator's relay (the default)
The edge sends through the SMTP relay the cluster's operator configured, the one notifications
use, over STARTTLS or TLS with the relay's certificate verified. Nodes never reach the relay; the plugin hands the edge
the message and the edge sends it. Without a relay (--smtp-addr unset), send says so and check reports the way
out as not ready.
Through Microsoft Graph, as your tenant
oz0 setting set EMAIL_TRANSPORT graph
oz0 tenant mail-sender acme [email protected]
oz0 connection start email-graph
- Register an app in the Microsoft Entra admin center (single tenant), give it the application permission
Mail.Sendon Microsoft Graph, and grant admin consent. - Limit the mailboxes it may send as: by default
Mail.Sendlets an app send as any mailbox of the tenant. In Exchange Online, an application access policy (or RBAC for Applications) restricts it to your sender's mailbox. oz0 connection start email-graph(or Credentials / Connections in the console): paste the app's client ID and tenant ID, then a client secret. The edge asks your tenant's token endpoint for a Graph token with the secret before it seals it; a secret Entra refuses is refused there.
Each message is checked and rendered on the edge, which mints the app's token for that message only (a client
credentials grant at login.microsoftonline.com, scope https://graph.microsoft.com/.default); the plugin's server
posts the rendered message to POST https://graph.microsoft.com/v1.0/users/<sender>/sendMail and the edge records it.
graph.microsoft.com is the plugin's only egress. Graph keeps a copy in the sender's Sent Items. Where Graph processes
and keeps the mail is your Microsoft 365 tenant's region.
What is recorded
mail.sent names the job, the agent, the plugin and function, the transport (relay or graph), the Message-ID (the
entry's target), how many recipients and their domains, the size, each attachment's SHA-256 and size, and with Graph
its request ID. mail.refused names the job, how many recipients were refused and their domains. Neither holds an
address, a subject or a body: those stay where the job's history keeps every call's arguments, under the tenant's
retention and erasure, as the call's own arguments. The plugin keeps nothing of its own.
A send through the relay is recorded as mail.sending before the relay has the message, and as mail.unsent when the
relay refuses it. A send that cannot be recorded is not made, and a call tried again after a send whose outcome was
never recorded is refused (outcome_unknown) rather than sent a second time: check whether it arrived, and send it
under a new call if it did not. A plugin that sends through its own service records with sent, which fails until
its record is written, so the plugin sends the receipt again.
Limits
- No receiving. It sends only.
Attachments
A message may attach up to 4 job files from the calling job's tree, at most 10 MiB together through the relay and 2 MiB through Microsoft Graph. Graph's request takes the whole message base64-encoded and at most 4 MB, and attachments are base64 inside the message too, so the edge also refuses a message whose request would exceed 4,000,000 bytes (long bodies besides attachments near the limit). check says so before the approval; send refuses such a message before anything is sent. A file of another job is refused.
- Addresses are plain ASCII addresses without a display name (no internationalised addresses); at most 50 recipients a message. The subject is one line of at most 255 characters; the text body is required (at most 256 KiB), the HTML body optional (at most 512 KiB).
HTML loads nothing and runs nothing
The edge refuses scripts, frames, forms, linked style sheets, event handlers and images or styles from other hosts (a remote image tells its host who opened the mail), and Outlook's conditional comments (<!--[if mso]>), whose content only Outlook reads. Images may be cid: sources or the data of a PNG, JPEG, GIF or WebP image (data:image/png;base64,...), never SVG or another type, and so must every candidate of a srcset, which the edge reads as the HTML standard does; a candidate may say only a density (2x) or a width (300w).
A source or link is judged as a mail client reads it: the tabs and line breaks the URL standard drops are dropped first, and one that holds another control character is refused. Links may be https, http, mailto or a fragment. An element may carry only attributes that format, describe or link it (style, class, width, align, alt, href, target, table attributes, aria-*, data-* and the like) and its source (src, srcset, background, poster); any other, such as ping, is refused.
A body made of many runs of text that only ignored tags separate (end tags that close no element, table cells outside a table, text in a table outside its cells, which the parser moves before the table) is refused too: the edge's HTML parser joins such runs into one at a cost that grows with the square of their number. Ordinary formatting, long tables and lists pass.
HTML cannot hide the AI label
The edge reads the HTML as a browser does, puts the label first in its body and everything you wrote after it in one <div> of its own, and sends the document as it read it, so markup may come back written slightly differently (<html>, <head> and <body> added, attributes quoted, your content inside that <div>) and what was written inside <head> is in the body, after the label. check shows the HTML as it will go.
The label's paragraph is stacked above the rest, and the <div> clips what your content paints to its own box, so in a mail client that reads either of those styles nothing you write can paint over the label. A <dialog>, which a browser lays out apart from the content around it and so outside that box, is refused. For a client that reads neither, the edge also limits what a style may do. <style> elements are refused (style an element with its style attribute), and <html>, <head> and <body> carry only lang, dir, class and id.
A style attribute is read as a mail client's CSS parser reads it (comments, strings, a vendor prefix such as -webkit- judged as the property it prefixes).
Permitted CSS and size limits
A style may set text, colour, font, spacing, border, size, display and table properties. Colour, calc(), min(), max(), clamp() and gradient functions are allowed within these limits.
Inline elements, including <span>, links, <b> and elements styled as display:inline, may have at most 4px of top padding and top border. Those values paint above the line without moving other content. Put larger padding on a block or table cell, or use display:inline-block.
Text may be at most 64px at any nesting depth. A style may specify 64px, 48pt, 4rem or a keyword no larger than xxx-large. An em or percentage size may not enlarge its parent: at most 1em or 100%.
Markup that enlarges text is counted at its nested position. An <h1> inside a 48px <span>, or four nested <big> elements, is refused unless the element gets an acceptable size of its own. This check also applies to headings and <font size>. text-size-adjust must be none, auto or at most 100%.
A line height must be normal or a number of at least 1.3. Use line-height:1.5; 24px and 1 are refused.
Allowed display values are block, inline, inline-block, list-item, flow-root, table displays, flex, inline-flex, contents and none.
Refused CSS
Everything else is refused: what moves or stacks an element (position, top, inset, transforms, z-index), what paints outside it (shadows, outlines, filters, border images), a reversed or end-aligned flex layout, a writing mode, custom properties and var(), negative margins, a text decoration's thickness, Outlook's mso- properties, and a style the edge cannot read as written (an escape, a string or comment left open).
Remaining rendering limit
These checks keep text near its line but cannot bound every glyph's ink. If a mail client honours neither stacking nor clipping, tall glyphs or stacked accents in any font can still paint over the AI label.
Checked messages and delivery
The edge sends only the message it checked. The relay accepts the edge-composed message under tenant rules: checked HTML, the AI label, recipients within tenant domains and an allowed sender. It also checks the sender against --smtp-sender-domains itself.
A message accepted by the relay counts as sent. If the relay then drops the connection, the send does not fail and a retry does not send it again.
Each tenant uses one outbound path: the relay or Microsoft Graph.
Retry limits
A retried call sends nothing again once its first attempt is recorded. With Graph, a retry can send twice if Graph accepted the first attempt but its record never reached the edge. Graph's sendMail has no idempotency key. The same limit applies when the relay accepted a message that the edge could not record.
Graph throttling with 429 or 503 returns a retryable error. The platform does not wait and retry automatically.
Test coverage
Tests use a fake SMTP relay and fake Microsoft Graph and Entra services based on Microsoft's documentation. The plugin has not been tested against a live mail server or Microsoft 365.
"Verified" means the project tested, pinned and secured the plugin. Microsoft does not endorse it.
ai.oz0/public-access
For Swedish public authorities - a public-access copy of records with masking a person decides, and an E-ARK archive package, from a signed tenant export, offline.
Atlassian Cloud
The verified connector ai.oz0/atlassian - Jira, Confluence and JSM Operations on Atlassian Cloud through Atlassian's own Rovo MCP server, on your own OAuth client, every write asking a person by default.