// 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. // // The order matches domain deletion: the SASL account goes first, while the // login is still in the registry. Dropping the row first would, on a // saslpasswd2 failure, leave an account that can still authenticate to Postfix // but that the panel no longer knows about — an orphan no operator can see or // remove. Failing before the row is deleted is recoverable: the application is // still listed and the delete can be retried. func (s *Service) Delete(id int64) error { a, err := s.store.GetApplication(id) if err != nil { return err } if err := s.sasl.Delete(a.Login); err != nil { return err } if _, err := s.store.DeleteApplication(id); 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) }