Show this Postfix's retry policy on Mail queue and delivery history.
test / test (push) Has been cancelled

Numbers come from a one-shot postconf -h at panel start so a manual override is visible after restart, without inventing an attempt count.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 22:12:03 +03:00
parent 46191172d6
commit 705239568b
17 changed files with 669 additions and 30 deletions
+5
View File
@@ -8,6 +8,7 @@ import (
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/view"
@@ -29,6 +30,10 @@ type Config struct {
// and used to cap domain/app level-2 ceilings (guide § Rate limiting).
RateLimitMessagesPerIP int
RateLimitWindowSeconds int
// RetryPolicy is this Postfix's deferred-mail timings, snapshotted once
// when the HTTP role starts (architecture.md). The Mail queue card and
// delivery history read it from here; they never call postconf.
RetryPolicy postfix.RetryPolicy
}
// Handlers holds dependencies for authenticated panel routes.
+20 -10
View File
@@ -2,6 +2,7 @@ package handlers
import (
"errors"
"fmt"
"io/fs"
"net/http"
"net/url"
@@ -115,7 +116,7 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
// The status in the panel's own badge vocabulary, so the headline reads
// the same way as every other health signal in the panel.
"Level": deliveryLevel(row.Status),
"Events": deliveryEvents(row),
"Events": deliveryEvents(row, h.cfg.RetryPolicy),
// The mail.log lines for this message, and — when there are none — the
// reason, which is a normal outcome rather than a failure.
"LogRows": logRows,
@@ -173,7 +174,9 @@ type deliveryEvent struct {
// timestamps *are* the history, and stating them as steps is what makes a row
// whose created_at and updated_at differ by six hours legible as "queued for
// six hours, then delivered" rather than as two dates in a list of fields.
func deliveryEvents(row store.SendLogRow) []deliveryEvent {
// policy supplies the human intervals for deferred and bounced copy, the same
// strings the Mail queue card prints, so the two cannot drift.
func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy) []deliveryEvent {
// A rejected message has no second step, and its first one is not an
// acceptance: the journal-milter refused it, so Postfix never queued it.
if row.Status == store.StatusRejected {
@@ -217,7 +220,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent {
Level: "warn",
Status: store.StatusDeferred,
Title: "Deferred, will be retried",
Detail: "The receiving server could not take the message yet. Postfix keeps it queued and retries until it is delivered or the queue lifetime runs out.",
Detail: 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:
return append(events, deliveryEvent{
@@ -225,7 +229,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent {
Level: "error",
Status: store.StatusBounced,
Title: "Bounced",
Detail: "Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after the queue lifetime. The reason is in the delivery log below.",
Detail: 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()),
})
default:
// A status the log-tailer learns to write before this switch does.
@@ -412,13 +417,18 @@ func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
return
}
out, errText := readQueue()
policy := h.cfg.RetryPolicy
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
"Title": "SelfPost — mail queue",
"User": auth.CurrentUser(r),
"Active": "mail_queue",
"IsGlobal": true,
"Output": out,
"Error": errText,
"Title": "SelfPost — mail queue",
"User": auth.CurrentUser(r),
"Active": "mail_queue",
"IsGlobal": true,
"Output": out,
"Error": errText,
"FirstRetry": policy.FirstRetry(),
"BackoffCap": policy.BackoffCap(),
"QueueLifetime": policy.QueueLifetime(),
"RetryFromDefaults": policy.FromDefaults,
})
}
@@ -8,7 +8,9 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
@@ -450,3 +452,90 @@ func writeMailLog(t *testing.T, lines ...string) string {
}
return path
}
// fixtureRetryPolicy is a distinctive policy so tests can tell the Config
// snapshot from live postconf and from compiled-in defaults (5 minutes / 5 days).
func fixtureRetryPolicy() postfix.RetryPolicy {
return postfix.RetryPolicy{
QueueRunDelay: 10 * time.Minute,
MinimalBackoff: 10 * time.Minute,
MaximalBackoff: 4000 * time.Second,
MaximalQueueLifetime: 2 * 24 * time.Hour,
BounceQueueLifetime: 2 * 24 * time.Hour,
}
}
// The retry card sits on the page itself, outside the HTMX poll, and prints
// whatever policy was cached on Config — never a live postconf.
func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/mail-queue")
for _, want := range []string{
"How delivery retries work",
"id=\"retry-policy\"",
">10 minutes<",
"doubling, cap about 1 hour 7 minutes",
">2 days<",
} {
if !strings.Contains(out, want) {
t.Errorf("mail queue is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, ">5 minutes<") || strings.Contains(out, ">5 days<") {
t.Errorf("mail queue shows stock defaults instead of the fixture:\n%s", out)
}
if strings.Contains(out, "compiled-in defaults") {
t.Error("a fixture policy must not show the fallback note")
}
}
func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueueBody, "/mail-queue/body")
if strings.Contains(out, "How delivery retries work") || strings.Contains(out, "10 minutes") {
t.Errorf("HTMX fragment includes the retry card:\n%s", out)
}
}
func TestMailQueueNotesCompiledInFallback(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: postfix.DefaultRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/mail-queue")
if !strings.Contains(out, "compiled-in defaults") {
t.Errorf("fallback note missing:\n%s", out)
}
}
func TestDeliveryPageDeferredUsesRetryPolicy(t *testing.T) {
h, row := serverWithDelivery(t)
h.cfg.RetryPolicy = fixtureRetryPolicy()
if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusDeferred); err != nil {
t.Fatalf("update status: %v", err)
}
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
for _, want := range []string{
"first after 10 minutes",
"up to about 1 hour 7 minutes",
"for up to 2 days",
} {
if !strings.Contains(out, want) {
t.Errorf("deferred history is missing %q:\n%s", want, out)
}
}
}
func TestDeliveryPageBouncedUsesRetryPolicy(t *testing.T) {
h, row := serverWithDelivery(t)
h.cfg.RetryPolicy = fixtureRetryPolicy()
if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusBounced); err != nil {
t.Fatalf("update status: %v", err)
}
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "gave up after 2 days in the queue") {
t.Errorf("bounced history does not use the fixture lifetime:\n%s", out)
}
}