Add optional inbound relay (backup-MX) behind INBOUND_RELAY_ENABLE.
test / test (push) Waiting to run

Port 25 accepts only configured domains and listed recipients, then forwards to an upstream; the outbound path is unchanged when the flag is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-17 23:17:30 +03:00
parent 6218540211
commit 0d98d92642
49 changed files with 2495 additions and 86 deletions
+249
View File
@@ -0,0 +1,249 @@
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()
}
// 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
}
+101
View File
@@ -0,0 +1,101 @@
package store
import (
"errors"
"testing"
)
func TestInboundDomainCRUD(t *testing.T) {
st := openTestStore(t)
d, err := st.AddInboundDomain("lists.example.com")
if err != nil {
t.Fatalf("AddInboundDomain: %v", err)
}
if d.ID == 0 || d.Name != "lists.example.com" || d.RecipientMode != RecipientModeList {
t.Fatalf("unexpected domain: %+v", d)
}
if d.Port != 25 || d.TLSMode != TLSModeMay || d.Host != "" {
t.Fatalf("unexpected default transport: %+v", d)
}
if _, err := st.AddInboundDomain("lists.example.com"); !errors.Is(err, ErrInboundDomainExists) {
t.Fatalf("duplicate error = %v, want ErrInboundDomainExists", err)
}
if err := st.UpdateInboundTransport(d.ID, "10.0.0.8", 25, TLSModeEncrypt); err != nil {
t.Fatalf("UpdateInboundTransport: %v", err)
}
addrs := []string{"staff@lists.example.com", "postmaster@lists.example.com"}
if err := st.UpdateInboundRecipients(d.ID, RecipientModeList, addrs); err != nil {
t.Fatalf("UpdateInboundRecipients: %v", err)
}
got, err := st.GetInboundDomain(d.ID)
if err != nil {
t.Fatalf("GetInboundDomain: %v", err)
}
if got.Host != "10.0.0.8" || got.TLSMode != TLSModeEncrypt || got.RecipientCount != 2 {
t.Fatalf("get after update: %+v", got)
}
if len(got.Recipients) != 2 || got.Recipients[0] != "postmaster@lists.example.com" {
t.Fatalf("recipients not sorted: %v", got.Recipients)
}
list, err := st.ListInboundDomains()
if err != nil {
t.Fatalf("ListInboundDomains: %v", err)
}
if len(list) != 1 || list[0].RecipientCount != 2 {
t.Fatalf("list: %+v", list)
}
if err := st.UpdateInboundRecipients(d.ID, RecipientModeAny, nil); err != nil {
t.Fatalf("switch to any: %v", err)
}
got, err = st.GetInboundDomain(d.ID)
if err != nil {
t.Fatal(err)
}
if got.RecipientMode != RecipientModeAny || got.RecipientCount != 0 || len(got.Recipients) != 0 {
t.Fatalf("any mode should clear the list: %+v", got)
}
if err := st.DeleteInboundDomain(d.ID); err != nil {
t.Fatalf("DeleteInboundDomain: %v", err)
}
assertCount(t, st, "inbound_domains", 0)
assertCount(t, st, "inbound_transports", 0)
assertCount(t, st, "inbound_recipients", 0)
if _, err := st.GetInboundDomain(d.ID); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("Get after delete = %v, want ErrInboundDomainNotFound", err)
}
}
func TestInboundDomainNotFound(t *testing.T) {
st := openTestStore(t)
if _, err := st.GetInboundDomain(99); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("GetInboundDomain(missing) = %v", err)
}
if err := st.UpdateInboundTransport(99, "10.0.0.1", 25, TLSModeNone); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("UpdateInboundTransport(missing) = %v", err)
}
if err := st.DeleteInboundDomain(99); !errors.Is(err, ErrInboundDomainNotFound) {
t.Fatalf("DeleteInboundDomain(missing) = %v", err)
}
}
func TestInboundDeleteCascadesRecipients(t *testing.T) {
st := openTestStore(t)
d, err := st.AddInboundDomain("backup.example.net")
if err != nil {
t.Fatal(err)
}
if err := st.UpdateInboundRecipients(d.ID, RecipientModeList, []string{"a@backup.example.net"}); err != nil {
t.Fatal(err)
}
if err := st.DeleteInboundDomain(d.ID); err != nil {
t.Fatal(err)
}
assertCount(t, st, "inbound_recipients", 0)
}
@@ -0,0 +1,26 @@
-- Optional inbound relay (backup-MX / forwarder). Separate from sending
-- domains: these rows exist even when INBOUND_RELAY_ENABLE is false, but the
-- listener, Postfix maps and panel UI are generated only when that flag is on.
CREATE TABLE inbound_domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
recipient_mode TEXT NOT NULL CHECK (recipient_mode IN ('list', 'any')),
created_at TEXT NOT NULL
);
-- One upstream per inbound domain (host:port + TLS policy for the hand-off).
CREATE TABLE inbound_transports (
inbound_domain_id INTEGER PRIMARY KEY REFERENCES inbound_domains(id) ON DELETE CASCADE,
host TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
tls_mode TEXT NOT NULL CHECK (tls_mode IN ('may', 'encrypt', 'none'))
);
-- Explicit recipients for recipient_mode = 'list'. Ignored when mode is 'any'.
CREATE TABLE inbound_recipients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inbound_domain_id INTEGER NOT NULL REFERENCES inbound_domains(id) ON DELETE CASCADE,
address TEXT NOT NULL,
UNIQUE (inbound_domain_id, address)
);
+3 -3
View File
@@ -1,8 +1,8 @@
// Package store owns the SelfPost SQLite database: the single file under /data
// that persists panel users (global administrators and domain-admins), sending
// domains and applications, 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.
// domains and applications, optional inbound-relay domains, 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 (