Recover a failed message delivery
Use this when an operator says a customer never got a reply, or when a
conversation.message.delivery_failed webhook fires. It covers email and SMS —
the two channel types Munin delivers out of band.
How outbound delivery works
conv_send_message does two things in one transaction: it appends the message to
the conversation, and it queues a delivery row. The message is in the thread
immediately; the actual send happens seconds later in a background worker.
That worker walks the delivery through these states:
deliveryStatus | Meaning |
|---|---|
queued | Waiting for the next worker pass. Also the state of a send deferred by the channel's rate limit. |
sent | The provider accepted it. |
failed | An attempt threw. The worker will try again after a backoff. |
dead | Five attempts all failed. Nobody will retry it automatically. |
Backoff doubles from 30 seconds, so a delivery reaches dead roughly eight
minutes after the first attempt. dead is the state that needs a human — the
message is sitting in the thread looking like every other outgoing message, and
the recipient never got it.
A dead delivery also opens a channel_outbound system alert against the
channel (visible via system_alerts_list), which resolves by itself once
anything sends successfully on that channel again.
Step 1 — find what didn't arrive
conv_get_conversation reports the delivery state on every message:
{ "name": "conv_get_conversation", "arguments": { "id": "ccv_…" } }
Each message carries deliveryStatus, deliveryError, deliveryAttempts and
deliveryNextAttemptAt. Inbound messages, internal notes, and messages on
channels that deliver in-band (chat widget) have deliveryStatus: null — that is
not a failure, there is simply nothing to deliver.
To sweep a whole channel rather than one conversation, list the org's open
alerts and keep the ones whose source is channel_outbound — the tool has no
source filter, so filter as you read:
{ "name": "system_alerts_list", "arguments": {} }
Each such alert's metadata names the channel and the most recent undelivered
messageId, and occurrenceCount tells you how many messages have died on that
channel since it opened.
The bounce that comes back is not a customer
When a send fails at the far end rather than at ours, the provider mails a DSN back
to the channel. Munin ingests it like any other inbound mail, but classifies it as
suppressed: "bounce" — from the envelope (Return-Path: <>), the sender
(mailer-daemon@, postmaster@, bounces@…), an RFC 3464
Content-Type: multipart/report; report-type=delivery-status, or X-Failed-Recipients.
A suppressed message is deliberately inert: it does not wake the agent, does not enqueue curation work, is not mirrored into Slack, and is left out of the context the agent reads when it drafts on that conversation. The long base64 header blocks DSNs echo back are stripped at ingest, and bodies are capped, so a bounce cannot cost a large context window.
So the DSN shows up in the inbox for a human to read, and nothing tries to answer it. Never reply to one — the recipient is a robot. Act on the original message instead, which is what the rest of this skill is about.
Auto-replies are suppressed the same way but filed differently. Out-of-office
replies are detected from Auto-Submitted, X-Auto-Response-Suppress, X-Autoreply,
Precedence: junk, Precedence: bulk with no list headers, or a subject that begins
with an autoresponder prefix (Automatisk svar:, Ute av kontoret:, Out of Office:,
Automatic reply: and their Nordic, German and French equivalents). When one of those
opens a new conversation it is created already closed, because nobody needs to read
it — an org that puts a support address in the Reply-To of a newsletter gets one per
recipient who has a holiday responder on. It stays searchable and can be reopened.
An auto-reply landing on an existing thread never closes it; the customer's question
there is still owed an answer.
Bounces deliberately do not get that treatment — a bounce conversation stays open, because a DSN is how an operator finds out a customer never got their reply.
Step 2 — read the error before retrying
deliveryError is the provider's own message. Retrying without fixing what it
says just burns another five attempts. The common shapes:
- Authentication (
535 5.7.8,Invalid login,authenticate) — the channel's credentials are wrong or were rotated. Re-issue them withconv_request_channel_credentialsand have the operator paste the new secret; the credentials never travel through you. Verify withconv_test_email_channelorconv_test_voice_sms_channelbefore retrying. - Rejected recipient (
550,Recipient address rejected,unknown user) — the address is wrong or gone. Retrying is pointless. Fix the contact (crm_update_contact) and send a new message; the dead one is addressed to the old address and always will be. - Connection / timeout /
421/450— a transient provider fault that outlived the five automatic attempts. This is the case retrying was built for. - Rate limit — note that a send deferred by the channel's own send limits
stays
queuedand carries its reason indeliveryError. That is not a failure and needs nothing from you.
Step 3 — re-queue it
Once the cause is fixed:
{ "name": "conv_retry_delivery", "arguments": { "messageId": "cvm_…" } }
This resets the delivery to queued with a fresh attempt budget. The worker
picks it up within about ten seconds; poll conv_get_conversation to confirm the
status moved to sent.
Two refusals are worth recognizing rather than retrying through:
conv_delivery_not_retryable— the delivery isn'tdead. Either it already went out, or the worker is still between automatic attempts. Re-read the conversation; do not loop on this.conv_delivery_channel_inactive— the channel is switched off or archived, so a retry would fail immediately. Repeated inbound failures auto-deactivate a channel, so this often travels with achannel_inboundalert. Reactivate the channel first.
What NOT to do
- Don't send a duplicate as a workaround. Retrying the dead delivery keeps the thread honest — one message, one send. Posting a fresh copy leaves the undelivered one in the thread, and the customer sees the text twice if the original later goes out.
- Don't retry a rejected recipient. A
550is a verdict, not a hiccup. Fix the address and send a new message. - Don't retry in a loop. Five automatic attempts already happened. If a manual retry dies too, the cause isn't transient — go back to step 2.
- Don't tell the operator the message was sent just because it's in the
thread. Being in the thread and being delivered are different facts, which is
exactly what
deliveryStatusexists to separate.
Related
skill://conv/setup-email-channel— repairing SMTP credentials.skill://conv/setup-voice-sms-channel— repairing SMS credentials.skill://conv/track-email-opens— the other half of "did it land?": delivered is not the same as read.