A send that fails for a passing reason is retried; one that finally fails is marked on the draft (#242)

Every sender turned every failure into `false`, so a one-minute Gmail
rate limit or a short outage lost the send like a refused recipient.

The three senders now classify through MailDeliveryInterface::deliver():
deferred (rate limits, 5xx, a connection that never opened, SMTP 4xx),
refused (other 4xx, SMTP 5xx, too large) or outcome unknown (a lost
answer, 502/504). send() stays a bool for invitations, alerts and read
receipts.

SendMessageHandler retries a deferral for at most two minutes, keeping
the provider's Retry-After. A lost answer is never resent blind: Gmail
and Graph look for the Message-ID in Sent first (now committed before
the first attempt), SMTP reports it as possibly sent. A final failure,
from the handler or from SendGaveUpSubscriber when the ladder runs out,
marks the draft, words the toast by its reason, and shows a red warning
triangle at the start of the draft's row and on Drafts in the sidebar.

Migration: four nullable columns and a partial index on message.

Closes #125

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 authored and GitHub committed 2026-10-20 10:34:56 +02:00
1 parent 4d18105909
commit 3b78d277d7
42 files changed
+2305 -89

No files matched your search

+2
View File
@@ -552,6 +552,8 @@ controller instead — the scroll position and which trees are open.
<i class="fa-solid fa-file-lines w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.drafts'|trans }}</span>
{{ macros.role_dot(enum('App\\Domain\\Enum\\Mail\\LabelRole').Drafts) }}
{% import '_partials/_unsent_mark.html.twig' as unsent %}
{{ unsent.sidebar(sidebarCounts.unsentDrafts()) }}
</span>
{{ macros.total_badge(sidebarCounts.forRoleBadge(enum('App\\Domain\\Enum\\Mail\\LabelRole').Drafts), 'role:drafts') }}
</a>
+10
View File
@@ -437,6 +437,16 @@ be a worse regression than the one this feature fixes.
different places depending on how wide the list happens to
be. #}
<span class="min-w-0 flex items-center gap-1.5">
{# A send of this draft that finally failed (#125): first in
the marker slot, at the very start of the row, where a
glance down the Drafts list finds it before anything
else. See _unsent_mark.html.twig; `draftMessage` is only
set on a conversation row. #}
{% if draftMessage|default(null) and draftMessage.sendFailure %}
{% import '_partials/_unsent_mark.html.twig' as unsent %}
{{ unsent.row(draftMessage.sendFailure) }}
{% endif %}
{# The "New" marker: a filled accent pill carrying a WORD.
Deliberately nothing like the unread dot further down
this file, which is a bare 8px green circle — the two
@@ -0,0 +1,32 @@
{#
The warning on a draft whose send finally failed (#125) — on its row in the
Drafts list, and on the Drafts entry in the sidebar while any draft has one.
One icon in both places, defined once, so the sidebar's mark is plainly the
same thing as the row's and the reader knows what to look for when they open
Drafts. Red, because it is the one state here that means "this did not happen
and you need to know": the toast that said so is gone by the time a scheduled
send's failure is noticed.
The sidebar's is rendered at every count and hidden at zero, the idiom every
patched marker follows (see _new_dot.html.twig): ui--sidebar finds it by
`data-count-key` after a counts refresh, and an element never rendered cannot
be found.
#}
{% macro row(failure) %}
{% set reason = ('thread_row.send_failed.' ~ failure.value)|trans %}
<i class="fa-solid fa-triangle-exclamation shrink-0 text-red-600 dark:text-red-400"
role="img"
data-unsent-draft
title="{{ reason }}"
aria-label="{{ reason }}"></i>
{% endmacro %}
{% macro sidebar(count) %}
<i class="fa-solid fa-triangle-exclamation text-red-600 dark:text-red-400 text-xs{{ count > 0 ? '' : ' hidden' }}"
role="img"
data-unsent-mark
data-count-key="unsent:role:drafts"
title="{{ 'sidebar.badge.unsent'|trans }}"
aria-label="{{ 'sidebar.badge.unsent'|trans }}"></i>
{% endmacro %}
@@ -16,14 +16,20 @@ ten. So the confirmation preceded the attempt by two seconds, every time, for
everybody, and a send that then failed was never mentioned again.
Variables: message, sent (bool)
A failure is worded by its reason (#125), read off the row the handler has just
marked: "may have been sent" and "the server refused it" ask different things of
the reader. Failed — the reason with nothing more to say — keeps the old words.
#}
{% set failure = sent ? null : message.sendFailure %}
{% set failedKey = failure and failure.value != 'failed' ? 'toast.send_failed_' ~ failure.value : 'toast.send_failed' %}
<turbo-stream action="append" target="toast-region">
<template>
{% include '_partials/_toast.html.twig' with {
type: sent ? 'success' : 'error',
message: sent
? 'toast.sent'|trans
: 'toast.send_failed'|trans({ '%subject%': message.subject|default('')|slice(0, 60) }),
: failedKey|trans({ '%subject%': message.subject|default('')|slice(0, 60) }),
extra: sent ? {} : { hide_after: 12000 },
} only %}
</template>