Setting Up Email

This guide walks the reader from a blank email mailbox to a Beach-driven inbox where the orchestrator reads each incoming message, runs through its tool loop, and replies with a properly threaded RFC 5322 email — subject, In-Reply-To, References — all correct.

The wiring uses three packages:

  • @cool-ai/beach-channel-email — IMAP polling inbound and SMTP outbound.
  • @cool-ai/beach-format-email — turns the orchestrator's settled parts into a MIME-shaped artefact (HTML and plain text).
  • @cool-ai/beach-core — the Manifest that gates "send only when ready".

A Beach orchestrator is assumed to be wired already. See Getting Started for the basics; this guide concerns the email-specific pieces.

Install

npm install \
  @cool-ai/beach-channel-email \
  @cool-ai/beach-format-email \
  @cool-ai/beach-core \
  imapflow mailparser nodemailer

imapflow, mailparser, and nodemailer are peer dependencies of @cool-ai/beach-channel-email. Install them at whatever versions the application prefers.

Pre-flight — what you need

  • An IMAP-accessible mailbox (Gmail with an app password, Fastmail, an in-house provider — anything imapflow can reach).
  • An SMTP server, ideally the same provider, but it can be different.
  • A way to persist the last-processed UID across server restarts. Without this, every restart re-processes old mail. Redis is the conventional choice; for a prototype, a JSON file on disk works.

Step 1 — Configure the EmailChannel

import { EmailChannel } from '@cool-ai/beach-channel-email';
import Redis from 'ioredis';

const redis = new Redis();

const emailChannel = new EmailChannel({
  id: 'email',
  imap: {
    host: 'imap.example.com',
    port: 993,
    secure: true,
    auth: { user: 'agent@example.com', pass: process.env.IMAP_PASS! },
    mailbox: 'INBOX',
  },
  smtp: {
    host: 'smtp.example.com',
    port: 587,
    secure: false,
    auth: { user: 'agent@example.com', pass: process.env.SMTP_PASS! },
    from: 'agent@example.com',
  },
  pollIntervalMs: 60_000,
  uidState: {
    get: async (mailbox) =>
      redis.get(`beach-email:lastUid:${mailbox}`)
        .then((v) => (v ? Number(v) : undefined)),
    set: async (mailbox, uid) => {
      await redis.set(`beach-email:lastUid:${mailbox}`, String(uid));
    },
  },
  onInbound: async (missive) => {
    // We will fill this in below.
  },
});

The uidState callbacks are how the channel persists what it has already processed. On restart, the channel asks get('INBOX') for the last UID and resumes from there.

Step 2 — Wire the formatter

@cool-ai/beach-format-email produces an EmailArtifact from a settled Delivery Manifest's slots. It takes a ComposerActor — an edge-positioned LLM specialist that writes the connective prose around the structured content the orchestrator emitted.

import { EmailFormatter, buildDefaultEmailComposerPrompt } from '@cool-ai/beach-format-email';
import { ComposerActor } from '@cool-ai/beach-format';
import { callActor }     from '@cool-ai/beach-llm';

const composer = new ComposerActor({
  promptTemplate: buildDefaultEmailComposerPrompt({
    brandSignOff: 'Best regards,\nThe Travel Concierge Team',
    toneGuidance: 'Warm, professional, concise. Match the recipient\'s language.',
  }),
  actorConfig: {
    id: 'email-composer',
    model: 'claude-haiku-4-5',
    systemPrompt: '',         // overridden per compose() call
    tools: [],
  },
  callActor,
  callActorDependencies: {
    provider,
    registry: tools,
    slotKey: 'email.compose',
    sessionId: 'composer',
  },
});

const formatter = new EmailFormatter({
  composer,
  from: 'agent@example.com',
});

The Composer is allowed to know it is writing email. It is an edge-positioned actor, not interior. Its job is salutations, lead-ins, sign-offs, and inline transitions. It never sees structured content directly — prices, dates, item details — because those come from deterministic Content Renderers and are substituted into the prose by the formatter.

Step 3 — Open a Delivery Manifest per inbound

When a message arrives, the channel calls onInbound with a parsed Missive. Three things happen in order: the inbound is persisted for audit; a Delivery Manifest is opened, keyed to the inbound message id; the orchestrator runs, main_reply is filled from its settled parts, and the manifest's onComplete formats and sends.

import { Manifest, ManifestRegistry } from '@cool-ai/beach-core';
import { randomUUID }                from 'node:crypto';

const manifestRegistry = new ManifestRegistry();

emailChannel.onInbound = async (inboundMissive) => {
  // 1. Persist
  await missiveStore.write(inboundMissive);

  // 2. Open the Delivery Manifest
  const manifestId = `email-delivery:${inboundMissive.id}`;
  const manifest = new Manifest({
    id: manifestId,
    expected: ['main_reply'],
    timeoutMs: 5 * 60_000,    // 5-minute SLA on the reply
    onComplete: async (filled) => {
      const artifact = await formatter.format({
        inbound: inboundMissive,
        filledSlots: filled,
      });
      await emailChannel.send({
        to: artifact.to,
        subject: artifact.subject,
        text: artifact.text,
        html: artifact.html,
        inReplyTo: artifact.inReplyTo,
        references: artifact.references,
      });
    },
    onTimeout: async (filled) => {
      console.warn(`[email] manifest ${manifestId} timed out`);
      // Optional: dispatch a fallback ("we are looking into your message and will reply soon")
    },
  });
  manifestRegistry.register(manifest);

  // 3. Resolve the conversation, run the orchestrator, fill the manifest
  const sessionId = await resolveSession(inboundMissive);
  const turnId    = randomUUID();

  const settled = await sessionManager.runTurn({
    sessionId,
    turnId,
    slotKey:    'concierge.reply',
    actorId:    'concierge',
    actorConfig,
    provider,
    registry:   tools,
    inboundMessage: {
      role: 'user',
      content: inboundMissive.parts[0].text ?? '',
    },
  });

  manifestRegistry.deliver(manifestId, 'main_reply', { parts: settled.parts });
};

A few details worth pointing at:

  • The manifest is keyed on the inbound message id, not the session id. Two messages from the same correspondent open two distinct manifests.
  • turnId is generated up front so external callers — cancelTurn() from a "stop" handler, for instance — can target the in-flight turn before it starts.
  • onTimeout is real ops protection. A turn that hangs on a stuck specialist must not block the email outbound forever. Five minutes is generous; tune to the application's SLAs.

Step 4 — Resolve the session

Email threading is the rule for session continuity. A reply to a message belongs to the same session as its parent. RFC 5322's In-Reply-To and References headers carry the chain; imapflow parses them and mailparser exposes them on the Missive.

async function resolveSession(missive: Missive): Promise<string> {
  // 1. Try In-Reply-To — if present, look up the session that produced the original
  if (missive.inReplyTo) {
    const original = await missiveStore.findByExternalId(missive.inReplyTo);
    if (original?.sessionId) return original.sessionId;
  }

  // 2. Try the References chain — older messages in the thread
  if (missive.references) {
    for (const ref of missive.references) {
      const ancestor = await missiveStore.findByExternalId(ref);
      if (ancestor?.sessionId) return ancestor.sessionId;
    }
  }

  // 3. New thread — open a new session
  return openSession({ channelId: 'email', actors: ['concierge'] }).id;
}

Variations are common. Some applications scope the session by sender address (origin.address) rather than thread; some thread by Subject after stripping Re: and Fwd: prefixes. Pick whatever matches how the users actually behave. Beach does not impose a model.

Step 5 — Start the channel

await emailChannel.start();

The channel begins polling IMAP at the configured interval, parses new messages, and calls onInbound for each.

Step 6 — Send a test message

Send an email to agent@example.com from a personal address. Within pollIntervalMs, the orchestrator runs and the reply lands in the inbox with:

  • Subject: Re: <original subject>
  • In-Reply-To: <original Message-ID>
  • References: <original chain> <original Message-ID>
  • An HTML and plain-text body composed by the Composer, with structured content rendered by the deterministic Content Renderers.

Reply to the agent's message and check that:

  • The reply lands in the original session (continuity by threading).
  • The orchestrator sees the new message as a follow-up to the conversation, not as a new query.

Common pitfalls

No uidState. Every restart will re-read the entire mailbox and reprocess everything — possibly replying to old messages from before the agent even existed. Always wire uidState to a durable store.

pollIntervalMs set too aggressively. Sixty seconds is the default for a reason. IMAP IDLE is more responsive but Beach does not yet support it; for sub-minute latency, use a webhook-style provider (SendGrid, Mailgun) and write a separate webhook inbound handler instead.

Reply lands without In-Reply-To. The formatter takes the inbound missive's origin.messageId as the parent. If the inbound parser failed to capture the Message-ID, the reply does not thread. Check imapflow's parsed envelope and verify the inbound logging.

Reply body has placeholder tokens visible — strings such as <a2ui-surface:itinerary> or <part:response> showing through. The Composer emitted prose with placeholders, but a Content Renderer was not registered for one of the part types. The formatter logs the unsubstituted token; register the missing renderer and rerun.

Long-running turns time out the Delivery Manifest. If the orchestrator dispatches research that takes six minutes and the manifest's timeout is five, the email never sends. Either raise the manifest timeout to cover the worst case, or send an interim "we are looking into this and will reply within X" email synchronously at inbound time and let the full reply come later through a separate outbound flow.

Bcc'd or forwarded mail loops. An auto-replier responding to an auto-replier responding to an auto-replier is the canonical email-bot disaster. Add a Precedence: auto_reply header to outbound mail and check inbound headers — drop messages with Precedence: list or Precedence: auto_reply rather than replying. The channel exposes raw headers so the consumer can implement the filter.

What this leaves you with

A Beach application that reads its own email and replies. Same orchestrator, same tool loop, same audit trail as the chat path; only the inbound and outbound shape differs. Switch to chat tomorrow and the orchestrator does not change.

Related