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
- Decide outbound: their own SMTP server, or send through Munin's configured Mailer (Resend).
- Decide inbound: poll IMAP, or rely on the customer forwarding to a
MUNIN_EMAIL_REPLY_DOMAINaddress. - Call
conv_configure_email_channelwith the non-secret config — never ask for or pass passwords. The response includes a one-time credential link. - 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. - Confirm the channel appears in
conv_list_channelswithactive: true;conv_test_email_channelre-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), optionalfromName(e.g. "Acme Support"). - Outbound mode:
smtp— host, port, secure (TLS yes/no), username. Most providers: port 587 withsecure: false(STARTTLS) or 465 withsecure: 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. - History (optional): by default only mail the server receives from the moment the channel is activated is imported (a test email sent right after saving the passwords is picked up on the first poll) — years of existing mail would otherwise open as fresh conversations and wake the agent on every one. If the operator wants recent threads in the inbox, set
inbound.backfillDays(1–90) to import that many days of existing mail on the first poll. Ask before setting it: every backfilled message is treated as new inbound mail, so inautomode the agent will answer them.
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. A topic can override it for the conversations it matches (conv_set_topic_automation); a single conversation can be switched by a person in the dashboard, but no MCP tool does it — an agent changes the posture for a whole channel or a whole topic, never one thread:
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.
autoconversations get swept and answered on recovery;draft_onlyones 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()thenlogout()(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_messageon a conversation tied to this channel, the message is enqueued inconv_message_deliveries. TheOutboundDeliveryWorkerdrains that queue and the email adapter sends via SMTP (or the Mailer). - Inbound (if IMAP is configured): the first poll after the channel activates starts from the first message the server received after activation (or, with
backfillDays, that many days back) and imports from there. From then on theInboundPollWorkerticks every 60s, fetches new UIDs (up to 100 per tick, so a backfill drains over several minutes), threads each message into an existing conversation (viaIn-Reply-To+Referencesheaders) or opens a new one. End-user senders are auto-created asconv_contacts. - Auto-replies and bounces: an inbound message whose headers mark it machine-generated (RFC 3834
Auto-Submitted,X-Autoreply,X-Autorespond,Precedence: junk,X-Auto-Response-Suppress, or an empty/mailer-daemonReturn-Path) is stored on the thread withmetadata.suppressedset toauto_replyorbounce, and nothing else happens: no agent turn, no draft, no human-attention flag, no topic pass, and a closed conversation stays closed. Out-of-office notices therefore stop reaching the operator queue while still being readable in the thread, and they do not count as the prospect having replied to an outreach sequence. There is no toggle — the classification is header-based and always on. - Mail loops: Munin marks every reply its agent sends with RFC 3834
Auto-Submitted: auto-replied, and drops an inbound message whoseMessage-IDmatches one it sent itself. Both matter when the polled mailbox also receives the channel's own outbound — ahello@alias delivering into themailmaster@mailbox it is sent from, say — because without them each reply is ingested as a fresh customer email and answered again, one round per poll. Replies an operator types stay unmarked; they are not automatic responses, and theMessage-IDcheck is what covers them. Relatedly,conv_send_email_channel_testrefuses atoequal to the channel's own address (conv_test_self_addressed): send the test to a mailbox you can read instead. - Identity: each sender also gets an
end_usersrow keyed by their address. If the same people sign in on a site running the analytics tracker, passing their signed email towindow.mn.analytics.identifymakes the two the same person, so their page-views land on the same contact as the email thread — seeskill://analytics/identify-visitors.
Reactivating a deactivated channel
IMAP polling failures come in two kinds, and only one of them switches a channel off:
- The mailbox refused us — the login was rejected, or the configured mailbox does not exist. Retrying cannot fix that, so the alert opens on the first failure and five in a row auto-deactivate the channel (
conv_list_channelsshowsactive: false,system_alerts_listcarries the failure detail andmetadata.failureKind: "permanent"). - The mail server could not be reached — connection refused, a timeout, a DNS blip, a server that is briefly unavailable. The channel stays on and polling backs off, doubling the gap after each failure up to 15 minutes. No alert opens until the failures have gone on for about five minutes — in practice the poll seven minutes in, given the backoff; the alert then carries
metadata.failureKind: "transient", and the first successful poll resolves it and returns to the normal cadence. A server that fails only intermittently, with successful polls in between, never builds up that run and does not alert: mail is still collected on the polls that succeed.
A mail server that keeps dropping out and coming back does not produce a fresh alert — or a fresh email to the org's owners — every time. An alert that comes back within six hours of resolving reopens the same row (its occurrenceCount keeps climbing) and emails nobody. Owners are emailed as soon as a new inbound alert opens — the wait already happened before it opened.
Two ways back from a deactivated channel:
- The config was wrong — call
conv_configure_email_channelwithchannelIdand 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'sprobefield. In the dashboard this is the Edit → Save button on the channel card. - Nothing was wrong on our side — the provider locked the account for a while, a certificate lapsed. There is nothing to edit, so just switch the channel back on: the Activate button on the channel card.
Editing or reactivating a channel clears its backoff, so the next tick polls it straight away and counts failures from zero.
Troubleshooting
- No outbound delivery — check
conv_message_deliveriesrows for the channel.status='dead'means 5 attempts failed; theerrorcolumn has the SMTP response.conv_test_email_channelis the fastest way to check creds. - Inbound stuck —
conv_inbound_state.cursor.lastUidshows the high-water mark andcursor.uidValiditythe mailbox generation it belongs to (if the server renumbers the mailbox, Munin restarts from its current end rather than re-importing it);last_polled_atshows the most recent tick;last_errorcarries any IMAP error. - Email lands in spam at the recipient — confirm SPF / DKIM / DMARC for the
fromAddressdomain. Munin doesn't manage DNS.