Agreed by the owner on 2026-10-13 ("the e-mail must be confirmed");
notifications to an unconfirmed address settled on 2026-10-17: "Без
подтверждения слать". Mockups 2f5f5b2, route guard b7bbb2c, panel guard
0774da2.
Migration 0013: users.email_confirmed_at ('' for every address saved
before it) and user_links - the SHA-256 of a link's token, its user,
purpose (confirm, reset), address, created and expiry. A changed address
loses its mark (keepConfirmed, in UpdateUser and SaveUser).
internal/web/auth/links.go. A link is 256 bits from crypto/rand, mailed as
https://SELFPOST_HOSTNAME/... - never the request's Host. Confirmation:
24 h, sent when an address is saved (Account, or the global role's user
form) and by Account's Send the link again; its page /confirm/<token> has
a button (a GET changes nothing) and confirms only for a session of its
own account. Reset: /login/reset asks for a username and answers the same
for every name (the sign-in page, "If that account has a confirmed e-mail,
the link is on its way"), mailing after the answer; only a confirmed
address gets one, none within ten minutes of the last, five links an hour
per account, five requests in 15 minutes per client address;
/login/reset/<token> (30 min) sets the password with the link taken in
the same transaction that checks the address again
(store.TakeResetLink), then ends every session. Changing the e-mail on
Account asks for the current password. A password set on Account or the
user form removes the reset links; a restore removes every link; the stop
waits for links being mailed (Server.Drain); a rejected request's log line
hides a link's token (loggedPath).
Reviewed for security before the commit (2026-10-17): no high; two
medium and five low, all fixed here - confirming needs the account's
session and the mail does not name the account (a mistyped address was a
takeover); a reset request neither replaces the links already sent nor
spends the account's quota inside ten minutes; take-and-set atomic; the
confirmation mail sent after the answer; the stop waits for mail; a
restore deletes the links; the token kept out of the log.
Tests: store TestUserLinks, TestUserLinkTakenOnce, TestEmailConfirmation,
TestTakeResetLink; auth TestResetSendsALinkOnlyToAConfirmedAddress,
TestResetLinkSetsThePasswordOnce, TestResetLinkDiesWithTheAddress,
TestResetRequestsAreLimited, TestResetRequestsAreSpaced, TestConfirmLink,
TestLinkPathsWithoutAToken; handlers TestAccountEmailIsConfirmedByItsLink,
TestUserFormSendsANewAddressItsLink; web TestLoggedPathHidesTokens; the
view's pages and fixtures; e2e password_reset_by_e_mail runs it through
the sink (and checks a link from before the restore is dead). go vet +
go test (26 packages) and make e2e on the test server for this tree.
Docs: guide § Account, security.md (the bullet and what ends sessions),
architecture (routes), schema-migrations (row 13, users, user_links),
help #account (word for word with the mockup), roadmap row done,
CHANGELOG [Unreleased].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 KiB
SQLite schema and migrations
What this file is. A living reference for the embedded SQLite schema: the 2.0 baseline, the migrations added after it, and how a database is opened. When you add or review a migration, update this file in the same change.
Source of truth in code: internal/store/migrations/*.sql, applied by
internal/store/store.go (migrate()).
Related: architecture.md § Persistence; plans/panel-redesign.md § No compatibility; development.md § Documentation.
Current snapshot
| Field | Value |
|---|---|
| Chain head | user_version = 13 |
| Files | 0001_init.sql (the 2.0 baseline), 0002_inbound_spam_log.sql, 0003_send_log_lines.sql, 0004_notifications.sql, 0005_send_log_rejected_index.sql, 0006_sender_check.sql, 0007_delivery_log_days.sql, 0008_tenant_boundary.sql, 0009_refusal_line.sql, 0010_send_log_indexes.sql, 0011_notify_addressees.sql, 0012_inbound_upstream.sql |
| File mark | PRAGMA application_id = 0x53503230 ("SP20") |
| Database file | /data/selfpost.db (bind mount) |
| Compatibility | 2.x MINOR releases must boot a 2.0.0 data directory. Nothing is carried over from 1.x. |
2.0 starts from an empty data directory
Decided by the owner on 2026-09-21: the redesign is a breaking release, the
1.x migration chain (0001 … 0010, with its admin table created in the
first file and dropped in the fifth) is deleted from the binary, and there is
no upgrade path. The only live 1.x installation is the owner's and is set up
again on 2.0. The 1.x chain stays in git history (last present at 1.9.5).
What that means in practice:
- A fresh
/datagets the baseline and nothing else. - A 1.x database is refused, not migrated.
store.OpenreturnsErrForeignSchemafor a file that has a schema version but not the 2.xapplication_id. Without that check a 1.x file atuser_version = 10would read as "already migrated" and the panel would start on tables it does not know. The file is not modified. The same applies to restoring a 1.x full backup into a 2.0 container. - A database whose version is higher than the build knows is refused too.
- Domains can still be carried by hand: a 1.x domain export (JSON, with the DKIM key and applications) imports into 2.0 — the transfer format is not the database.
How migrations run
- Migrations are embedded at build time (
//go:embed migrations/*.sql). - Filenames are sorted lexicographically; file order = version number.
PRAGMA user_versionrecords progress: after file i (0-based), version is i + 1.- Each pending migration runs in its own transaction, then bumps
user_version. - There is no down-migration; fixes ship as a new
00NN_*.sqlfile.
Implication: once 2.0.0 is released, 0001_init.sql is never edited
again and no file is deleted, renamed or reordered — every 2.x database has
already run it. 2.0.0 and 2.0.1 shipped with the baseline alone; the
features after them add files, the first being 0002_inbound_spam_log.sql of
inbound-antispam-panel (2.1.0), then 0003_send_log_lines.sql of
delivery-log-storage (2.2.0), 0004_notifications.sql and
0005_send_log_rejected_index.sql of panel-notifications (2.3.0), and
0006_sender_check.sql of from-header-binding (2.4.0), and
0007_delivery_log_days.sql and 0008_tenant_boundary.sql of the 2026-10-12
audit (2.5.0), 0009_refusal_line.sql (2.5.1) and
0010_send_log_indexes.sql (2.5.3) and 0011_notify_addressees.sql
(2.5.4), and 0012_inbound_upstream.sql of inbound-upstream-result
(2.10.0).
Migration chain
| Ver | File | Shipped | Kind | Summary |
|---|---|---|---|---|
| 1 | 0001_init.sql |
2.0.0 | DDL | The whole 2.0 schema, below |
| 2 | 0002_inbound_spam_log.sql |
2.1.0 | DDL | inbound_spam_log, inbound_spam_list and two indexes; nothing existing changes |
| 3 | 0003_send_log_lines.sql |
2.2.0 | DDL | send_log_lines and one index; nothing existing changes |
| 4 | 0004_notifications.sql |
2.3.0 | DDL | users.notify (default 1: every user starts with notifications on) and notify_state |
| 5 | 0005_send_log_rejected_index.sql |
2.3.0 | DDL | idx_send_log_rejected, a partial index of the refused rows of send_log by time; a file of its own because the test installation had run 0004 before the review asked for it |
| 6 | 0006_sender_check.sql |
2.4.0 | DDL | send_log.reason and send_log.header_from (both default '': a row refused earlier keeps no reason), and sender_check_days |
| 7 | 0007_delivery_log_days.sql |
2.5.0 | DDL | delivery_log_days: what the reconcile sweep and the log tailer did, a row per UTC day, kept a year, shown nowhere - the measurement that decides the sweep's fate (audit 2026-10-12, stage 1). send_log.reason gains the values reconcile and reconcile-contradicted without a schema change |
| 8 | 0008_tenant_boundary.sql |
2.5.0 | DDL + data | idx_applications_login_nocase: an application login is unique without regard to case (audit 2026-10-12, H4). send_log.domain_id and inbound_spam_log.inbound_domain_id (ON DELETE SET NULL), each with an index, filled once by name from the registry as it was then: a row belongs to a domain by id, and a domain deleted and added again starts with an empty log (L12) |
| 9 | 0009_refusal_line.sql |
2.5.1 | DDL | send_log.refusal_line: Postfix's line about a refusal at MAIL FROM (a level-2 limit, a client address off the list), which has no queue id; the log tailer puts it on the row, found by send_log.client_ip, the client the refusal came from; send_log.recovered, a refusal row the tailer wrote from mail.log because the panel stopped before writing it; sender_check_days.messages (messages deferred mid-message, counted apart from the connections turned away) and sender_check_days.estimated (a day whose downtime is an upper bound) (audit 2026-10-12, stage 2b) |
| 10 | 0010_send_log_indexes.sql |
2.5.3 | DDL | Four indexes of send_log, each for a question that walked the table or sorted a domain's log (audit 2026-10-12, M2, stage 3): idx_send_log_open, partial, the queued and deferred rows by time (the tailer's sweep every 5 min); idx_send_log_domain_id and idx_send_log_app_login, a page of one domain's or one application's log in id order; idx_send_log_refusal_line, partial, whether a refusal line is already on a row. TestSendLogQueriesUseTheirIndexes checks the plans of these and of the other by-time and by-scope statements the store runs |
| 11 | 0011_notify_addressees.sql |
2.5.4 | DDL | notify_addressees (key, address, told_at): who a subject's notification is owed to and who was handed it, so a copy that failed or was held back by the hourly ceiling goes out at a later check and a subject's end goes to those told (audit 2026-10-12, stage 4); deleted with its notify_state row |
| 12 | 0012_inbound_upstream.sql |
2.10.0 | DDL | inbound_spam_log gains queue_id, upstream_status ('', queued, held, sent, deferred, bounced; CHECK), upstream_relay, upstream_reply and upstream_at, and the partial index idx_inbound_spam_log_queue_id on rows that have a queue id: what the upstream answered about mail accepted on port 25, put on the row by the log tailer (inbound-upstream-result). Rows from before have none |
| 13 | 0013_email_links.sql |
2.14.0 | DDL | users.email_confirmed_at ('' for every address saved before it: all unconfirmed) and user_links, the one-time links that confirm an address and reset a password, with idx_user_links_user (password-reset; owner, 2026-10-13 and 2026-10-17) |
Kind: DDL — schema only; data — INSERT/UPDATE that must stay correct
for operators upgrading from older 2.x images.
Schema at head
| Table | Role |
|---|---|
settings |
Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user |
users |
Panel logins. role is global or domain; email is the user's own address; notify is the user's own switch for notification e-mails (v4); all_domains / all_inbound_domains widen a domain user to every domain of that direction; email_confirmed_at is when email was confirmed, '' while it is not, cleared when it changes (v13) |
user_links |
The one-time links of password-reset (v13): the SHA-256 of the token, the user, purpose (confirm, reset; CHECK), the address it was sent to, when, and until when; one live link per user and purpose |
sessions |
Panel login sessions (token hash, owning user by id and deleted with them, sliding idle expiry) |
domains |
Sending (outbound) domains. dmarc_rua is where aggregate reports go ('' = no reports) |
user_domains |
Outbound domains assigned to a domain user |
applications |
SASL applications per domain, with the client-IP restriction (auth_ip_restrict, auth_allowed_ips) |
application_addresses |
Explicit From addresses (list mode) |
rate_limits |
Level-2 limits per domain / application, manual or auto |
send_log |
Delivery journal. reason says why a row was refused (limit, client, from, and sender since 2.8.0) or that the sweep closed it (reconcile, reconcile-contradicted), header_from what the From: or Sender: line of a message refused for it said (v6), refusal_line, client_ip and recovered (v9), and domain_id (v8: the row belongs to a domain by id, ON DELETE SET NULL) |
logtail_state |
Log-tailer read position |
inbound_domains |
Inbound relay domains |
inbound_transports |
Upstream host / port / TLS per inbound domain |
inbound_recipients |
Allow-list when recipient_mode = list |
user_inbound_domains |
Inbound domains assigned to a domain user — granted separately from the outbound ones |
dmarc_reports |
Parsed aggregate report summaries |
dmarc_report_records |
Per-source rows inside a report |
inbound_spam_log |
Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (accept, reject, tempfail, quarantine) and who made it (rspamd, postfix, selfpost). Metadata only (v2). inbound_domain_id (v8) ties a row to its domain by id; queue_id and upstream_status, upstream_relay, upstream_reply, upstream_at (v12) are what the upstream answered for an accepted message |
inbound_spam_list |
Allow / deny entries for the external filter (address, domain, ip), instance-wide; unique per list (v2) |
notify_state |
What the panel has already written to its users about, one row per subject (dns:<domain>, cert, queue, limit:<domain>), so that each thing is said once and a restart says nothing again (v4) |
sender_check_days |
What the sender check costs, a row per day of the container's zone (TZ) that has something to say: seconds the panel did not answer while Postfix ran, connections turned away (deferred), messages deferred mid-message (messages), and whether the day's downtime is an upper bound (estimated). Kept a year (v6, v9) |
delivery_log_days |
What the reconcile sweep and the log tailer did, a row per day of the container's zone, kept a year. Read off a copy of the database; no page shows it (v7) |
notify_addressees |
Who a subject's notification is owed to and whether each was handed it; goes with the subject's notify_state row (v11) |
send_log_lines |
The mail.log lines Postfix wrote under a queue id, with the line's own time; what a message's page shows once the log has rotated. No reference to send_log: a line belongs to a row by queue id and time, and goes when no row carries its id (v3) |
TestBaselineSchema (internal/store/schema_test.go) pins every table, column
and index of this list; TestOpenMigratesA20Database opens a database that
holds the baseline alone.
What changed against the last 1.x schema
| 1.x | 2.0 | Why |
|---|---|---|
admin created in 0001, dropped in 0005 |
never exists | the reason for the squash |
users.role = global / domain_admin |
global / domain |
the role is a reach, not a rank — everyone in the panel is an administrator |
users.dmarc_report_email |
users.email |
a user's e-mail is theirs; it is what the panel writes to, and a domain's report address can be filled with it |
instance setting dmarc_report_email (mirror of the global user's field); domains.dmarc_rua NULL = inherit the instance default |
domains.dmarc_rua alone: the address, '' = no reports |
a domain's report address is one of three explicit choices — no reports, hosted, a typed address; nothing is inherited and no user's default exists (2026-10-10) |
| — | user_inbound_domains |
inbound is delegated per user, separately from outbound |
| — | users.all_domains, users.all_inbound_domains |
"All" includes domains added later |
rate_limits.allowed_ips (unused since 1.9.0) |
gone | client IPs belong to the application (auth_allowed_ips) |
Where a domain's DMARC reports go
domains.dmarc_rua is the address itself and the only thing stored; '' means
no reports, and it is what a new domain has. The choice shown in Domain
settings is derived from it: '' is no reports, the address SelfPost derives
for the domain and the server's hostname (dmarc.HostedReportAddress, offered
while hosted reports are enabled) is hosted, anything else is an address typed
for the domain. Nothing refers to a user: deleting one changes no domain.
Adding a migration
- Add
internal/store/migrations/00NN_short_name.sql(next number). - Update the table in Migration chain, the snapshot at the top, and the affected rows of Schema at head.
- Update
TestBaselineSchemaif tables, columns or indexes change, and add a test that opens a database at the previous version and migrates it. - A data migration states in a comment which 2.x versions it is written for.