test-email: a test message from Server › Health
Decided by the owner on 2026-10-13 (out of preflight); the sender is his
choice of 2026-10-16 ("Да, адрес годится"); mockups e92769f, e3b2501,
8f0ccf9, the route guard 75f97c9.
Card 09 on Health, in the right stack's static end (the left stack is
polled whole): one recipient and Send. HandleTestMessage (global role)
takes one address (validate.AccountEmail - no list, nothing that reads as
an option), at most ten an hour counted from the send log, writes the
message's send-log row first (store.InsertTestMessage: queued, the
hostname as domain, no application, no queue id), awaits its Message-ID
(logtail.AwaitMessageID) and hands it to sendmail as notifications go
(notify.Mailer.SendWithID: postmaster@ the hostname, signed by the local
socket with the key of the server's own name). The browser lands on the
row; the tailer gives it its queue id from cleanup's message-id= line
(src/cleanup/cleanup_message.c:639) and from then on its delivery lines
its status. Postfix refusing the hand-over removes the row; sendmail out
of time keeps it (postdrop may have queued it). The sweep closes a row
whose id was never seen once it is no longer awaited
(CloseUnmatchedTestMessages); an id awaited past ten minutes is not taken.
Reviewed for security before the commit (2026-10-17): no high or medium;
its three low findings are fixed in this commit (the unmatched row's
close, the awaited id's age, the timeout keeping the row) and the
security.md bullet says how the queue id is matched.
Tests: TestTestMessage (refusals, the row and the page, Postfix refusing,
the hourly ten), TestTestMessageTimeoutKeepsTheRow, TestTestMessageRows,
TestCloseUnmatchedTestMessages, TestAwaitedMessageID,
TestRunGivesTheTestMessageItsQueueID, TestHealthTestMessageCard; e2e sends
one to the sink: the row is sent with a long queue id, the sink has it
signed d=mail.e2e.test. go vet + go test (26 packages) and make e2e on the
test server for this tree.
Docs: help (word for word with the mockup), guide § Overview and Health,
architecture § Log tailer, security.md, CHANGELOG [Unreleased] § Added,
roadmap row done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
package logtail
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The panel's own test message (Server › Health, test-email) is handed to
|
||||
// Postfix through sendmail, which says nothing of the queue id it gets; the
|
||||
// journal-milter does not see local mail (non_smtpd_milters is the signer
|
||||
// alone). Its row is written first, with no queue id, and its Message-ID is
|
||||
// awaited here: cleanup logs each message's Message-ID with its queue id,
|
||||
//
|
||||
// postfix/cleanup[52]: 4j5qN53nwRz791P4: message-id=<9f3c....1760600000@mail.example.org>
|
||||
//
|
||||
// (Postfix 3.10.13, src/cleanup/cleanup_message.c:639, msg_info("%s:
|
||||
// message-id=%s")), and the tailer puts that queue id on the row. From then on
|
||||
// the row is any row: the delivery lines of its queue id give it its status.
|
||||
// The awaited ids live in memory: a restart between the send and cleanup's
|
||||
// line, a matter of a second, leaves the row without a queue id, and the
|
||||
// reconcile sweep closes it once awaitedTTL has passed
|
||||
// (StatusStore.CloseUnmatchedTestMessages).
|
||||
|
||||
// messageIDRe matches cleanup's Message-ID line from the head, after the time
|
||||
// stamp: the id is the client's text, so only a whole line of this shape counts.
|
||||
var messageIDRe = regexp.MustCompile(`^\S+ \S*/cleanup\[\d+\]: ([0-9A-Za-z]+): message-id=(<[^<>\s]+>)\s*$`)
|
||||
|
||||
// awaitedTTL is how long an awaited Message-ID is kept: far longer than
|
||||
// sendmail's hand-over takes, and short enough that a lost one goes.
|
||||
const awaitedTTL = 10 * time.Minute
|
||||
|
||||
type awaitedIDs struct {
|
||||
mu sync.Mutex
|
||||
ids map[string]awaitedRow
|
||||
}
|
||||
|
||||
type awaitedRow struct {
|
||||
row int64
|
||||
since time.Time
|
||||
}
|
||||
|
||||
var awaited = &awaitedIDs{ids: make(map[string]awaitedRow)}
|
||||
|
||||
// AwaitMessageID registers the send-log row of a message about to be handed
|
||||
// to Postfix under its Message-ID ("<...>"), for the tailer to give it its
|
||||
// queue id. Call it before the message is sent.
|
||||
func AwaitMessageID(id string, row int64) {
|
||||
awaited.mu.Lock()
|
||||
defer awaited.mu.Unlock()
|
||||
now := time.Now()
|
||||
for k, v := range awaited.ids {
|
||||
if now.Sub(v.since) > awaitedTTL {
|
||||
delete(awaited.ids, k)
|
||||
}
|
||||
}
|
||||
awaited.ids[id] = awaitedRow{row: row, since: now}
|
||||
}
|
||||
|
||||
// ForgetMessageID drops an awaited id: the message was not handed over.
|
||||
func ForgetMessageID(id string) {
|
||||
awaited.mu.Lock()
|
||||
defer awaited.mu.Unlock()
|
||||
delete(awaited.ids, id)
|
||||
}
|
||||
|
||||
// takeAwaited returns the row awaiting a Message-ID line's id, if any, and
|
||||
// stops awaiting it.
|
||||
func takeAwaited(rest string) (queueID string, row int64, ok bool) {
|
||||
m := messageIDRe.FindStringSubmatch(rest)
|
||||
if m == nil {
|
||||
return "", 0, false
|
||||
}
|
||||
awaited.mu.Lock()
|
||||
defer awaited.mu.Unlock()
|
||||
a, ok := awaited.ids[m[2]]
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
delete(awaited.ids, m[2])
|
||||
// An id awaited too long belongs to a row the sweep has closed by now;
|
||||
// whoever received the message knows the id, and a line of his with it
|
||||
// must not reach that row.
|
||||
if time.Since(a.since) > awaitedTTL {
|
||||
return "", 0, false
|
||||
}
|
||||
return m[1], a.row, true
|
||||
}
|
||||
Reference in New Issue
Block a user