MOLTED EMAIL

Semantic Kernel Integration Guide

Add a governed email mailbox to your Semantic Kernel agents. Send, receive, and track email with built-in policy enforcement in C#, Python, or TypeScript.

Semantic Kernel is Microsoft's open-source SDK for building AI agents and copilots. It supports C#, Python, and TypeScript, and integrates with Azure OpenAI, OpenAI, and other LLM providers. Its plugin architecture makes it straightforward to wrap external services as typed functions your agent can call.

This guide shows how to add a Molted mailbox as a Semantic Kernel plugin. Every email the agent proposes runs through the Molted policy engine before delivery - 20+ rules evaluated in under a second. Policy runs at the infrastructure layer and cannot be overridden by the model.

Prerequisites

  • A Molted account with an API key (sign up at molted.email/signup)
  • A verified sending domain (see Domains)
  • Semantic Kernel installed:
    • C#: dotnet add package Microsoft.SemanticKernel
    • Python: pip install semantic-kernel
    • TypeScript: npm install @microsoft/semantic-kernel

1. Create a mailbox for your agent

Each agent (or agent team) should have its own mailbox. Log in to the portal under Mailboxes, or create one 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 from the response - you will pass it in each send request.

2. C#: Build the email plugin

In Semantic Kernel, plugins are classes with methods decorated with [KernelFunction] and [Description]. The kernel passes these descriptions to the model so it knows how to call them.

Plugins/MailboxPlugin.cs
using System.ComponentModel;
using System.Net.Http.Json;
using Microsoft.SemanticKernel;

public class MailboxPlugin
{
    private readonly HttpClient _http;
    private readonly string _apiKey;
    private readonly string _tenantId;
    private readonly string _mailboxId;

    public MailboxPlugin(string apiKey, string tenantId, string mailboxId)
    {
        _apiKey = apiKey;
        _tenantId = tenantId;
        _mailboxId = mailboxId;
        _http = new HttpClient();
        _http.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _apiKey);
    }

    [KernelFunction("send_email")]
    [Description(
        "Send an email to a contact through the governed mailbox. " +
        "The email is evaluated against policy rules before delivery. " +
        "Blocked sends return a reason and should not be retried."
    )]
    public async Task<string> SendEmailAsync(
        [Description("Recipient email address")] string to,
        [Description("Email subject line")] string subject,
        [Description("Email body in plain text or HTML")] string body,
        [Description(
            "Unique key to prevent duplicate sends within the cooldown window. " +
            "Use format: {recipient}-{event-type}. Omit to use recipient+subject."
        )]
        string dedupeKey = ""
    )
    {
        var payload = new
        {
            tenantId = _tenantId,
            mailboxId = _mailboxId,
            recipientEmail = to,
            templateId = "_default",
            dedupeKey = string.IsNullOrEmpty(dedupeKey) ? $"{to}-{subject}" : dedupeKey,
            agentId = "semantic-kernel-agent",
            payload = new { subject, html = body, text = body },
        };

        var response = await _http.PostAsJsonAsync(
            "https://api.molted.email/v1/agent/send",
            payload
        );
        response.EnsureSuccessStatusCode();

        var result = await response.Content.ReadFromJsonAsync<SendResult>()
            ?? throw new InvalidOperationException("Empty response from Molted API");

        if (result.Status == "blocked")
            return $"Email blocked by policy: {result.BlockReason}. " +
                   $"Do not retry. Decision trace: {result.RequestId}";

        return $"Email queued. requestId={result.RequestId}, status={result.Status}";
    }

    [KernelFunction("check_email_status")]
    [Description("Check the delivery status of a previously queued email.")]
    public async Task<string> CheckEmailStatusAsync(
        [Description("The requestId returned when the email was sent")] string requestId
    )
    {
        var response = await _http.GetAsync(
            $"https://api.molted.email/v1/agent/send/{requestId}/status"
        );
        response.EnsureSuccessStatusCode();

        var result = await response.Content.ReadFromJsonAsync<StatusResult>()
            ?? throw new InvalidOperationException("Empty response");

        return $"Status: {result.Status}. Provider: {result.Provider}. " +
               $"Last event: {result.LastEvent}.";
    }

    private record SendResult(string Status, string RequestId, string? BlockReason);
    private record StatusResult(string Status, string Provider, string LastEvent);
}

3. C#: Wire the plugin into a kernel and run

Program.cs
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;

var apiKey = Environment.GetEnvironmentVariable("MOLTED_API_KEY")!;
var tenantId = Environment.GetEnvironmentVariable("MOLTED_TENANT_ID")!;
var mailboxId = Environment.GetEnvironmentVariable("MOLTED_MAILBOX_ID")!;
var openAiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!;

var kernel = Kernel.CreateBuilder()
    .AddOpenAIChatCompletion("gpt-4o", openAiKey)
    .Build();

kernel.Plugins.AddFromObject(
    new MailboxPlugin(apiKey, tenantId, mailboxId),
    "Mailbox"
);

var settings = new OpenAIPromptExecutionSettings
{
    ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions,
};

var result = await kernel.InvokePromptAsync(
    promptTemplate: """
    You are a customer outreach agent. When sending emails:
    - Always use a dedupeKey in format {recipient}-{event-type}.
    - If blocked, report the blockReason and do not retry.
    - 'duplicate_send' and 'suppressed_recipient' are terminal - do not retry.

    Send a trial welcome email to alice@example.com.
    Subject: 'Welcome to the trial'
    Body: 'Thanks for signing up - your 14-day trial starts now.'
    Use dedupeKey: alice@example.com-trial-welcome
    """,
    arguments: new KernelArguments(settings)
);

Console.WriteLine(result);

4. Python: Build the email plugin

plugins/mailbox_plugin.py
import os
import requests
from semantic_kernel.functions import kernel_function


class MailboxPlugin:
    def __init__(self):
        self._api_key = os.environ["MOLTED_API_KEY"]
        self._tenant_id = os.environ["MOLTED_TENANT_ID"]
        self._mailbox_id = os.environ["MOLTED_MAILBOX_ID"]

    @kernel_function(
        name="send_email",
        description=(
            "Send an email to a contact through the governed mailbox. "
            "The email is evaluated against policy rules before delivery. "
            "Blocked sends return a reason and should not be retried."
        ),
    )
    def send_email(
        self,
        to: str,
        subject: str,
        body: str,
        dedupe_key: str = "",
    ) -> str:
        """
        Args:
            to: Recipient email address.
            subject: Email subject line.
            body: Email body in plain text or HTML.
            dedupe_key: Unique key to prevent duplicate sends. Use {recipient}-{event}.
        """
        payload = {
            "tenantId": self._tenant_id,
            "mailboxId": self._mailbox_id,
            "recipientEmail": to,
            "templateId": "_default",
            "dedupeKey": dedupe_key or f"{to}-{subject}",
            "agentId": "semantic-kernel-agent",
            "payload": {"subject": subject, "html": body, "text": body},
        }
        resp = requests.post(
            "https://api.molted.email/v1/agent/send",
            json=payload,
            headers={"Authorization": f"Bearer {self._api_key}"},
            timeout=10,
        )
        resp.raise_for_status()
        result = resp.json()

        if result["status"] == "blocked":
            return (
                f"Email blocked: {result['blockReason']}. "
                f"Do not retry. requestId={result['requestId']}"
            )
        return f"Email queued. requestId={result['requestId']}, status={result['status']}"

    @kernel_function(
        name="check_email_status",
        description="Check the delivery status of a previously queued email.",
    )
    def check_email_status(self, request_id: str) -> str:
        """
        Args:
            request_id: The requestId returned when the email was sent.
        """
        resp = requests.get(
            f"https://api.molted.email/v1/agent/send/{request_id}/status",
            headers={"Authorization": f"Bearer {self._api_key}"},
            timeout=10,
        )
        resp.raise_for_status()
        data = resp.json()
        return (
            f"Status: {data['status']}. "
            f"Provider: {data.get('provider', 'unknown')}. "
            f"Last event: {data.get('lastEvent', 'none')}."
        )

5. Python: Wire the plugin into a kernel

agent.py
import asyncio
import os
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.open_ai_prompt_execution_settings import (
    OpenAIChatPromptExecutionSettings,
)
from plugins.mailbox_plugin import MailboxPlugin


async def main():
    kernel = Kernel()
    kernel.add_service(
        OpenAIChatCompletion(
            service_id="chat",
            ai_model_id="gpt-4o",
            api_key=os.environ["OPENAI_API_KEY"],
        )
    )
    kernel.add_plugin(MailboxPlugin(), plugin_name="Mailbox")

    settings = OpenAIChatPromptExecutionSettings(
        function_choice_behavior=FunctionChoiceBehavior.Auto(),
    )

    system_prompt = """
    You are a customer outreach agent. When sending emails:
    - Always use a dedupe_key in format {recipient}-{event-type}.
    - If blocked, report the blockReason and do not retry.
    - 'duplicate_send' and 'suppressed_recipient' are terminal - do not retry.
    - 'rate_limit_exceeded' is retryable - inform the user and wait.
    """

    chat_history = kernel.get_service("chat").__class__.__name__

    result = await kernel.invoke_prompt(
        prompt=f"""
        {system_prompt}

        Send a trial welcome email to alice@example.com.
        Subject: 'Welcome to the trial'
        Body: 'Thanks for signing up - your 14-day trial starts now.'
        Use dedupe_key: alice@example.com-trial-welcome
        """,
        settings=settings,
    )
    print(result)


if __name__ == "__main__":
    asyncio.run(main())

6. Handle policy blocks

The policy engine may block a send for several reasons. The plugin surfaces these directly to the model.

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 explicit handling in your system prompt. duplicate_send and suppressed_recipient are terminal - the model should not retry. rate_limit_exceeded is retryable after a wait period.

7. Use with Azure OpenAI

Replace the chat completion registration to use Azure OpenAI:

C# — Azure OpenAI
var kernel = Kernel.CreateBuilder()
    .AddAzureOpenAIChatCompletion(
        deploymentName: "gpt-4o",
        endpoint: Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")!,
        apiKey: Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY")!
    )
    .Build();
Python — Azure OpenAI
from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion

kernel.add_service(
    AzureChatCompletion(
        service_id="chat",
        deployment_name="gpt-4o",
        endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
        api_key=os.environ["AZURE_OPENAI_API_KEY"],
    )
)

The mailbox plugin is model-agnostic - the same MailboxPlugin works with OpenAI, Azure OpenAI, and any other provider Semantic Kernel supports.

8. Listen for inbound replies

Use the Molted CLI to stream inbound events to your agent so it can react to replies:

molted listen --pipe "python reply_handler.py" --events "inbound.*"

In reply_handler.py:

reply_handler.py
import sys
import json
import asyncio
import os
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.connectors.ai.open_ai.prompt_execution_settings.open_ai_prompt_execution_settings import (
    OpenAIChatPromptExecutionSettings,
)
from plugins.mailbox_plugin import MailboxPlugin

kernel = Kernel()
kernel.add_service(
    OpenAIChatCompletion(
        service_id="chat",
        ai_model_id="gpt-4o",
        api_key=os.environ["OPENAI_API_KEY"],
    )
)
kernel.add_plugin(MailboxPlugin(), plugin_name="Mailbox")

settings = OpenAIChatPromptExecutionSettings(
    function_choice_behavior=FunctionChoiceBehavior.Auto(),
)


async def handle_event(event: dict) -> None:
    if event.get("event") != "inbound.classified":
        return
    data = event["data"]
    if data.get("intent") == "support" and data.get("suggestedAction") == "reply":
        await kernel.invoke_prompt(
            prompt=f"Reply to {data['fromEmail']} who wrote: '{data['subject']}'. Keep it short.",
            settings=settings,
        )


for line in sys.stdin:
    event_data = json.loads(line.strip())
    asyncio.run(handle_event(event_data))

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

What happens under the hood

When your 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 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 send rate (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 an immutable decision trace records which rule triggered and why. The agent sees the blockReason immediately and can respond accordingly.

The model cannot override these checks - they run at the infrastructure layer, outside the plugin's return path.