---
title: "ai.oz0/email"
description: "Agents send mail for their jobs to the tenant's own domains, with the AI label and approval by default, through the operator's SMTP relay or Microsoft Graph, every message recorded."
canonical_url: "https://docs.oz0.ai/build/plugins/verified/email"
---
# ai.oz0/email

> Agents send mail for their jobs to the tenant's own domains, with the AI label and approval by default, through the operator's SMTP relay or Microsoft Graph, every message recorded.

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](https://docs.oz0.ai/govern/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](https://docs.oz0.ai/govern/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](https://docs.oz0.ai/build/plugins/verified/email#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](https://docs.oz0.ai/govern/audit-trail) as `mail.sent`.

It sends only: it reads no mailbox.

<table>
<thead>
  <tr>
    <th>
      Function
    </th>
    
    <th>
      Reads or writes
    </th>
    
    <th>
      What it does
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        oz0_email.send
      </code>
    </td>
    
    <td>
      writes (open world), waits for approval
    </td>
    
    <td>
      Sends one message: <code>
        to
      </code>
      
       and <code>
        cc
      </code>
      
       (at most 50 together), <code>
        subject
      </code>
      
      , <code>
        text
      </code>
      
      , an optional <code>
        html
      </code>
      
       and up to 4 <code>
        attachments
      </code>
      
       with their <code>
        attachment_names
      </code>
      
      . Returns the Message-ID, the transport, how many recipients and their domains, the size and each attachment's SHA-256
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        oz0_email.check
      </code>
    </td>
    
    <td>
      reads
    </td>
    
    <td>
      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
    </td>
  </tr>
</tbody>
</table>

## Install

```bash [Terminal]
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:`](https://docs.oz0.ai/reference/plugin-manifest/dependencies#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

```bash [Terminal]
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](https://docs.oz0.ai/govern/reviewed-changes#four-eyes-for-governance-changes) (`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

```bash [Terminal]
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

```bash [Terminal]
oz0 tenant mail-sender acme agents@acme.se
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:

```bash [Terminal]
orchestrator-zero server start ... --smtp-from oz0@hosting.example --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](https://docs.oz0.ai/admin/notifications#email)
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

```bash [Terminal]
oz0 setting set EMAIL_TRANSPORT graph
oz0 tenant mail-sender acme agents@acme.se
oz0 connection start email-graph
```

1. Register an app in the Microsoft Entra admin center (single tenant), give it the application permission
`Mail.Send` on Microsoft Graph, and grant admin consent.
2. Limit the mailboxes it may send as: by default `Mail.Send` lets 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.
3. `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](https://docs.oz0.ai/govern/personal-data), 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.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
