diff --git a/CHANGELOG.md b/CHANGELOG.md index d7ac733..1240cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version the page shows it. A row refused before this says the line was not kept. A refusal's line is red for a permanent reply, amber for a temporary one (audit 2026-10-12, stage 2b). +- **A message the panel closed itself says so.** The history of a `bounced` + row read "the receiving server refused the message permanently, or + Postfix gave up ... The reason is in the delivery log below" also for a + row the reconcile sweep closed, where neither is known and the log has + nothing. Such a row's step is now "Closed by the panel", with why. - **The Sender check card counts connections and messages apart.** What was one column, "Messages deferred", was two things: connections Postfix turned away while the panel did not answer - refused before any message diff --git a/docs/audit-2026-10-12.md b/docs/audit-2026-10-12.md index 042d63e..60d3660 100644 --- a/docs/audit-2026-10-12.md +++ b/docs/audit-2026-10-12.md @@ -783,7 +783,7 @@ order - each stands on the one before. sentence stays only for a row whose line was truly never written or is older than this change, and says that. Done when the e2e shows the stored line on the page of each of the three refusals. - **Opus** -- [ ] **A message the sweep closed does not say the receiving server refused +- [x] **A message the sweep closed does not say the receiving server refused it** (review of stage 1, 2026-10-14). The history step of a `bounced` row reads "the receiving server refused the message permanently, or Postfix gave up ... The reason is in the delivery log below" diff --git a/docs/roadmap.md b/docs/roadmap.md index 9478831..1819bd7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | -| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`; next stage 2b → `2.5.1`) | 28/62 | [audit-2026-10-12.md](audit-2026-10-12.md) | +| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`; next stage 2b → `2.5.1`) | 29/62 | [audit-2026-10-12.md](audit-2026-10-12.md) | **Recommended order** (not binding; owner, 2026-10-09: feature order follows development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and diff --git a/internal/web/handlers/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go index 59134ec..223ceee 100644 --- a/internal/web/handlers/handlers_monitor.go +++ b/internal/web/handlers/handlers_monitor.go @@ -211,6 +211,12 @@ func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy, sendsAs vi fmt.Sprintf("The receiving server could not take the message yet. Postfix retries: first after %s, then with increasing gaps up to %s, for up to %s. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.", policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime()))) case store.StatusBounced: + if row.Reason == store.ReasonReconcile { + // Neither the receiving server nor Postfix said so: the panel + // closed the row (audit 2026-10-12, stage 2b). + return append(events, step(row.UpdatedAt, "error", "Closed by the panel", + "Postfix no longer held the message and mail.log never said what became of it - the log rotated away while the panel was down, or could not be read. The panel closed it as a failure rather than show a delivery it cannot evidence.")) + } return append(events, step(row.UpdatedAt, "error", "Bounced", fmt.Sprintf("Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after %s in the queue. The reason is in the delivery log below.", policy.QueueLifetime()))) diff --git a/internal/web/handlers/handlers_users_test.go b/internal/web/handlers/handlers_users_test.go index 6f546df..a804535 100644 --- a/internal/web/handlers/handlers_users_test.go +++ b/internal/web/handlers/handlers_users_test.go @@ -186,3 +186,21 @@ func TestARefusedMessageShowsPostfixsLine(t *testing.T) { } } } + +// A message the reconcile sweep closed does not say a receiving server +// refused it: neither did (audit 2026-10-12, stage 2b). +func TestAMessageTheSweepClosedSaysSo(t *testing.T) { + h, st := inboundHandlers(t) + root := userWith(t, st, "root", store.RoleGlobal, store.Reach{}) + if err := st.InsertQueued(store.SendLogEntry{QueueID: "GONEQ", Domain: "example.org", AppLogin: "a", To: "x@example.net", Subject: "lost"}); err != nil { + t.Fatal(err) + } + rows, _ := st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0) + if _, err := st.CloseUnreported(rows[0].ID); err != nil { + t.Fatal(err) + } + body := send(h.HandleDelivery, &root, "GET", "/outbound/log/"+idStr(rows[0].ID), map[string]string{"id": idStr(rows[0].ID)}, nil).Body.String() + if !strings.Contains(body, "Closed by the panel") || strings.Contains(body, "the receiving server refused") { + t.Fatalf("the page of a row the sweep closed:\n%s", body) + } +}