KB-035: smtp2graph Message Stranded in the Queue (Never Retried, Never Failed)¶
Status: Reference. Upstream behaviour of smtp2graph v1.1.5. There is no config switch for it, so the fix is a manual re-queue. First seen 2026-09-24, when one delivery canary sat in the queue for nine hours while every other message went through.
Symptom¶
SMTP2GraphQueueStalled fires and stays firing. smtp2graph_messages_queued is a small constant
(usually 1) and smtp2graph_oldest_queued_seconds climbs steadily. smtp2graph_messages_failed
stays at 0, the canary goes on succeeding, and mail sent after the stuck message is delivered
normally. So the queue is not stalled. One file is stranded in it.
The app container's log holds exactly one error for the file and nothing after it, with no retries:
The reason is in the file log, not in kubectl logs:
It will be one of Invalid content for mail, Access to mailbox "…" denied or
The message exceeds the maximum supported size.
Cause¶
smtp2graph maps three Graph error codes to a "permanent" error class:
| Graph error code | Logged as |
|---|---|
ErrorMimeContentInvalidBase64String |
Invalid content for mail |
ErrorAccessDenied |
Access to mailbox "…" denied |
ErrorMessageSizeExceeded |
The message exceeds the maximum supported size |
Its queue handler does not retry a permanent error, and it does not move the file to failed/
either. Moving to failed/ only happens when a retryable error uses up retryLimit. The file
stays in queue/, and the file watcher only acts on new files, so nothing looks at it again.
Two consequences:
SMTP2GraphUndeliverableMail(which countsfailed/) never fires for these three errors. The warning-levelSMTP2GraphQueueStalledis the only signal.ErrorSendAsDenied, the error behind the Aug 2026 outage, is not in the permanent class. It retries and ends up infailed/, so the critical alert still covers it.- A "permanent" error is not always permanent. On 2026-09-24 Graph rejected a 454-byte, 7-bit,
plain-text canary as invalid base64, and the same file was delivered on the first re-queue.
Treat
Invalid contenton a small, well-formed message as a transient Graph fault.
Fix¶
Look at the message before you re-send it. A real oversize or access-denied failure will fail the same way again.
kubectl -n infrastructure exec deploy/smtp2graph -c app -- sh -c \
'ls -la /data/mailroot/queue; grep -m4 -iE "^(From|To|Subject):" /data/mailroot/queue/*.eml'
To re-queue it, move the file out of queue/ and back in, so the watcher sees a new file:
f='<id>.eml'
kubectl -n infrastructure exec deploy/smtp2graph -c app -- sh -c \
"cd /data/mailroot && mv queue/$f temp/$f && sleep 2 && mv temp/$f queue/$f"
Confirm from the exporter, not from the absence of log lines (a successful send logs nothing at the default level):
kubectl -n infrastructure exec deploy/smtp2graph -c metrics -- python -c \
"import urllib.request;print(urllib.request.urlopen('http://127.0.0.1:9184/metrics').read().decode())" \
| grep -E '^smtp2graph_(messages|oldest)'
messages_queued 0 and messages_failed 0 mean it was delivered. If it fails again, the
message is still mail the relay has accepted, so leave it in queue/ until you have fixed the
cause:
- Access denied: the Exchange RBAC-for-Applications scope on the app registration changed.
See the
send.forceMailboxnote inexternalsecret.yaml. Repair the scope, then re-queue. - Invalid content, again: check the file for real MIME damage (truncation, 8-bit bytes in a 7-bit part). If it looks well-formed, wait and re-queue later rather than giving up after two tries.
- Size exceeded: this is the only case that can never succeed as-is. Tell the sender, then delete the file.
Only delete a message once you have confirmed it can never be sent. Copy it out with
kubectl cp first if anyone might still want what it says.
Do not clear it by deleting the pod. The queue is on an emptyDir, so a new pod silently
drops every queued message with it.
References¶
kubernetes/apps/infrastructure/smtp2graph/app/prometheusrule.yaml, the delivery-outcome alerts and why they exist.kubernetes/apps/infrastructure/smtp2graph/app/exporter/, the queue exporter and 6-hourly canary.