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.
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.
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
InboundPollWorkerticks every 60s, fetches new UIDs, threads each message into an existing conversation (viaIn-Reply-To+Referencesheaders) or opens a new one. End-user senders are auto-created asconv_contacts. - 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
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_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 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_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;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.