Email Voice

@tuttiai/email — give agents IMAP IDLE inbound + SMTP outbound with proper RFC 5322 threading

The Email voice gives agents the ability to read incoming mail (via IMAP IDLE — server-pushed, not polled) and send outgoing mail with proper threading headers. It also exports the shared EmailClientWrapper that @tuttiai/inbox’s email adapter consumes.

Three tools ship: send_email and send_reply are marked destructive: true; list_inbox is read-only.

Installation

npx tutti-ai add email

Required permissions

permissions: ["network"]

Required environment variables

VarUsed for
TUTTI_EMAIL_PASSWORDShared fallback for both IMAP and SMTP. Use this when both connections share a password (Gmail with an app password, IONOS, most providers).
TUTTI_EMAIL_IMAP_PASSWORDIMAP-only override.
TUTTI_EMAIL_SMTP_PASSWORDSMTP-only override.

For Gmail / Outlook with 2FA, basic auth is disabled — generate an app-specific password.

Score example

import { EmailVoice } from "@tuttiai/email";
import { defineScore } from "@tuttiai/core";

export default defineScore({
  agents: {
    support: {
      name: "support",
      system_prompt: "You are an email support agent. Read incoming mail and send threaded replies.",
      voices: [
        new EmailVoice({
          imap: { host: "imap.example.com", port: 993, user: "bot@example.com" },
          smtp: { host: "smtp.example.com", port: 587, user: "bot@example.com" },
          from: "Tutti Bot <bot@example.com>",
        }),
      ],
      permissions: ["network"],
    },
  },
});

Tools

ToolDescription
send_emailSend a fresh email. Single string or array of recipients; cc / bcc supported.
send_replyReply with proper In-Reply-To and References headers. The in_reply_to Message-ID comes from list_inbox (or from the inbox event payload). The tool appends the parent to References if you didn’t already include it.
list_inboxRead-only summary of recent messages — Message-ID, from, subject, date. Defaults to UNSEEN, capped at 50 entries.

Inbound (inbox)

The wrapper exposes subscribeMessage(handler) powered by IMAP IDLE — the server pushes new mail; no polling loop. @tuttiai/inbox’s EmailInboxAdapter consumes this. A score that wires both the voice (outbound tools) and the inbox adapter (inbound) shares one EmailClientWrapper via EmailClientWrapper.forKey("host:port:user", …) — one IMAP connection, one SMTP transporter.

inbox: {
  agent: "support",
  adapters: [{
    platform: "email",
    imap: { host: "imap.example.com", port: 993, user: "bot@example.com" },
    smtp: { host: "smtp.example.com", port: 587, user: "bot@example.com" },
    from: "Tutti Bot <bot@example.com>",
  }],
}

Threading

When the inbox adapter delivers a reply, it sets:

  • In-Reply-To: <inbound-message-id> — the message it’s replying to.
  • References: <existing-chain> <inbound-message-id> — full chain for multi-turn threads.
  • Subject: Re: <original> — idempotent (no double Re: if already present).

This is what mail clients use to render threaded conversations; without it, every reply shows up as an unrelated email.

Defence-in-depth

SurfaceDefault
Oversized body filter (IMAP SIZE flag, applied before parsing)drop at > 1 MB plaintext (configurable via maxBodyChars)
Inbound text redaction (SecretsManager.redact)on by default — strings shaped like sk-…, AWS keys, etc. are stripped before the agent ever sees them. Opt out via inboxRedactRawText: false for agents that legitimately need to handle credentials.
Bounded threading cacheLRU, 1000 entries, in-memory. Older threads can’t be replied to via the inbox adapter; agents that need older threads should use send_email with the recipient address explicitly.

Lifecycle

The IMAP connection is established lazily on the first subscribeMessage / list_inbox / explicit launch() call, and stays open with IDLE-driven push. SMTP transports are pooled by nodemailer and created on first send. Call voice.teardown() (or TuttiRuntime.teardown()) on shutdown — both connections are closed cleanly.

Edit this page on GitHub →