release: 0.5.0: Pre-1.0 security review, domain-admin role, doc consolidation
A bounce could be recorded as a successful delivery. The log-tailer's Fixed: - A bounce could be recorded as a successful delivery. The log-tailer's - Log-tailer resumes where it stopped instead of jumping to end-of-file on - Level-2 rate limit no longer overshoots under concurrency: messages that Changed: - The project has a single public home: `github.com/mixeme/selfpost`. Codeberg - Code comments no longer cite the archived specification. References like - `docs/code-review.md` is gone. Its plan is finished — phases 0 (bar the - `docs/architecture.md` gained a *Code layers* section: a diagram of - Phase 1 doc/code hygiene (`docs/code-review.md`): removed ~30 stale … (1 more in CHANGELOG) Security: - Pre-release security review (plan § D, model Fable, 2026-08-06): full pass - `saslpasswd2` argv: the application login is now passed after a `--` Added: - Optional password encryption for the two secret-bearing downloads (plan - Domain import accepts an encrypted export: the envelope is detected by its - `selfpost-backup` writes encrypted archives and reads them back: - `TestParseDelivery` covers the exotic mail.log shapes the review asked for - docs: README *Encrypting a backup or export*; `docs/security.md` § … (14 more in CHANGELOG) Removed: - docs: `security.md` — accepted risk «restore old backup revives session rows» Changed: - docs: `implementation-plan.md` trimmed to the sole open v1.x gate — pre-release - docs: `architecture.md` — sessions (SQLite, idle renew, password change), - docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow. - docs: `security.md` — accepted risk for send-log rows stuck at `queued` after - docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail. … (10 more in CHANGELOG) Full detail: CHANGELOG.md (0.5.0).
This commit is contained in:
@@ -10,7 +10,7 @@ import (
|
||||
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
|
||||
var ErrNoAdmin = errors.New("no administrator account")
|
||||
|
||||
// Admin is the single panel administrator (spec 7.6.1).
|
||||
// Admin is the single panel administrator (security.md).
|
||||
type Admin struct {
|
||||
Username string
|
||||
PasswordHash string
|
||||
@@ -19,7 +19,7 @@ type Admin struct {
|
||||
|
||||
// AdminExists reports whether the administrator account has been created. This
|
||||
// doubles as the "primary setup complete" flag: once true, the /setup route is
|
||||
// permanently gone (spec 7.6.1).
|
||||
// permanently gone (security.md).
|
||||
func (s *Store) AdminExists() (bool, error) {
|
||||
var n int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestUpdateAdmin(t *testing.T) {
|
||||
}
|
||||
|
||||
// An update before setup must not create the account: only the one-time setup
|
||||
// flow may do that (spec 7.6.1).
|
||||
// flow may do that (security.md).
|
||||
func TestUpdateAdminWithoutAdmin(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
|
||||
@@ -8,23 +8,24 @@ import (
|
||||
)
|
||||
|
||||
// ErrLoginExists is returned when an application login is already taken. The
|
||||
// login is globally unique because it is the SASL identity Postfix authenticates
|
||||
// (one sasldb2 across all domains, spec 5.1).
|
||||
// login is globally unique because it is the SASL identity Postfix
|
||||
// authenticates (one sasldb2 across all domains, architecture.md § Mail path).
|
||||
var ErrLoginExists = errors.New("application login already exists")
|
||||
|
||||
// ErrApplicationNotFound is returned when an application id does not exist.
|
||||
var ErrApplicationNotFound = errors.New("application not found")
|
||||
|
||||
// Address modes (spec 4.1). Kept in sync with the CHECK constraint in the schema.
|
||||
// Address modes (product.md § Multi-domain model). Kept in sync with the CHECK
|
||||
// constraint in the schema.
|
||||
const (
|
||||
AddressModeWildcard = "wildcard" // any address within the application's domain
|
||||
AddressModeList = "list" // only the explicitly listed addresses
|
||||
)
|
||||
|
||||
// Application is a SASL account bound to a single domain (spec 4.1, 5.1). The
|
||||
// password is never stored here — only in sasldb2, hashed — so it can be shown
|
||||
// exactly once at creation/regeneration (spec 7.6.1). Addresses is populated only
|
||||
// in 'list' mode.
|
||||
// Application is a SASL account bound to a single domain (product.md §
|
||||
// Multi-domain model). The password is never stored here — only in sasldb2,
|
||||
// hashed — so it can be shown exactly once at creation/regeneration
|
||||
// (security.md). Addresses is populated only in 'list' mode.
|
||||
type Application struct {
|
||||
ID int64
|
||||
DomainID int64
|
||||
@@ -35,9 +36,9 @@ type Application struct {
|
||||
}
|
||||
|
||||
// Binding is one sender-address → login pair, as consumed by the
|
||||
// smtpd_sender_login_maps generator (spec 5.1). For a wildcard application the
|
||||
// Address is the domain wildcard "@example.com"; for a list application there is
|
||||
// one Binding per listed address.
|
||||
// smtpd_sender_login_maps generator (architecture.md § Mail path). For a
|
||||
// wildcard application the Address is the domain wildcard "@example.com"; for
|
||||
// a list application there is one Binding per listed address.
|
||||
type Binding struct {
|
||||
Address string
|
||||
Login string
|
||||
@@ -45,7 +46,7 @@ type Binding struct {
|
||||
|
||||
// AddApplication inserts an application and, in list mode, its addresses, in a
|
||||
// single transaction. The caller must have validated login and every address
|
||||
// (spec 7.6.2) beforehand; the query is parameterised regardless. A duplicate
|
||||
// (security.md) beforehand; the query is parameterised regardless. A duplicate
|
||||
// login maps to ErrLoginExists.
|
||||
func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) {
|
||||
now := time.Now().UTC()
|
||||
@@ -82,7 +83,7 @@ func (s *Store) AddApplication(domainID int64, login, mode string, addresses []s
|
||||
}
|
||||
|
||||
// UpdateApplicationMode switches an application's address mode and replaces its
|
||||
// address list atomically (spec 7.2.7). The login and password are untouched.
|
||||
// address list atomically (product.md). The login and password are untouched.
|
||||
// Returns ErrApplicationNotFound if the id does not exist.
|
||||
func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error {
|
||||
tx, err := s.db.Begin()
|
||||
@@ -163,7 +164,7 @@ func (s *Store) GetApplication(id int64) (Application, error) {
|
||||
}
|
||||
|
||||
// ListApplicationsByDomain returns a domain's applications ordered by login,
|
||||
// each with its address list populated (spec 7.2.6).
|
||||
// each with its address list populated (product.md).
|
||||
func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE domain_id = ? ORDER BY login",
|
||||
@@ -196,8 +197,9 @@ func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error)
|
||||
}
|
||||
|
||||
// ListApplicationLogins returns every application login across all domains,
|
||||
// ordered, for the send-log monitoring screen's filter dropdown (spec 7.2).
|
||||
// Logins are globally unique (spec 5.1), so no domain qualifier is needed.
|
||||
// ordered, for the send-log monitoring screen's filter dropdown (product.md).
|
||||
// Logins are globally unique (architecture.md § Mail path), so no domain
|
||||
// qualifier is needed.
|
||||
func (s *Store) ListApplicationLogins() ([]string, error) {
|
||||
rows, err := s.db.Query("SELECT login FROM applications ORDER BY login")
|
||||
if err != nil {
|
||||
@@ -218,7 +220,7 @@ func (s *Store) ListApplicationLogins() ([]string, error) {
|
||||
|
||||
// ListLoginsByDomain returns the SASL logins of a domain's applications. Used to
|
||||
// purge sasldb2 entries before a domain (and its applications via cascade) is
|
||||
// deleted, while the logins are still known (spec 7.2.4).
|
||||
// deleted, while the logins are still known (product.md).
|
||||
func (s *Store) ListLoginsByDomain(domainID int64) ([]string, error) {
|
||||
rows, err := s.db.Query("SELECT login FROM applications WHERE domain_id = ? ORDER BY login", domainID)
|
||||
if err != nil {
|
||||
@@ -237,10 +239,11 @@ func (s *Store) ListLoginsByDomain(domainID int64) ([]string, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListBindings returns every sender-address → login pair across all domains, the
|
||||
// raw material for the smtpd_sender_login_maps file (spec 5.1). Wildcard
|
||||
// applications yield a single "@domain" binding; list applications yield one
|
||||
// binding per address. Ordered deterministically so the generated map is stable.
|
||||
// ListBindings returns every sender-address → login pair across all domains,
|
||||
// the raw material for the smtpd_sender_login_maps file (architecture.md §
|
||||
// Mail path). Wildcard applications yield a single "@domain" binding; list
|
||||
// applications yield one binding per address. Ordered deterministically so the
|
||||
// generated map is stable.
|
||||
func (s *Store) ListBindings() ([]Binding, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT '@' || d.name, a.login
|
||||
@@ -271,7 +274,7 @@ func (s *Store) ListBindings() ([]Binding, error) {
|
||||
|
||||
// DeleteApplication removes an application and its addresses (via cascade),
|
||||
// returning the deleted application so the caller can drop its sasldb2 entry
|
||||
// (spec 7.2.8). Returns ErrApplicationNotFound if no such row existed.
|
||||
// (product.md). Returns ErrApplicationNotFound if no such row existed.
|
||||
func (s *Store) DeleteApplication(id int64) (Application, error) {
|
||||
a, err := s.GetApplication(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -16,9 +16,10 @@ var ErrDomainExists = errors.New("domain already exists")
|
||||
// ErrDomainNotFound is returned when a domain id/name does not exist.
|
||||
var ErrDomainNotFound = errors.New("domain not found")
|
||||
|
||||
// Domain is a sending domain managed through the panel (spec 4.1). The DKIM key
|
||||
// material itself lives on disk under /data; this row records the selector and
|
||||
// metadata. AppCount is populated by the listing queries, not stored.
|
||||
// Domain is a sending domain managed through the panel (product.md §
|
||||
// Multi-domain model). The DKIM key material itself lives on disk under /data;
|
||||
// this row records the selector and metadata. AppCount is populated by the
|
||||
// listing queries, not stored.
|
||||
type Domain struct {
|
||||
ID int64
|
||||
Name string
|
||||
@@ -28,7 +29,7 @@ type Domain struct {
|
||||
}
|
||||
|
||||
// AddDomain inserts a new sending domain. The caller is responsible for having
|
||||
// validated name (spec 7.6.2) before it reaches SQL; the query is parameterised
|
||||
// validated name (security.md) before it reaches SQL; the query is parameterised
|
||||
// regardless. A duplicate name maps to ErrDomainExists.
|
||||
func (s *Store) AddDomain(name, selector string) (Domain, error) {
|
||||
now := time.Now().UTC()
|
||||
@@ -49,7 +50,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) {
|
||||
return Domain{ID: id, Name: name, DKIMSelector: selector, CreatedAt: now}, nil
|
||||
}
|
||||
|
||||
// ListDomains returns every domain with its bound-application count (spec 7.2.2),
|
||||
// ListDomains returns every domain with its bound-application count (product.md),
|
||||
// ordered by name.
|
||||
func (s *Store) ListDomains() ([]Domain, error) {
|
||||
rows, err := s.db.Query(`
|
||||
@@ -92,7 +93,7 @@ func (s *Store) GetDomain(id int64) (Domain, error) {
|
||||
}
|
||||
|
||||
// DeleteDomain removes a domain. Its applications and their address/binding rows
|
||||
// go with it via ON DELETE CASCADE (spec 7.2.4). Returns ErrDomainNotFound if no
|
||||
// go with it via ON DELETE CASCADE (product.md). Returns ErrDomainNotFound if no
|
||||
// such row existed.
|
||||
func (s *Store) DeleteDomain(id int64) error {
|
||||
res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id)
|
||||
|
||||
@@ -62,8 +62,8 @@ func TestDeleteDomainCascadesApplications(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Insert an application + address directly (the AddApplication API lands in
|
||||
// Phase 4); this verifies the ON DELETE CASCADE wiring now.
|
||||
// Insert an application + address directly (bypassing the AddApplication
|
||||
// API) to verify the ON DELETE CASCADE wiring.
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := st.db.Exec(
|
||||
"INSERT INTO applications (domain_id, login, address_mode, created_at) VALUES (?, ?, 'wildcard', ?)",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogtailState is the persisted read position of the mail.log tailer: how far
|
||||
// into the log it had got, and a fingerprint of the file that offset refers to.
|
||||
// It lets the tailer resume after a panel restart instead of jumping to
|
||||
// end-of-file and losing the delivery lines written while it was down.
|
||||
type LogtailState struct {
|
||||
Fingerprint string
|
||||
Offset int64
|
||||
}
|
||||
|
||||
// LogtailState returns the stored read position for path. ok is false the first
|
||||
// time a path is followed (nothing persisted yet), which the tailer treats as
|
||||
// "start at the end".
|
||||
func (s *Store) LogtailState(path string) (LogtailState, bool, error) {
|
||||
var st LogtailState
|
||||
err := s.db.QueryRow(
|
||||
`SELECT fingerprint, read_offset FROM logtail_state WHERE path = ?`,
|
||||
path,
|
||||
).Scan(&st.Fingerprint, &st.Offset)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return LogtailState{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return LogtailState{}, false, fmt.Errorf("read logtail state for %q: %w", path, err)
|
||||
}
|
||||
return st, true, nil
|
||||
}
|
||||
|
||||
// SaveLogtailState records the tailer's read position for path, replacing any
|
||||
// previous one. It is called on a timer while tailing, so it is a single small
|
||||
// upsert rather than a transaction.
|
||||
func (s *Store) SaveLogtailState(path string, st LogtailState) error {
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO logtail_state (path, fingerprint, read_offset, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
fingerprint = excluded.fingerprint,
|
||||
read_offset = excluded.read_offset,
|
||||
updated_at = excluded.updated_at`,
|
||||
path, st.Fingerprint, st.Offset, time.Now().UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save logtail state for %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Log-tailer read position. Without it the tailer starts at end-of-file on
|
||||
-- every start, so delivery lines written while the panel was down are never
|
||||
-- parsed and their send-log rows stay "queued" forever. One row per followed
|
||||
-- path; fingerprint identifies the file the offset belongs to (the head bytes
|
||||
-- of the log), so a rotated or recreated mail.log is detected across a restart,
|
||||
-- where os.SameFile cannot help.
|
||||
CREATE TABLE logtail_state (
|
||||
path TEXT PRIMARY KEY,
|
||||
fingerprint TEXT NOT NULL,
|
||||
read_offset INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -9,23 +9,25 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rate-limit scopes (spec 7.4). A level-2 limit is attached either to a domain
|
||||
// (counted across all its applications and IPs) or to a single application.
|
||||
// Rate-limit scopes (README § Rate limiting). A level-2 limit is attached
|
||||
// either to a domain (counted across all its applications and IPs) or to a
|
||||
// single application.
|
||||
const (
|
||||
RateLimitScopeDomain = "domain"
|
||||
RateLimitScopeApp = "application"
|
||||
)
|
||||
|
||||
// RateLimit is a differentiated level-2 rate limit (spec 7.4): an optional set
|
||||
// of expected client IPs plus a message ceiling over a sliding window, attached
|
||||
// to a domain or an application. It is enforced in the journal-milter; level 1
|
||||
// (Postfix anvil, spec 5) is the IP backstop that always applies even when this
|
||||
// is absent or the milter is down.
|
||||
// RateLimit is a differentiated level-2 rate limit (README § Rate limiting):
|
||||
// an optional set of expected client IPs plus a message ceiling over a sliding
|
||||
// window, attached to a domain or an application. It is enforced in the
|
||||
// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the
|
||||
// IP backstop that always applies even when this is absent or the milter is
|
||||
// down.
|
||||
//
|
||||
// Both the IP binding and the ceiling are optional in the schema, but a limit is
|
||||
// only enforced when it is Active(): the design deliberately allows an admin to
|
||||
// leave the IP binding empty for apps that send from changing IPs, in which case
|
||||
// only level 1 protects them (spec 7.4's caveat).
|
||||
// Both the IP binding and the ceiling are optional in the schema, but a limit
|
||||
// is only enforced when it is Active(): the design deliberately allows an
|
||||
// admin to leave the IP binding empty for apps that send from changing IPs, in
|
||||
// which case only level 1 protects them (README § Rate limiting).
|
||||
type RateLimit struct {
|
||||
Scope string
|
||||
RefID int64
|
||||
@@ -35,8 +37,9 @@ type RateLimit struct {
|
||||
}
|
||||
|
||||
// Active reports whether the limit is fully configured and should be enforced.
|
||||
// A missing IP binding, ceiling or window leaves the differentiated limit inert
|
||||
// (spec 7.4): the IP binding is what scopes the limit to a known sender.
|
||||
// A missing IP binding, ceiling or window leaves the differentiated limit
|
||||
// inert (README § Rate limiting): the IP binding is what scopes the limit to a
|
||||
// known sender.
|
||||
func (r RateLimit) Active() bool {
|
||||
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
|
||||
}
|
||||
@@ -76,7 +79,7 @@ func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error)
|
||||
}
|
||||
|
||||
// SetRateLimit upserts the level-2 limit for a domain or application. The caller
|
||||
// (panel) has already validated the IPs and numbers (spec 7.6.2); values are
|
||||
// (panel) has already validated the IPs and numbers (security.md); values are
|
||||
// stored via bound parameters and read back live by the milter.
|
||||
func (s *Store) SetRateLimit(rl RateLimit) error {
|
||||
_, err := s.db.Exec(
|
||||
@@ -148,12 +151,13 @@ func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
|
||||
return rl, true, nil
|
||||
}
|
||||
|
||||
// CountMessages returns how many distinct messages the reference (a domain name
|
||||
// or an application login) has queued since t, for the level-2 sliding window
|
||||
// (spec 7.4). It counts distinct queue-ids — one message with many recipients is
|
||||
// one message, matching level 1's per-message semantics — and excludes rows that
|
||||
// were themselves rejected by a limit (they were never sent). It reuses the send
|
||||
// log the journal already writes (spec 7.4: "переиспользует данные журнала").
|
||||
// CountMessages returns how many distinct messages the reference (a domain
|
||||
// name or an application login) has queued since t, for the level-2 sliding
|
||||
// window (README § Rate limiting). It counts distinct queue-ids — one message
|
||||
// with many recipients is one message, matching level 1's per-message
|
||||
// semantics — and excludes rows that were themselves rejected by a limit (they
|
||||
// were never sent). It reuses the send log the journal already writes (README
|
||||
// § Rate limiting — the limiter reuses the send log).
|
||||
func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error) {
|
||||
var column string
|
||||
switch scope {
|
||||
|
||||
+24
-20
@@ -6,23 +6,25 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Send-log status values (spec 7.3). "queued" is written by the journal-milter
|
||||
// when a message is accepted; the log-tailer advances it to one of the final
|
||||
// states as Postfix reports delivery per recipient.
|
||||
// Send-log status values (architecture.md § Persistence). "queued" is written
|
||||
// by the journal-milter when a message is accepted; the log-tailer advances it
|
||||
// to one of the final states as Postfix reports delivery per recipient.
|
||||
const (
|
||||
StatusQueued = "queued"
|
||||
StatusSent = "sent"
|
||||
StatusDeferred = "deferred"
|
||||
StatusBounced = "bounced"
|
||||
// StatusRejected marks a message the journal-milter refused with a 4xx under
|
||||
// a level-2 rate limit (spec 7.4). Such a row never gets a queue-id and is
|
||||
// excluded from the level-2 message count (it was never sent).
|
||||
// a level-2 rate limit (README § Rate limiting). Such a row never gets a
|
||||
// queue-id and is excluded from the level-2 message count (it was never
|
||||
// sent).
|
||||
StatusRejected = "rejected"
|
||||
)
|
||||
|
||||
// SendLogEntry is a single queued send-log row. The journal-milter creates one
|
||||
// per (queue-id, recipient) pair at end-of-message (spec 7.3.3); every field
|
||||
// except the status/timestamps comes from the accepted message.
|
||||
// per (queue-id, recipient) pair at end-of-message (architecture.md §
|
||||
// Persistence); every field except the status/timestamps comes from the
|
||||
// accepted message.
|
||||
type SendLogEntry struct {
|
||||
QueueID string
|
||||
Domain string
|
||||
@@ -34,8 +36,8 @@ type SendLogEntry struct {
|
||||
|
||||
// InsertQueued records an accepted message in the send log with status
|
||||
// "queued". It is called from the journal-milter hot path, so it returns any
|
||||
// error for the caller to log rather than deciding policy here; the milter must
|
||||
// stay fail-open regardless (spec 7.3).
|
||||
// error for the caller to log rather than deciding policy here; the milter
|
||||
// must stay fail-open regardless (architecture.md § Persistence).
|
||||
func (s *Store) InsertQueued(e SendLogEntry) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(
|
||||
@@ -51,9 +53,10 @@ func (s *Store) InsertQueued(e SendLogEntry) error {
|
||||
}
|
||||
|
||||
// InsertRejected records a message the journal-milter refused under a level-2
|
||||
// rate limit (spec 7.4), so the rejection is visible in the send-log UI. Only
|
||||
// the fields known at MAIL FROM are set (domain, sender, app login); there is no
|
||||
// queue-id or recipient because the message was rejected before it was queued.
|
||||
// rate limit (README § Rate limiting), so the rejection is visible in the
|
||||
// send-log UI. Only the fields known at MAIL FROM are set (domain, sender, app
|
||||
// login); there is no queue-id or recipient because the message was rejected
|
||||
// before it was queued.
|
||||
func (s *Store) InsertRejected(e SendLogEntry) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(
|
||||
@@ -88,9 +91,9 @@ func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SendLogRow is one row as returned to the monitoring UI (spec 7.2, 7.3.3): a
|
||||
// SendLogEntry plus the fields that only exist once a row has been written
|
||||
// (id, current status, timestamps).
|
||||
// SendLogRow is one row as returned to the monitoring UI (architecture.md §
|
||||
// Persistence): a SendLogEntry plus the fields that only exist once a row has
|
||||
// been written (id, current status, timestamps).
|
||||
type SendLogRow struct {
|
||||
ID int64
|
||||
QueueID string
|
||||
@@ -111,7 +114,7 @@ type SendLogFilter struct {
|
||||
}
|
||||
|
||||
// QuerySendLog returns send-log rows matching filter, newest first, for the
|
||||
// monitoring screen's server-side pagination (spec 7.2's send-log view).
|
||||
// monitoring screen's server-side pagination (product.md's send-log view).
|
||||
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
|
||||
where, args := sendLogWhere(filter)
|
||||
args = append(args, limit, offset)
|
||||
@@ -171,10 +174,11 @@ func sendLogWhere(f SendLogFilter) (string, []any) {
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
// DeleteSendLogBefore removes send-log rows created before cutoff, implementing
|
||||
// the configurable retention window (spec 7.3, SEND_LOG_RETENTION_DAYS). It
|
||||
// returns the number of rows pruned. created_at is stored as RFC3339 UTC, so a
|
||||
// lexical comparison against the same format is chronologically correct.
|
||||
// DeleteSendLogBefore removes send-log rows created before cutoff,
|
||||
// implementing the configurable retention window (architecture.md §
|
||||
// Persistence, SEND_LOG_RETENTION_DAYS). It returns the number of rows pruned.
|
||||
// created_at is stored as RFC3339 UTC, so a lexical comparison against the
|
||||
// same format is chronologically correct.
|
||||
func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
|
||||
res, err := s.db.Exec(
|
||||
`DELETE FROM send_log WHERE created_at < ?`,
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// readSendLog returns every send_log row ordered by id. Phase 6 has no read
|
||||
// query yet (the monitoring UI is Phase 7), so tests read the table directly.
|
||||
// readSendLog returns every send_log row ordered by id. The monitoring UI has
|
||||
// no equivalent read query, so tests read the table directly.
|
||||
type sendLogRow struct {
|
||||
QueueID string
|
||||
Domain string
|
||||
@@ -40,7 +40,8 @@ func readSendLog(t *testing.T, s *Store) []sendLogRow {
|
||||
func TestInsertQueuedAndUpdateStatus(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
|
||||
// Two recipients on the same queue-id → two independent rows (spec 7.3.3).
|
||||
// Two recipients on the same queue-id → two independent rows (architecture.md
|
||||
// § Persistence).
|
||||
for _, to := range []string{"a@example.net", "b@example.net"} {
|
||||
if err := st.InsertQueued(SendLogEntry{
|
||||
QueueID: "ABC123",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package store owns the SelfPost SQLite database: the single file under /data
|
||||
// that persists the administrator account, sending domains and applications,
|
||||
// the send log and rate-limit settings (spec 9). It exposes typed queries so
|
||||
// the rest of the panel never builds SQL by hand.
|
||||
// the send log and rate-limit settings (architecture.md § Persistence). It
|
||||
// exposes typed queries so the rest of the panel never builds SQL by hand.
|
||||
package store
|
||||
|
||||
import (
|
||||
|
||||
Reference in New Issue
Block a user