Munin
Munin developer portal
Get a key →

Set up an email channel

Use this when a customer wants Munin to send and receive email under one of their addresses (e.g. support@acme.com).

TL;DR

  1. Decide outbound: their own SMTP server, or send through Munin's configured Mailer (Resend).
  2. Decide inbound: poll IMAP, or rely on the customer forwarding to a MUNIN_EMAIL_REPLY_DOMAIN address.
  3. Call conv_configure_email_channel with the non-secret config — never ask for or pass passwords. The response includes a one-time credential link.
  4. Share the credential link — the human enters the SMTP/IMAP passwords in the dashboard; the channel activates and the passwords are verified against the servers on save. The link works once and expires after 24 hours; mint a fresh one with conv_request_channel_credentials.
  5. Confirm the channel appears in conv_list_channels with active: true; conv_test_email_channel re-verifies credentials any time.

A mailer-outbound channel with no IMAP inbound needs no secrets at all — it is active immediately, no link involved.

Step 1 — gather the config

Required from the operator:

  • Addressing: fromAddress (must be a real mailbox they control), optional fromName (e.g. "Acme Support").
  • Outbound mode:
    • smtp — host, port, secure (TLS yes/no), username. Most providers: port 587 with secure: false (STARTTLS) or 465 with secure: true. The password is entered through the credential link, never in the conversation.
    • mailer — no extra config; uses the Munin instance's configured Mailer. Best when you have no separate SMTP relay.
  • Inbound (optional): IMAP host, port, secure, username, mailbox name (defaults to INBOX). The password comes through the credential link.

Passwords are stored encrypted via pgcrypto and only enter Munin through the credential link. Re-calling conv_configure_email_channel for updates keeps the stored passwords — non-secret fields can be changed freely.

Step 2 — create the channel

Call conv_configure_email_channel (admin):

{
  "name": "Acme Support",
  "config": {
    "addressing": {
      "fromAddress": "support@acme.com",
      "fromName": "Acme Support",
      "replyToTemplate": "support+conv-{conversationId}@acme.com"
    },
    "outbound": {
      "provider": "smtp",
      "host": "smtp.acme.com",
      "port": 587,
      "secure": false,
      "username": "support@acme.com"
    },
    "inbound": {
      "provider": "imap",
      "host": "imap.acme.com",
      "port": 993,
      "secure": true,
      "username": "support@acme.com",
      "mailbox": "INBOX"
    }
  }
}

Returns the channel ID, type 'email', the redacted DTO (passwords show as ••••), and — when SMTP or IMAP is configured — a credentialLink with a one-time URL. The channel stays active: false until the human opens the link and saves the passwords; saving verifies them against the SMTP/IMAP servers and activates the channel.

To update an existing channel, pass channelId and only the fields you want to change. Stored passwords are preserved; to rotate a password, mint a link with conv_request_channel_credentials — or tell the human they can retype it directly in the channel's edit dialog in the dashboard, which accepts passwords because a person is typing them.

An update that introduces a transport with no stored password — adding IMAP inbound to an outbound-only channel, for example — comes back active: false. Mint a credential link so the human can enter the missing password; the channel activates on save.

If the channel is currently deactivated, the update also re-tests the stored credentials and reactivates the channel when SMTP and IMAP both connect — see Reactivating a deactivated channel below.

Step 2b — decide what the agent does with inbound mail

defaultAgentMode on the channel sets the posture every new conversation inherits (conv_change_agent_mode overrides it per conversation):

  • auto (default) — the agent answers the sender directly.
  • draft_only — the agent does the same work (knowledge-base lookups, CRM, connectors, the audit pass) but writes the answer to an internal draft and flags the conversation for human attention instead of sending. The draft appears in the dashboard inbox, pre-filled in an editable composer; a teammate edits and sends it. The sender sees nothing until a human sends — no reply, no typing indicator.
  • off — the agent does nothing on this channel.

Reach for draft_only when a customer wants a human in the loop before anything leaves the building — a cautious launch, a regulated business, or an inbox where a wrong answer is expensive. It costs the same model call as auto, so it is a review posture, not a cheaper one.

Two things to tell the operator:

  • Audit actions that end a thread are withheld in draft_only. The audit pass still tags a topic and can still mark spam, but it will not close or snooze a conversation whose answer hasn't been sent yet.
  • Drafts are produced when the message arrives, not retroactively. If the runner is down when mail lands, the conversation is simply unanswered in the inbox — a human still sees it. auto conversations get swept and answered on recovery; draft_only ones don't, on purpose, so a parked draft is never regenerated on a loop.

Outreach-originated conversations are always draft_only and are drafted by skill://outreach/draft-reply-email into the proposal review queue instead — the conversation runner leaves them alone.

Step 3 — verify

Call conv_test_email_channel with { channelId }. It performs:

  • An SMTP verify() (no mail sent).
  • An IMAP connect() then logout() (no fetch).

Returns { smtp: 'ok' | 'error: <message>', imap: 'ok' | 'error: <message>' | 'not configured' }.

If smtp reports auth failure, the most common causes are app-password-required (Gmail, iCloud), the wrong port for the cipher (587 STARTTLS vs 465 implicit-TLS), or a region-specific endpoint (Microsoft 365 enforces smtp.office365.com).

Step 4 — confirm registration

Call conv_list_channels. Look for the new row with type: 'email', active: true, and the addressing block.

What happens next

  • Outbound: when an admin uses conv_send_message on a conversation tied to this channel, the message is enqueued in conv_message_deliveries. The OutboundDeliveryWorker drains that queue and the email adapter sends via SMTP (or the Mailer).
  • Inbound (if IMAP is configured): the InboundPollWorker ticks every 60s, fetches new UIDs, threads each message into an existing conversation (via In-Reply-To + References headers) or opens a new one. End-user senders are auto-created as conv_contacts.
  • Identity: each sender also gets an end_users row keyed by their address. If the same people sign in on a site running the analytics tracker, passing their signed email to window.mn.analytics.identify makes the two the same person, so their page-views land on the same contact as the email thread — see skill://analytics/identify-visitors.

Reactivating a deactivated channel

Five consecutive IMAP polling failures auto-deactivate a channel and open a system alert (conv_list_channels shows active: false, system_alerts_list carries the failure detail). Two ways back:

  • The config was wrong — call conv_configure_email_channel with channelId and the corrected fields. The update re-tests the stored credentials: SMTP and IMAP both connecting reactivates the channel and resolves the alert, and anything else leaves it deactivated with the connection errors in the response's probe field. In the dashboard this is the Edit → Save button on the channel card.
  • Nothing was wrong on our side — the mailbox was down, the provider throttled, a certificate lapsed. There is nothing to edit, so just switch the channel back on: the Activate button on the channel card. Polling resumes on the next tick and re-deactivates the channel if the failures continue.

Troubleshooting

  • No outbound delivery — check conv_message_deliveries rows for the channel. status='dead' means 5 attempts failed; the error column has the SMTP response. conv_test_email_channel is the fastest way to check creds.
  • Inbound stuckconv_inbound_state.cursor.lastUid shows the high-water mark; last_polled_at shows the most recent tick; last_error carries any IMAP error.
  • Email lands in spam at the recipient — confirm SPF / DKIM / DMARC for the fromAddress domain. Munin doesn't manage DNS.