Files
selfpost/internal/app/service.go
T
mix 60e08e802e release: 1.2.0: Panel 1.2.0-1.2.5: layout, RBAC, tests, GUI and CI fixes
[1.2.0]
The second MINOR after 1.0.0: domain administrators with per-domain scope, a panel visual refresh on the SelfPost palette, and refinements to navigation and the send log. Upgrading runs one SQLite migration (`0005_panel_users`); the single administrator becomes a global user.
Added:
  - panel: **domain-admin role** — global administrators manage panel users and
Fixed:
  - Docker build: `LICENSE` is no longer excluded by `.dockerignore`, so the
  - panel: on signed-in pages with a narrow card (**Settings**, the user form)
Changed:
  - panel: navigation session icons are distinct — **Settings** uses a gear,
  - panel: `/` redirects domain administrators to `/domains`; global users still
  - panel: **Sign out** in the navigation column uses the same type size and
  - panel: **visual style** brought in line with the SelfPost mark — brick accent
  - panel: the send log's **status is a badge**, in the same ok/warn/error/unknown
  … (1 more in CHANGELOG)
[1.2.1]
Panel refinements after 1.2.0: navigation icons, status-badge centreing, drill-down back-link placement, and safeguards for the sole global administrator. Upgrading is a tag bump; no migration.
Fixed:
  - panel: status badge text sat low in the box (and below the heading or label
  - panel: the **Users** navigation icon was two full silhouettes with staggered
  - panel: the **Settings** navigation icon was a sun-with-rays (circle plus
  - panel: the user create/edit form placed «Back to users» at the bottom of the
Changed:
  - panel: the user edit form disables role change and delete for the only global
  - panel: the user create/edit form hides **Assigned domains** when the role is
  - panel: **Settings** shows panel credentials and DMARC aggregate reports side
[1.2.2]
Status page layout after 1.2.1: paired cards in a wide column, denser machine and check copy, and a user-form checkbox fix. Upgrading is a tag bump; no migration.
Changed:
  - panel: **Status** is wide again so paired cards fill the column. Layout:
Fixed:
  - panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid
  - panel: on the user create/edit form, **Assigned domains** checkboxes stacked
[1.2.3]
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no migration.
Changed:
  - panel: the domain detail page is wide with paired cards (DKIM+SPF ‖ DMARC;
  - panel: Domains list — **Add domain** sits beside the domain field; the
  - panel: page URLs, browser titles, and headings are aligned — **Settings** is
Fixed:
  - image: `mail.log` rotation no longer silently stops when the build context
[1.2.4]
Level-2 rate-limit semantics inverted after 1.2.3, plus a small DNS field height fix. Upgrading is a tag bump; no migration.
Changed:
  - rate limiting (level 2): domain ceilings apply to every client IP (no IP
Fixed:
  - panel: on the domain DNS status grid, the Type (TXT) field height matches
[1.2.5]
Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
Changed:
  - panel: rate-limit forms refined — level-1 backstop as muted copy on the

Squashed CHANGELOG sections: [1.2.0], [1.2.1], [1.2.2], [1.2.3], [1.2.4], [1.2.5].

Full detail: CHANGELOG.md (1.2.0, 1.2.1, 1.2.2, 1.2.3, 1.2.4, 1.2.5).
2026-09-22 15:24:52 +03:00

266 lines
9.8 KiB
Go

// Package app owns application accounts (product.md § Multi-domain model): the
// SASL credentials in sasldb2, the per-application sender address mode, and
// the smtpd_sender_login_maps bindings that tie each login to the addresses it
// may send from. It keeps those three stores — the SQLite registry, sasldb2
// and the Postfix map — in agreement and drives the Postfix reload.
package app
import (
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// SenderMaps is the slice of the Postfix manager the application service needs:
// rebuilding the sender_login_maps from the current bindings and reloading.
// *postfix.Postfix satisfies it; tests substitute a fake.
type SenderMaps interface {
RebuildSenderLoginMaps(bindings []postfix.Binding) error
}
// Service coordinates application state across SQLite, sasldb2 and the Postfix
// sender_login_maps. Web handlers validate raw input first; the Service performs
// the domain-ownership checks that must not be skipped (security.md) and keeps
// the stores consistent.
type Service struct {
store *store.Store
sasl *SASLDB
pf SenderMaps
}
// NewService builds the application service over the shared store, the sasldb2
// manager and the Postfix manager.
func NewService(st *store.Store, sasl *SASLDB, pf SenderMaps) *Service {
return &Service{store: st, sasl: sasl, pf: pf}
}
// List returns a domain's applications with their address lists (product.md).
func (s *Service) List(domainID int64) ([]store.Application, error) {
return s.store.ListApplicationsByDomain(domainID)
}
// Get returns one application by id (store.ErrApplicationNotFound if absent).
func (s *Service) Get(id int64) (store.Application, error) {
return s.store.GetApplication(id)
}
// Create adds an application to a domain: it validates the login and (in list
// mode) that every address belongs to the domain (security.md), generates a
// strong password, writes the SASL account and rebuilds the sender map (spec
// 7.2.5). The generated password is returned so the caller can show it exactly
// once (security.md) — it is never persisted in plaintext.
//
// The registry row is written first so its UNIQUE constraint is the sole arbiter
// of a duplicate login (avoiding a check-then-act race and, crucially, avoiding
// clobbering an existing account's password in sasldb2). If the SASL write or
// the map rebuild fails, everything is rolled back so we never leave an
// application the panel cannot fully account for.
func (s *Service) Create(domainID int64, login, mode string, rawAddresses []string) (store.Application, string, error) {
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
if err != nil {
return store.Application{}, "", err
}
password, err := generatePassword()
if err != nil {
return store.Application{}, "", err
}
a, err := s.store.AddApplication(domainID, login, mode, addresses)
if err != nil {
return store.Application{}, "", err
}
if err := s.sasl.Set(login, password); err != nil {
s.rollbackCreate(a.ID, "") // login has no SASL account yet; nothing to unset
return store.Application{}, "", err
}
if err := s.Resync(); err != nil {
s.rollbackCreate(a.ID, login)
return store.Application{}, "", err
}
return a, password, nil
}
// rollbackCreate best-effort undoes a partially created application after a
// downstream failure: it removes the SASL account (if one was written) and the
// registry row. Errors here are subordinate to the original failure the caller
// returns.
func (s *Service) rollbackCreate(id int64, login string) {
if login != "" {
_ = s.sasl.Delete(login)
}
_, _ = s.store.DeleteApplication(id)
}
// ImportApplication re-creates an application from a domain-export file
// (architecture.md § Persistence): it validates the login and (in list mode)
// that every address belongs to the domain, inserts the registry row and
// writes the SASL account with the imported password verbatim, re-keyed under
// this instance's realm so the credential keeps working without regeneration.
// It deliberately does not rebuild the sender map — the caller (domain import)
// does that once after all applications are in — and returns
// store.ErrLoginExists if the login collides.
func (s *Service) ImportApplication(domainID int64, login, mode string, rawAddresses []string, password string) error {
addresses, err := s.validateForDomain(domainID, login, mode, rawAddresses)
if err != nil {
return err
}
if err := validateImportedPassword(password); err != nil {
return err
}
a, err := s.store.AddApplication(domainID, login, mode, addresses)
if err != nil {
return err // ErrLoginExists surfaces to the caller as a friendly message
}
if err := s.sasl.Set(login, password); err != nil {
_, _ = s.store.DeleteApplication(a.ID)
return err
}
return nil
}
// Secret returns an application's stored password for a domain export
// (architecture.md § Persistence). See SASLDB.Secret for why this is possible
// and safe.
func (s *Service) Secret(login string) (string, error) {
return s.sasl.Secret(login)
}
// UpdateMode switches an application's address mode / list and rebuilds the
// sender map (product.md). The login and password are untouched. Addresses are
// re-validated against the application's domain.
func (s *Service) UpdateMode(id int64, mode string, rawAddresses []string) error {
a, err := s.store.GetApplication(id)
if err != nil {
return err
}
addresses, err := s.validateForDomain(a.DomainID, a.Login, mode, rawAddresses)
if err != nil {
return err
}
if err := s.store.UpdateApplicationMode(id, mode, addresses); err != nil {
return err
}
return s.Resync()
}
// RegeneratePassword issues a fresh password for an existing application (spec
// 7.2.9). The old password is invalidated by overwriting the SASL account; the
// address mode and bindings are unchanged, so no map rebuild is needed. The new
// password is returned to be shown once.
func (s *Service) RegeneratePassword(id int64) (string, error) {
a, err := s.store.GetApplication(id)
if err != nil {
return "", err
}
password, err := generatePassword()
if err != nil {
return "", err
}
if err := s.sasl.Set(a.Login, password); err != nil {
return "", err
}
return password, nil
}
// Delete removes an application: its SASL account, its registry row (and address
// rows via cascade) and its sender-map bindings, then reloads Postfix (spec
// 7.2.8). The domain and other applications are untouched.
func (s *Service) Delete(id int64) error {
a, err := s.store.DeleteApplication(id)
if err != nil {
return err
}
if err := s.sasl.Delete(a.Login); err != nil {
return err
}
// Drop the application's level-2 limit, if any (guide § Rate limiting);
// rate_limits has no cascade of its own.
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
return err
}
return s.Resync()
}
// RateLimit returns the application-level differentiated rate limit (guide §
// Rate limiting), and whether one is configured, for the application's edit
// form.
func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
return s.store.GetRateLimit(store.RateLimitScopeApp, appID)
}
// SaveRateLimit stores the application-level trusted-IP override (guide § Rate
// limiting). The caller has validated the IPs and numbers (security.md); the
// milter reads the row live, so no reload is needed.
func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{
Scope: store.RateLimitScopeApp,
RefID: appID,
AllowedIPs: ips,
MaxMessages: maxMessages,
WindowSeconds: windowSeconds,
})
}
// ClearRateLimit removes the application-level rate limit (guide § Rate
// limiting).
func (s *Service) ClearRateLimit(appID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
}
// PurgeDomainSASL removes the SASL accounts of every application bound to a
// domain. It must be called before the domain's registry rows are cascade-
// deleted, while the logins are still known (product.md). The registry rows and
// the sender map are handled by the domain deletion path; this only clears
// sasldb2, which has no cascade of its own.
func (s *Service) PurgeDomainSASL(domainID int64) error {
logins, err := s.store.ListLoginsByDomain(domainID)
if err != nil {
return err
}
for _, login := range logins {
if err := s.sasl.Delete(login); err != nil {
return err
}
}
return nil
}
// Resync rebuilds smtpd_sender_login_maps from the full set of application
// bindings and reloads Postfix (architecture.md § Mail path). It is the single
// idempotent apply path shared by create/edit/delete and is also reachable
// from the manual reload button; it doubles as recovery if the map ever drifts
// from the database.
func (s *Service) Resync() error {
bindings, err := s.store.ListBindings()
if err != nil {
return err
}
pfBindings := make([]postfix.Binding, 0, len(bindings))
for _, b := range bindings {
pfBindings = append(pfBindings, postfix.Binding{Address: b.Address, Login: b.Login})
}
return s.pf.RebuildSenderLoginMaps(pfBindings)
}
// validateForDomain resolves the domain, validates the login and address mode,
// and — in list mode — validates that every address belongs to the domain
// (security.md). It returns the cleaned address list, which is empty in wildcard
// mode. Resolving the domain here also confirms it exists before any write.
func (s *Service) validateForDomain(domainID int64, login, mode string, rawAddresses []string) ([]string, error) {
d, err := s.store.GetDomain(domainID)
if err != nil {
return nil, err
}
if err := validateLogin(login); err != nil {
return nil, err
}
if err := validateAddressMode(mode); err != nil {
return nil, err
}
if mode == store.AddressModeWildcard {
return nil, nil
}
return parseAddresses(rawAddresses, d.Name)
}