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>
122 lines
3.7 KiB
Go
122 lines
3.7 KiB
Go
// Package handlers implements the panel's authenticated HTTP handlers.
|
|
package handlers
|
|
|
|
import (
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/mixeme/selfpost/internal/app"
|
|
"github.com/mixeme/selfpost/internal/dmarc"
|
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
|
"github.com/mixeme/selfpost/internal/domain"
|
|
"github.com/mixeme/selfpost/internal/health"
|
|
"github.com/mixeme/selfpost/internal/inbound"
|
|
"github.com/mixeme/selfpost/internal/notify"
|
|
"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"
|
|
)
|
|
|
|
// Config holds handler-specific panel configuration.
|
|
type Config struct {
|
|
Hostname string
|
|
SubmissionEnabled bool
|
|
MailLogPath string
|
|
DataDir string
|
|
DBPath string
|
|
DeployRoot string
|
|
Version string
|
|
TLSCertFile string
|
|
TLSServedCertFile string
|
|
TLSReloadInterval time.Duration
|
|
SMTPBanner string
|
|
SignSocket string
|
|
SignLocalSocket string
|
|
JournalSocket string
|
|
SenderSocket string
|
|
// Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel
|
|
// 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
|
|
// InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and
|
|
// routes exist only when this is true.
|
|
InboundEnabled bool
|
|
// InboundAntispamMilter mirrors INBOUND_ANTISPAM_MILTER and
|
|
// InboundAntispamAction INBOUND_ANTISPAM_MILTER_ACTION ("accept",
|
|
// "tempfail", or "" for a value build/postfix-config.sh would refuse). The
|
|
// filter is on only when the inbound relay is on and the milter is set.
|
|
InboundAntispamMilter string
|
|
InboundAntispamAction string
|
|
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
|
|
DMARCEnabled bool
|
|
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used
|
|
// as bootstrap and fallback when the settings row is missing or invalid.
|
|
SendLogRetentionEnvDefault int
|
|
// InboundSpamLogRetentionEnvDefault is INBOUND_SPAM_LOG_RETENTION_DAYS at
|
|
// panel start, the same bootstrap and fallback for the inbound journal.
|
|
InboundSpamLogRetentionEnvDefault int
|
|
}
|
|
|
|
// Handlers holds dependencies for authenticated panel routes.
|
|
type Handlers struct {
|
|
store *store.Store
|
|
domains *domain.Service
|
|
apps *app.Service
|
|
inbound *inbound.Service
|
|
lists *inbound.Lists
|
|
dmarc *dmarc.Service
|
|
cfg Config
|
|
view *view.Engine
|
|
dns *dnscheck.Checker
|
|
machine *health.MachineSampler
|
|
auth *auth.Module
|
|
// testMail sends the panel's test message (notify.Mailer; tests
|
|
// substitute one that records).
|
|
testMail testMailer
|
|
}
|
|
|
|
// testMailer is what sends the test message: notify.Mailer.SendWithID.
|
|
type testMailer interface {
|
|
SendWithID(to, subject, body, id string) error
|
|
}
|
|
|
|
// New builds authenticated panel handlers.
|
|
func New(
|
|
st *store.Store,
|
|
domains *domain.Service,
|
|
apps *app.Service,
|
|
inboundSvc *inbound.Service,
|
|
lists *inbound.Lists,
|
|
dmarcSvc *dmarc.Service,
|
|
cfg Config,
|
|
v *view.Engine,
|
|
dns *dnscheck.Checker,
|
|
machine *health.MachineSampler,
|
|
a *auth.Module,
|
|
) *Handlers {
|
|
return &Handlers{
|
|
store: st,
|
|
domains: domains,
|
|
apps: apps,
|
|
inbound: inboundSvc,
|
|
lists: lists,
|
|
dmarc: dmarcSvc,
|
|
cfg: cfg,
|
|
view: v,
|
|
dns: dns,
|
|
machine: machine,
|
|
auth: a,
|
|
// The test message goes as notifications go (test-email).
|
|
testMail: notify.NewMailer(cfg.Hostname),
|
|
}
|
|
}
|
|
|
|
func logf(format string, args ...any) {
|
|
log.Printf(format, args...)
|
|
}
|