MOLTED EMAIL

LangChain Integration Guide

Add a governed email mailbox to your LangChain agents. Send, receive, and track email with built-in policy enforcement.

LangChain agents can send email through any transport API with a simple tool call. The problem is that nothing governs what they send: no deduplication, no cooldown windows, no suppression checks, no audit trail.

This guide shows how to give a LangChain agent a managed mailbox. Every email the agent proposes runs through the Molted policy engine before it leaves: 20+ rules evaluated in under a second. The agent keeps sending through a familiar Tool interface; policy runs at the infrastructure layer and cannot be bypassed.

Prerequisites

  • A Molted account with an API key (sign up at molted.email/signup)
  • A verified sending domain (see Domains)
  • LangChain installed: npm install langchain @langchain/openai
  • Molted SDK installed: npm install @molted/mail

1. Create a mailbox for your agent

Each LangChain agent should have its own mailbox. Log in to the portal, go to Mailboxes, and create one - or create it via the API:

curl
curl -X POST https://api.molted.email/v1/me/mailboxes \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Outbound Agent",
    "emailAddress": "agent@yourdomain.com"
  }'

Note the mailboxId in the response - you will pass it on each send request.

2. Define the send tool

The send tool wraps requestSend from the Molted SDK. It accepts a recipient, subject, and body from the LLM, then submits the request to the policy engine.

tools/send-email.ts
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { MailClient } from "@molted/mail";

const client = new MailClient({
  baseUrl: "https://api.molted.email",
  apiKey: process.env.MOLTED_API_KEY!,
});

export const sendEmailTool = tool(
  async ({ to, subject, body, dedupeKey }) => {
    const result = await client.requestSend({
      tenantId: process.env.MOLTED_TENANT_ID!,
      recipientEmail: to,
      templateId: "_default",
      dedupeKey: dedupeKey ?? `${to}-${subject}`,
      agentId: "langchain-agent",
      payload: {
        subject,
        html: body,
        text: body,
      },
    });

    if (result.status === "blocked") {
      return `Email blocked by policy: ${result.blockReason}. Decision trace: ${result.requestId}`;
    }

    return `Email queued: requestId=${result.requestId}, status=${result.status}`;
  },
  {
    name: "send_email",
    description:
      "Send an email to a contact. The email is checked against policy rules before it is delivered. Blocked sends return a reason and are not retried.",
    schema: z.object({
      to: z.string().email().describe("Recipient email address"),
      subject: z.string().describe("Email subject line"),
      body: z.string().describe("Email body in plain text or HTML"),
      dedupeKey: z
        .string()
        .optional()
        .describe(
          "Unique key to prevent duplicate sends. Defaults to recipient+subject."
        ),
    }),
  }
);

The tool returns the policy decision to the LLM. If a send is blocked (for example, a duplicate within the cooldown window), the agent sees the reason and can decide what to do next rather than silently failing or retrying blindly.

3. Add a check-send-status tool (optional)

Your agent can query the delivery status of a previous send:

tools/check-status.ts
import { tool } from "@langchain/core/tools";
import { z } from "zod";

export const checkEmailStatusTool = tool(
  async ({ requestId }) => {
    const res = await fetch(
      `https://api.molted.email/v1/agent/send/${requestId}/status`,
      {
        headers: {
          Authorization: `Bearer ${process.env.MOLTED_API_KEY}`,
        },
      }
    );
    const data = await res.json();
    return `Status: ${data.status}. Provider: ${data.provider ?? "unknown"}. Last event: ${data.lastEvent ?? "none"}.`;
  },
  {
    name: "check_email_status",
    description: "Check the delivery status of a previously queued email.",
    schema: z.object({
      requestId: z
        .string()
        .describe("The requestId returned when the email was sent"),
    }),
  }
);

4. Wire the tools into a LangChain agent

agent.ts
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { ChatOpenAI } from "@langchain/openai";
import { sendEmailTool } from "./tools/send-email";
import { checkEmailStatusTool } from "./tools/check-status";

const model = new ChatOpenAI({
  model: "gpt-4o",
  temperature: 0,
});

const agent = createReactAgent({
  llm: model,
  tools: [sendEmailTool, checkEmailStatusTool],
  messageModifier: `
    You are a customer outreach agent. When sending emails:
    - Always include a dedupeKey to prevent accidental duplicate sends.
    - If a send is blocked by policy, report the reason to the user and do not retry.
    - After sending, you can check delivery status if the user asks.
  `,
});

// Run the agent
const result = await agent.invoke({
  messages: [
    {
      role: "user",
      content:
        "Send a trial welcome email to alice@example.com. Subject: 'Welcome to the trial' Body: 'Thanks for signing up, your trial starts today.'",
    },
  ],
});

console.log(result.messages.at(-1)?.content);

5. Handle policy blocks

The policy engine may block a send for several reasons. The tool surfaces these to the LLM so the agent can respond appropriately rather than failing silently.

Common block reasons:

ReasonWhat it means
duplicate_sendThe same dedupeKey was used within the cooldown window
rate_limit_exceededThe mailbox or tenant has hit a send rate limit
suppressed_recipientThe recipient has unsubscribed or bounced previously
cooldown_activeA cooldown window is in effect for this recipient
risk_budget_exceededThe agent's risk budget for this period is exhausted

Include block handling in your agent instructions so the LLM knows what to do when a send is blocked:

messageModifier: `
  If a send is blocked:
  - "duplicate_send": inform the user the email was already sent recently, do not retry
  - "suppressed_recipient": inform the user this contact cannot be emailed, do not retry
  - "rate_limit_exceeded": wait and inform the user
  - any other block: report the reason and decision trace ID to the user
`

6. Listen for inbound replies

Use the CLI to pipe inbound events to your LangChain agent so it can react to replies:

molted listen --pipe "node reply-handler.js" --events "inbound.*"

In reply-handler.js, instantiate a separate agent that receives the inbound event and decides what to do:

reply-handler.js
import readline from "node:readline";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
import { ChatOpenAI } from "@langchain/openai";
import { sendEmailTool } from "./tools/send-email.js";

const rl = readline.createInterface({ input: process.stdin });
const agent = createReactAgent({
  llm: new ChatOpenAI({ model: "gpt-4o", temperature: 0 }),
  tools: [sendEmailTool],
});

rl.on("line", async (line) => {
  const event = JSON.parse(line);
  if (event.event !== "inbound.classified") return;

  const { fromEmail, subject, intent, suggestedAction } = event.data;

  if (intent === "support" && suggestedAction === "reply") {
    await agent.invoke({
      messages: [
        {
          role: "user",
          content: `Reply to ${fromEmail} who asked: "${subject}". Keep it short and helpful.`,
        },
      ],
    });
  }
});

See Reactive Agent Guide for the full event reference and daemon setup.

Multi-agent setup

If you have multiple LangChain agents sharing a tenant (an onboarding agent, a churn-prevention agent, and a support agent), register each one:

Register each agent
curl -X POST https://api.molted.email/v1/agent/register \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tenantId": "tenant_abc123", "name": "onboarding-agent"}'

Pass the agentId in each send request. The policy engine tracks per-agent rate limits, and the decision trace records which agent proposed the send. See Agent Coordination for leases and consensus.

What happens under the hood

When your LangChain agent calls send_email, the mailbox evaluates 20+ policy rules in sequence before anything leaves:

  1. Suppression check - has this recipient unsubscribed or bounced?
  2. Deduplication - has this exact dedupeKey been used within the cooldown window?
  3. Cooldown - is there an active cooldown for this recipient?
  4. Rate limits - has the mailbox or tenant exceeded its rate budget (per-minute, per-hour, per-day)?
  5. Risk budget - has the agent exhausted its send budget for this period?
  6. Consent - does the contact have a valid consent record?

If all rules pass, the email is delivered. If any rule fires, the send is blocked and the decision trace records exactly which rule triggered and why. Your agent sees the blockReason immediately and can respond accordingly.

The agent cannot override these checks - they run at the infrastructure layer, outside the model's control.