npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 nodemailer

imapflow, 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 Missive records with channelId, threadId, externalId (IMAP UID), origin, and destination populated. Hands each to your onInbound callback.
  • Persists the last-processed UID via a consumer-provided state callback so restarts don't re-process old mail.
  • Sends SMTP via nodemailer with RFC 5322 In-Reply-To and References threading 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 ArtifactStore interface 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 EmailChannel per 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 nodemailer mock 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