Files
selfpost/internal/store/inbound.go
T
mixandClaude Opus 5.5 3b86f6c3b6 panel-redesign: inbound is delegated per user, separately from outbound
Stage 1, step 2 (plan § Who sees what).

Store
  - Reach{AllDomains, DomainIDs, AllInbound, InboundDomainIDs} is what a
    domain user is assigned. CreateUser takes it; SetUserReach / ClearUserReach
    replace SetUserDomains / ClearUserDomains and write both directions in one
    transaction. A domain user with nothing on either list is refused
    (ErrEmptyReach). With All set the rows of that direction are not stored,
    so clearing All later brings nothing stale back.
  - ListDomainsForUser honours all_domains; ListInboundDomainsForUser is its
    inbound twin. Both filter in the query.

Authorization
  - auth.Principal carries the whole reach: CanAccessDomain (now also true
    with All), CanAccessInboundDomain, HasOutbound, HasInbound. An id or a
    name on one list opens nothing on the other.
  - Inbound handlers in three strengths: requireInbound (the list, filtered),
    requireInboundDomain (one domain: page, DNS re-check, upstream,
    recipients), requireInboundGlobal (add, delete confirm, delete). All
    answer the same 404.
  - domainIDSet and the DMARC report pages go through the principal instead
    of looking at p.Domains themselves.

Panel (old templates, until stage 2)
  - User form: two lists with All; inbound is offered only where the feature
    is on, and saving the form with it off keeps the user's inbound reach.
    Promotion to global drops the reach.
  - Menu: a group with nothing assigned is absent; / sends an inbound-only
    administrator to Inbound. The inbound pages hide add / delete from a
    domain administrator.

Tests: every route of one inbound domain against five kinds of refused
request (another tenant's id, a missing id, outbound-only reach, an outbound
domain with the same id, one with the same name, no principal) with the 404
bodies compared; add/delete as every non-global user; the filtered list
including a domain added later; thirteen outbound routes against another
tenant's domain and application id; the user form; the reach in the store;
the principal matrix; the menu; the home redirect. Removing the access check
or the query filter makes the tests fail (checked by hand).

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail that predate this work.

Roadmap 12/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:33:37 +03:00

279 lines
8.4 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// Recipient modes for an inbound domain. Kept in sync with the CHECK constraint.
const (
RecipientModeList = "list" // only explicitly listed addresses
RecipientModeAny = "any" // any address at the domain
)
// TLS modes for the hand-off to the upstream. Values are Postfix
// smtp_tls_policy_maps levels: may (opportunistic), encrypt (required), none.
const (
TLSModeMay = "may"
TLSModeEncrypt = "encrypt"
TLSModeNone = "none"
)
// ErrInboundDomainExists is returned when the inbound domain is already configured.
var ErrInboundDomainExists = errors.New("inbound domain already exists")
// ErrInboundDomainNotFound is returned when an inbound domain id/name does not exist.
var ErrInboundDomainNotFound = errors.New("inbound domain not found")
// InboundDomain is a backup-MX / forwarder domain. Host may be empty until the
// operator saves an upstream; map generation skips those rows so mail is never
// accepted with nowhere to send it. RecipientCount is populated by listing
// queries; Recipients is populated by Get.
type InboundDomain struct {
ID int64
Name string
RecipientMode string
Host string
Port int
TLSMode string
CreatedAt time.Time
RecipientCount int
Recipients []string
}
// AddInboundDomain inserts a new inbound domain with a default transport
// (empty host, port 25, opportunistic TLS) and listed-recipients mode. The
// caller must have validated name (security.md).
func (s *Store) AddInboundDomain(name string) (InboundDomain, error) {
now := time.Now().UTC()
tx, err := s.db.Begin()
if err != nil {
return InboundDomain{}, fmt.Errorf("begin add inbound domain: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec(
"INSERT INTO inbound_domains (name, recipient_mode, created_at) VALUES (?, ?, ?)",
name, RecipientModeList, now.Format(time.RFC3339),
)
if err != nil {
if isUniqueViolation(err) {
return InboundDomain{}, ErrInboundDomainExists
}
return InboundDomain{}, fmt.Errorf("insert inbound domain: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return InboundDomain{}, fmt.Errorf("inbound domain id: %w", err)
}
if _, err := tx.Exec(
"INSERT INTO inbound_transports (inbound_domain_id, host, port, tls_mode) VALUES (?, '', 25, ?)",
id, TLSModeMay,
); err != nil {
return InboundDomain{}, fmt.Errorf("insert inbound transport: %w", err)
}
if err := tx.Commit(); err != nil {
return InboundDomain{}, fmt.Errorf("commit add inbound domain: %w", err)
}
return InboundDomain{
ID: id,
Name: name,
RecipientMode: RecipientModeList,
Port: 25,
TLSMode: TLSModeMay,
CreatedAt: now,
}, nil
}
// ListInboundDomains returns every inbound domain with its transport and
// recipient count, ordered by name.
func (s *Store) ListInboundDomains() ([]InboundDomain, error) {
rows, err := s.db.Query(`
SELECT d.id, d.name, d.recipient_mode, d.created_at,
t.host, t.port, t.tls_mode,
(SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id)
FROM inbound_domains d
INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id
ORDER BY d.name`)
if err != nil {
return nil, fmt.Errorf("list inbound domains: %w", err)
}
defer rows.Close()
var out []InboundDomain
for rows.Next() {
d, err := scanInboundDomain(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// ListInboundDomainsForUser returns the inbound domains a domain user reaches,
// ordered by name: the assigned ones, or every inbound domain when the user's
// All flag is set. Like ListDomainsForUser, the filter is in the query.
func (s *Store) ListInboundDomainsForUser(userID int64) ([]InboundDomain, error) {
rows, err := s.db.Query(`
SELECT d.id, d.name, d.recipient_mode, d.created_at,
t.host, t.port, t.tls_mode,
(SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id)
FROM inbound_domains d
INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = ? AND u.all_inbound_domains = 1)
OR EXISTS (SELECT 1 FROM user_inbound_domains ud WHERE ud.user_id = ? AND ud.inbound_domain_id = d.id)
ORDER BY d.name`, userID, userID)
if err != nil {
return nil, fmt.Errorf("list inbound domains for user: %w", err)
}
defer rows.Close()
var out []InboundDomain
for rows.Next() {
d, err := scanInboundDomain(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// GetInboundDomain returns one inbound domain with its recipient list, or
// ErrInboundDomainNotFound.
func (s *Store) GetInboundDomain(id int64) (InboundDomain, error) {
row := s.db.QueryRow(`
SELECT d.id, d.name, d.recipient_mode, d.created_at,
t.host, t.port, t.tls_mode,
(SELECT COUNT(*) FROM inbound_recipients r WHERE r.inbound_domain_id = d.id)
FROM inbound_domains d
INNER JOIN inbound_transports t ON t.inbound_domain_id = d.id
WHERE d.id = ?`, id)
d, err := scanInboundDomain(row)
if errors.Is(err, sql.ErrNoRows) {
return InboundDomain{}, ErrInboundDomainNotFound
}
if err != nil {
return InboundDomain{}, err
}
addrs, err := s.listInboundRecipients(id)
if err != nil {
return InboundDomain{}, err
}
d.Recipients = addrs
return d, nil
}
// UpdateInboundTransport sets the upstream host, port and TLS mode.
func (s *Store) UpdateInboundTransport(id int64, host string, port int, tlsMode string) error {
res, err := s.db.Exec(
"UPDATE inbound_transports SET host = ?, port = ?, tls_mode = ? WHERE inbound_domain_id = ?",
host, port, tlsMode, id,
)
if err != nil {
return fmt.Errorf("update inbound transport: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update inbound transport rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
return nil
}
// UpdateInboundRecipients replaces the recipient mode and, in list mode, the
// address list. In any mode the stored list is cleared.
func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin update inbound recipients: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec("UPDATE inbound_domains SET recipient_mode = ? WHERE id = ?", mode, id)
if err != nil {
return fmt.Errorf("update inbound recipient mode: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update inbound recipient mode rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
if _, err := tx.Exec("DELETE FROM inbound_recipients WHERE inbound_domain_id = ?", id); err != nil {
return fmt.Errorf("clear inbound recipients: %w", err)
}
if mode == RecipientModeList {
for _, addr := range addresses {
if _, err := tx.Exec(
"INSERT INTO inbound_recipients (inbound_domain_id, address) VALUES (?, ?)",
id, addr,
); err != nil {
return fmt.Errorf("insert inbound recipient: %w", err)
}
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit update inbound recipients: %w", err)
}
return nil
}
// DeleteInboundDomain removes an inbound domain and its transport/recipients
// (ON DELETE CASCADE). Returns ErrInboundDomainNotFound if no such row existed.
func (s *Store) DeleteInboundDomain(id int64) error {
res, err := s.db.Exec("DELETE FROM inbound_domains WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete inbound domain: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete inbound domain rows: %w", err)
}
if n == 0 {
return ErrInboundDomainNotFound
}
return nil
}
func (s *Store) listInboundRecipients(id int64) ([]string, error) {
rows, err := s.db.Query(
"SELECT address FROM inbound_recipients WHERE inbound_domain_id = ? ORDER BY address",
id,
)
if err != nil {
return nil, fmt.Errorf("list inbound recipients: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var addr string
if err := rows.Scan(&addr); err != nil {
return nil, err
}
out = append(out, addr)
}
return out, rows.Err()
}
func scanInboundDomain(r scanRow) (InboundDomain, error) {
var (
d InboundDomain
createdAt string
)
if err := r.Scan(
&d.ID, &d.Name, &d.RecipientMode, &createdAt,
&d.Host, &d.Port, &d.TLSMode, &d.RecipientCount,
); err != nil {
return InboundDomain{}, err
}
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return d, nil
}