Email plugin

Multi-account IMAP/SMTP channel for Nexo agents. Receives messages through IMAP IDLE (with a 60 s polling fallback for servers that don't speak IDLE), sends through SMTP under a circuit-breaker, and exposes six tools (email_send, email_reply, email_archive, email_move_to, email_label, email_search) so an agent can read and act on a mailbox.

Status (Phase 81.20.x shipped 2026-05-16). Email is now a standalone subprocess plugin distributed via crates.io. Install with cargo install nexo-plugin-email; the daemon's binary-mode discovery walker auto-detects the binary, probes --print-manifest, and wires all five auto-discovery stages (pairing adapter, HTTP routes, admin RPC, Prometheus metrics, dashboard) without any daemon-side code change. The daemon binary no longer compiles nexo-plugin-email in-tree (cargo tree -i nexo-plugin-email returns "did not match any packages").

Install

cargo install nexo-plugin-email          # latest crates.io release

The binary lands in $HOME/.cargo/bin/nexo-plugin-email. The daemon's PluginDiscoveryConfig::default() already includes that directory in its search_paths, so a fresh nexo daemon boot finds the plugin without manifest editing. The walker spawns nexo-plugin-email --print-manifest, captures the bundled TOML, and registers the plugin's 12 tools + 5 manifest sections via the generic auto-discovery contract.

If your environment hardens against arbitrary binary execution during boot, set plugins.discovery.auto_detect_binaries: false in config/discovery.yaml and add an explicit nexo-plugin.toml reference under search_paths instead.

Configuration

config/plugins/email.yaml — multi-account schema. Credentials live in nexo-auth (Phase 17), not in this YAML; see Per-account credentials below.

email:
  enabled: true
  max_body_bytes: 32768           # body_text truncation
  max_attachment_bytes: 26214400  # 25 MiB; oversized attachments are
                                  # written truncated and flagged
  attachments_dir: data/email-attachments
  outbound_queue_dir: data/email-outbound
  poll_fallback_seconds: 60       # used when IDLE isn't supported
  idle_reissue_minutes: 28        # < RFC 2177's 29-minute ceiling
  spf_dkim_warn: true             # boot-time DNS check, non-fatal

  loop_prevention:
    auto_submitted: true          # RFC 3834
    list_headers: true            # List-Id / List-Unsubscribe / Precedence
    self_from: true               # bounce-back from our own outbound

  accounts:
    - instance: ops
      address: ops@example.com
      provider: custom            # gmail | outlook | yahoo | icloud | custom
      imap: { host: imap.example.com, port: 993, tls: implicit_tls }
      smtp: { host: smtp.example.com, port: 587, tls: starttls }
      folders:
        inbox:   INBOX
        sent:    Sent
        archive: Archive
      filters:
        from_allowlist: []
        from_denylist:  []

Topics: plugin.inbound.email.<instance> (parsed inbound), plugin.outbound.email.<instance> (commands you publish to send), plugin.outbound.email.<instance>.ack (per-message ack), and email.bounce.<instance> (DSNs).

Per-account credentials

secrets/email/<instance>.tomlchmod 0o600 enforced at boot. Three auth kinds are supported.

# Password (app password works fine for Outlook / iCloud / Yahoo).
[auth]
kind = "password"
username = "ops@example.com"
password = "${EMAIL_OPS_PASSWORD}"

# Pre-issued OAuth2 bearer (bring-your-own-token).
[auth]
kind = "oauth2_static"
username = "ops@gmail.com"
access_token  = "${EMAIL_OPS_TOKEN}"
refresh_token = "${EMAIL_OPS_REFRESH}"   # optional
expires_at    = 1735689600                # optional unix sec

# Reuse an account already in `config/plugins/google-auth.yaml`.
[auth]
kind = "oauth2_google"
username = "ops@gmail.com"
google_account_id = "ops"

${ENV} placeholders are resolved at boot via nexo_config::env::resolve_placeholders. The OAuth2-Google variant delegates token reads to the Google credential store and shares its per-account refresh mutex so concurrent IMAP IDLE workers never race a token rotation.

Provider auto-detect

The setup helper provider_hint(domain) recognises five families out of the box:

DomainProviderIMAP hostSMTP host
gmail.com, googlemail.comGmailimap.gmail.com:993smtp.gmail.com:587
outlook.com, hotmail.com, live.com, msn.comOutlookoutlook.office365.com:993smtp.office365.com:587
yahoo.com, yahoo.co.uk, ymail.com, rocketmail.comYahooimap.mail.yahoo.com:993smtp.mail.yahoo.com:587
icloud.com, me.com, mac.comiCloudimap.mail.me.com:993smtp.mail.me.com:587
anything elseCustom(prompt)(prompt)

Gmail addresses also get a suggest_oauth_google = true hint so the wizard offers to reuse google-auth.yaml instead of asking for an app password.

Tools

The agent gets six tools when the email plugin is active:

ToolPurpose
email_sendSend a new message. from is pinned to the account address (anti-spoof).
email_replyFetch the parent by UID, derive recipients (reply_all adds parent.To/Cc minus own), inherit In-Reply-To / References.
email_archiveUID MOVE to the configured archive folder; falls back to COPY + STORE \Deleted + EXPUNGE.
email_move_toSame as archive but to an arbitrary folder (no auto-create).
email_labelGmail-only: STORE +X-GM-LABELS / -X-GM-LABELS. Errors on non-Gmail.
email_searchPortable JSON DSL → IMAP SEARCH atoms. Default limit 50, max 200.

Every result is wrapped in a { ok: bool, ... } envelope. Errors become { ok: false, error: "..." } rather than thrown exceptions so the agent doesn't have to branch on exception types.

email_search query shape:

{
  "instance": "ops",
  "folder": "INBOX",
  "query": {
    "from": "alice@x", "to": "bob@x",
    "subject": "report", "body": "kpi",
    "since": "2024-01-01", "before": "2024-12-31",
    "unseen": true, "seen": false
  },
  "limit": 50
}

User-controlled strings pass through imap_quote (RFC 3501 quoted-string + CR/LF collapse) before reaching the wire — that's the security boundary against atom injection.

Outbound attachments are referenced by file path; the dispatcher reads the bytes at enqueue time so a missing file fails fast with ack: Failed instead of parking a doomed job:

{
  "instance": "ops",
  "to": ["alice@x"],
  "subject": "Report",
  "body": "see attached",
  "attachments": [
    { "data_path": "/tmp/q3.pdf", "filename": "q3.pdf" }
  ]
}

Inbound events

Published as JSON on plugin.inbound.email.<instance>:

{
  "account_id": "ops@example.com",
  "instance": "ops",
  "uid": 42,
  "internal_date": 1700000000,
  "raw_bytes": "<.eml bytes (binary-safe via serde_bytes)>",
  "meta": {
    "message_id": "<abc@x>",
    "in_reply_to": "<parent@x>",
    "references": ["<root@x>", "<parent@x>"],
    "from": { "address": "alice@x", "name": "Alice Doe" },
    "to":   [{ "address": "ops@example.com" }],
    "cc":   [],
    "subject": "Re: hi",
    "body_text": "...",
    "body_html": null,
    "date": 1700000000,
    "headers_extra": { "list-id": "<l@x>" },
    "body_truncated": false
  },
  "attachments": [
    {
      "sha256": "abc...",
      "local_path": "data/email-attachments/abc...",
      "size_bytes": 4096,
      "mime_type": "application/pdf",
      "filename": "report.pdf",
      "disposition": "attachment",
      "truncated": false
    }
  ],
  "thread_root_id": "<root@x>"
}

thread_root_id is the canonical session key — pass it through session_id_for_thread() (UUIDv5) to bridge into nexo-core's session map.

Bounce events

Delivery reports never reach the LLM as conversational content. They publish on email.bounce.<instance>:

{
  "account_id": "ops@example.com",
  "instance": "ops",
  "original_message_id": "<our-outbound@example.com>",
  "recipient": "ghost@unknown.com",
  "status_code": "5.1.1",
  "action": "failed",
  "reason": "smtp; 550 5.1.1 user unknown",
  "classification": "permanent"
}

classification follows SMTP convention: 5.x.xpermanent, 4.x.xtransient, anything else → unknown. The detector fires on a Content-Type: multipart/report; report-type=delivery- status envelope; legacy Postfix / sendmail bounces without that marker are caught via a From localpart heuristic (MAILER-DAEMON, mail-daemon, mail.daemon, postmaster).

Loop-prevention

After parse, before publish, the worker walks LoopPreventionCfg in priority order and short-circuits on the first match:

ReasonTrigger
auto_submittedAuto-Submitted header is anything other than no (RFC 3834).
list_mailList-Id or List-Unsubscribe present (RFC 2369).
precedence_bulkPrecedence: bulk|junk|list (RFC 2076).
self_fromInbound From matches the account's own address.
dsn_inboundparse_bounce returned Some (handled before loop walk).

Each suppressed message advances the IMAP cursor — it has been processed, just not surfaced.

SPF / DKIM boot warns

When spf_dkim_warn: true, each account triggers a 3 s non-blocking DNS lookup at start. WARN lines are operator-actionable:

TagMeans
email.spf.missingNo v=spf1 TXT record at the apex of the From domain.
email.spf.misalignmentSPF policy exists but doesn't authorise the configured SMTP host.
email.dkim.missingNo TXT at default._domainkey.<domain>. Try selectors default, google, selector1, mail.
email.spf_dkim.dns_unavailableThe DNS lookup itself failed. Often transient.

DMARC, multi-selector DKIM rotation, and signature verification are deliberately out of scope for v1.

Troubleshooting

  • email.idle.unsupported — the server doesn't advertise IDLE; the worker is permanently in 60 s polling mode. Yahoo Plus and some legacy IMAP servers behave this way.
  • email.uidvalidity.changed — the mailbox was recreated server-side; the cursor reset to last_uid=0 and every existing message will be processed again.
  • Outbound DLQ growing — inspect data/email-outbound/<instance>.dlq.jsonl. After 5 transient attempts (or any 5xx) jobs land here; there's no auto-purge.
  • email.auth.xoauth2_failed — the OAuth2 token was rejected. The worker retries once with a forced refresh; if it still fails the SMTP / IMAP circuit-breaker opens.
  • EMAIL_INSECURE_TLS=1 — disables TLS cert verification. Logged at WARN; only safe for fake servers / loopback.

Limitations

DeferredTracked in
Persistent bounce historyproyecto/FOLLOWUPS.md
Interactive setup wizardproyecto/FOLLOWUPS.md
greenmail e2e test harnessproyecto/FOLLOWUPS.md
Email-specific Prometheus metricsproyecto/FOLLOWUPS.md
Phase 16 binding-policy auto-filterproyecto/FOLLOWUPS.md
HTML body in outbound(text/plain only in v1)
.ics calendar invitesPhase 65
Vision OCR over attached imagesPhase 49

Deployment (Phase 81.19.b)

The email plugin is shipped as a standalone repo: nexo-rs-plugin-email (nexo-plugin-email v0.1.2+ on crates.io). The crate is dual-mode:

ModeUsed forWire path
In-processDefault — daemon registers a singleton factoryfactory_registry.register("email", email_plugin_factory(...))
SubprocessOperator drops manifest in search_paths and removes the in-tree factorydiscovery walker auto-spawns the binary via JSON-RPC stdio

By default the daemon runs the email plugin in-process, exactly as before the extract. The factory wins over discovery's auto-subprocess fallback (init_loop.rs:417), so an email manifest in plugins.discovery.search_paths does NOT spawn the subprocess unless the operator strips the in-tree factory registration.

Subprocess opt-in (advanced)

For deployments that want process-level isolation of the IMAP/SMTP work, install the binary and remove the in-tree factory:

cargo install nexo-plugin-email
mkdir -p ~/.config/nexo/plugins.d/
cp $(which nexo-plugin-email) ~/.config/nexo/plugins.d/
# Copy the manifest from $CARGO_HOME/.../nexo-plugin-email-0.1.2/
# nexo-plugin.toml into the same dir.

Then in agents.yaml:

plugins:
  discovery:
    search_paths:
      - ~/.config/nexo/plugins.d

And strip the in-tree email_plugin_factory registration from the daemon source (proyecto/src/main.rs Phase 81.19.b block). Without that strip, both paths are visible but the factory wins.

The subprocess advertises zero tool defs in its initialize reply — tool dispatch (email_send / email_reply / …) requires the in-process surface and currently doesn't work in pure subprocess mode. Follow-up 81.19.b.tool-dispatch-subprocess tracks closing that gap.