@cool-ai/beach-channel-email
v0.2.4
Published
Email edge for Beach applications — IMAP inbound and SMTP outbound, gated by Delivery Manifests.
Readme
@cool-ai/beach-channel-email
Email channel adapter for Beach. IMAP polling inbound, SMTP outbound. Designed to be gated by a Delivery Manifest from @cool-ai/beach-core's ManifestRegistry — the package itself stays thin and channel-agnostic beyond the inbound/outbound edges.
Home: cool-ai.org · Documentation: cool-ai.org/docs
Install
npm install @cool-ai/beach-channel-email imapflow mailparser nodemailerimapflow, mailparser, and nodemailer are peer dependencies so the consumer controls the version and they can be shared with other parts of the application.
Quick start
import { EmailChannel } from '@cool-ai/beach-channel-email';
const channel = new EmailChannel({
id: 'email',
imap: {
host: 'imap.example.com',
port: 993,
secure: true,
auth: { user: '[email protected]', pass: process.env.IMAP_PASS! },
mailbox: 'INBOX',
},
smtp: {
host: 'smtp.example.com',
port: 587,
secure: false,
auth: { user: '[email protected]', pass: process.env.SMTP_PASS! },
from: '[email protected]',
},
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) => {
// See "Delivery Manifest wiring" below.
},
});
await channel.start();Delivery Manifest wiring
The email channel is a batched outbound. The orchestrator's interim respond() parts ("I'm checking") must not be sent — only the final reply. The standard Beach pattern for this is the Delivery Manifest (see beach-core README § Two patterns of use).
import { ManifestRegistry, Manifest } from '@cool-ai/beach-core';
import { SessionTurnManager } from '@cool-ai/beach-session';
import { EmailChannel } from '@cool-ai/beach-channel-email';
const registry = new ManifestRegistry();
const sessionManager = new SessionTurnManager({ router });
const channel = new EmailChannel({
// ...config...
onInbound: async (missive) => {
// 1. Persist the inbound missive.
await missiveStore.write(missive);
// 2. Open a Delivery Manifest keyed by the inbound missive ID. The outbound
// email will be held until `main_reply` is filled.
const manifestId = `email-delivery:${missive.id}`;
const manifest = new Manifest({
id: manifestId,
expected: ['main_reply'],
onComplete: async (filled) => {
const reply = filled.get('main_reply') as { text: string };
await channel.send({
to: [missive.origin.address!],
subject: `Re: ${missive.subject ?? '(no subject)'}`,
text: reply.text,
inReplyTo: missive.origin.messageId,
references: (missive.destination?.metadata?.['references'] as string[]) ?? [],
});
},
});
registry.register(manifest);
// 3. Resolve a session and run a turn. The actor's final respond() fills
// main_reply; interim respond() parts are ignored by the batched edge.
const sessionId = await resolveSession(missive.origin.address!);
const turnId = randomUUID();
const settled = await sessionManager.runTurn({
sessionId,
turnId,
actorId: 'concierge',
actorConfig: { /* ... */ },
registry: tools,
provider,
inboundMessage: { role: 'user', content: missive.parts[0].text! },
slotKey: 'concierge.reply',
});
// 4. Fill the Delivery Manifest with the full parts array so the formatter can render it.
registry.deliver(manifestId, 'main_reply', { parts: settled.parts });
},
});Wire @cool-ai/beach-format-email in the onComplete handler to produce the email artifact from the Delivery Manifest:
import { EmailHtmlComposer } from '@cool-ai/beach-format-email';
const composer = new EmailHtmlComposer({
llmRender: async ({ section, narrative, envelope }) => {
// your AI SDK call — returns the prose-rendered HTML for the section
},
});
const manifest = new Manifest({
id: manifestId,
expected: ['main_reply'],
onComplete: async (filled) => {
const result = await composer.compose({
sections: [{ sectionId: 'reply', data: filled.get('main_reply') as never }],
narrative: filled.get('narrative') as string ?? null,
envelope: {
channelClass: 'email-html',
from: '[email protected]',
to: [missive.origin.address!],
inboundSubject: missive.subject,
inReplyToMessageId: missive.origin.messageId,
references: missive.references,
},
});
if (result.status === 'rendered') {
await channel.send({
to: result.artifact.to,
subject: result.artifact.subject,
text: result.artifact.plainText,
html: result.artifact.html,
...(result.artifact.inReplyTo !== undefined ? { inReplyTo: result.artifact.inReplyTo } : {}),
...(result.artifact.references !== undefined ? { references: result.artifact.references } : {}),
});
}
},
});
await channel.start();The manifest lives above the turn. The session manager, router, and actor know nothing about it. The outbound edge (the onComplete handler) is the only component that reads the email-shaped fields on missive.destination.
What this package does and does not do
Does:
- Polls IMAP on a configurable interval (default 60s) using
imapflow. - Parses RFC 2822 messages with
mailparser; extracts From/To/CC, subject, In-Reply-To, References, plain-text body. - Builds
Missiverecords withchannelId,threadId,externalId(IMAP UID),origin, anddestinationpopulated. Hands each to youronInboundcallback. - Persists the last-processed UID via a consumer-provided state callback so restarts don't re-process old mail.
- Sends SMTP via
nodemailerwith RFC 5322In-Reply-ToandReferencesthreading headers.
Does not:
- Maintain a MissiveStore (consumer-owned).
- Own sessions, turns, or any actor machinery (consumer-owned via
@cool-ai/beach-session). - Handle attachments in v1 — metadata fields are reserved but bytes are not extracted. Artifact storage will arrive alongside an
ArtifactStoreinterface in@cool-ai/beach-missives. - Use IMAP IDLE — v1 polls. IDLE support will arrive once the long-lived-connection process model is decided.
- Support multiple mailboxes per channel instance — run one
EmailChannelper mailbox.
SMTP without authentication (trusted local relay)
smtp.auth is optional. When omitted the channel sends mail through an unauthenticated SMTP connection — suitable only for trusted-network local relays such as a Postfix daemon on localhost accepting unauthenticated connections from the loopback interface and signing outbound with DKIM via OpenDKIM.
const channel = new EmailChannel({
imap: {
host: 'imap.example.com', port: 993, secure: true,
auth: { type: 'password', user: '[email protected]', pass: process.env.IMAP_PASS! },
},
smtp: {
host: 'localhost', port: 25, secure: false,
from: '[email protected]',
},
// ...
});A startup warning is emitted when smtp.auth is absent so the opt-out is visible in operator logs. Never use this mode against a remote SMTP server. The IMAP side still requires authentication; this affects outbound only.
Testing
- Unit tests in this repo run against an in-memory
nodemailermock and a fake IMAP source. No network. - Integration tests against a real mailbox are left to the consumer. PO uses a dedicated test inbox on holbrookmill; TA is expected to do the same.
Related
beach-coreREADME —ManifestRegistryand the Delivery / Assembly Manifest patterns.beach-missivesREADME — theMissiverecord shape and channel fields.beach-sessionREADME —SessionTurnManager,runTurn,onTurnSettled.
