Add optional inbound relay (backup-MX) behind INBOUND_RELAY_ENABLE.
test / test (push) Waiting to run
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:
@@ -13,6 +13,7 @@ package dnscheck
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -98,6 +99,7 @@ type Checker struct {
|
||||
mu sync.Mutex
|
||||
servers map[string]cached[Server]
|
||||
domains map[string]cached[Domain]
|
||||
inbound map[string]cached[Result]
|
||||
}
|
||||
|
||||
type cached[T any] struct {
|
||||
@@ -120,6 +122,7 @@ func newChecker(r resolver, timeout, srvTTL, domTTL time.Duration) *Checker {
|
||||
domainTTL: domTTL,
|
||||
servers: make(map[string]cached[Server]),
|
||||
domains: make(map[string]cached[Domain]),
|
||||
inbound: make(map[string]cached[Result]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,9 +175,77 @@ func (c *Checker) Domain(q Query, force bool) Domain {
|
||||
func (c *Checker) Forget(domainName string) {
|
||||
c.mu.Lock()
|
||||
delete(c.domains, domainName)
|
||||
delete(c.inbound, domainName)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// InboundMX reports whether any MX for name points at this server's hostname
|
||||
// (the inbound-relay check). Other MX values are the domain's own primaries
|
||||
// and are not an error. force skips the cache.
|
||||
func (c *Checker) InboundMX(name, hostname string, force bool) Result {
|
||||
key := name + "\x00" + hostname
|
||||
if !force {
|
||||
c.mu.Lock()
|
||||
entry, ok := c.inbound[key]
|
||||
c.mu.Unlock()
|
||||
if ok && time.Now().Before(entry.expires) {
|
||||
return entry.value
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
|
||||
defer cancel()
|
||||
result := c.checkInboundMX(ctx, name, hostname)
|
||||
|
||||
c.mu.Lock()
|
||||
c.inbound[key] = cached[Result]{value: result, expires: time.Now().Add(c.domainTTL)}
|
||||
c.mu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *Checker) checkInboundMX(ctx context.Context, name, hostname string) Result {
|
||||
mxs, err := c.resolver.LookupMX(ctx, name)
|
||||
if err != nil {
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname),
|
||||
}
|
||||
}
|
||||
return lookupFailed("the MX record", err)
|
||||
}
|
||||
if len(mxs) == 0 {
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("No MX record is published at %s. Publish an MX pointing at %s — until then the internet will not deliver here.", name, hostname),
|
||||
}
|
||||
}
|
||||
|
||||
want := normalizeName(hostname)
|
||||
var records []string
|
||||
matched := false
|
||||
for _, mx := range mxs {
|
||||
host := normalizeName(mx.Host)
|
||||
records = append(records, fmt.Sprintf("%d %s.", mx.Pref, host))
|
||||
if host == want {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return Result{
|
||||
Status: health.StatusOK,
|
||||
Detail: fmt.Sprintf("An MX points at %s (this server). Other MX values are the domain's own primaries — they are not an error.", hostname),
|
||||
Records: records,
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
Status: health.StatusError,
|
||||
Detail: fmt.Sprintf("No MX points at %s (this server). Publish the record below, or wait for DNS to propagate and Re-check.", hostname),
|
||||
Records: records,
|
||||
}
|
||||
}
|
||||
|
||||
// checkDomain runs the three record checks concurrently: they are independent,
|
||||
// and in series three timeouts would stack up into a page that looks hung.
|
||||
func (c *Checker) checkDomain(ctx context.Context, q Query) Domain {
|
||||
|
||||
@@ -374,3 +374,37 @@ func TestReportAuth(t *testing.T) {
|
||||
t.Errorf("advice %q should cite %q", got.Detail, ReportAuthExample())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundMXPointsAtServer(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
mx: map[string][]*net.MX{
|
||||
"lists.example.com": {
|
||||
{Host: "mail.primary.example.net.", Pref: 10},
|
||||
{Host: "mail.example.org.", Pref: 20},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := newTestChecker(f).InboundMX("lists.example.com", "mail.example.org", false)
|
||||
if got.Status != health.StatusOK {
|
||||
t.Fatalf("status = %q (%s)", got.Status, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundMXMissingThisServer(t *testing.T) {
|
||||
f := &fakeResolver{
|
||||
mx: map[string][]*net.MX{
|
||||
"backup.example.net": {{Host: "mail.primary.example.net.", Pref: 10}},
|
||||
},
|
||||
}
|
||||
got := newTestChecker(f).InboundMX("backup.example.net", "mail.example.org", false)
|
||||
if got.Status != health.StatusError {
|
||||
t.Fatalf("status = %q, want error", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundMXAbsent(t *testing.T) {
|
||||
got := newTestChecker(&fakeResolver{}).InboundMX("none.example", "mail.example.org", false)
|
||||
if got.Status != health.StatusError {
|
||||
t.Fatalf("status = %q, want error", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// Package inbound owns backup-MX / relay-forwarder domains: the SQLite
|
||||
// registry and the Postfix lookup tables (relay_domains, transport_maps,
|
||||
// relay_recipient_maps, smtp_tls_policy_maps). It does not listen on port 25
|
||||
// itself — postfix-config.sh does that when INBOUND_RELAY_ENABLE is true.
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// Maps is the slice of the Postfix manager the inbound service needs.
|
||||
type Maps interface {
|
||||
RebuildInboundMaps(routes []postfix.InboundRoute) error
|
||||
}
|
||||
|
||||
// Service coordinates inbound-domain state across SQLite and the Postfix maps.
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
maps Maps
|
||||
}
|
||||
|
||||
// NewService builds the inbound service over the shared store and Postfix maps.
|
||||
func NewService(st *store.Store, maps Maps) *Service {
|
||||
return &Service{store: st, maps: maps}
|
||||
}
|
||||
|
||||
// List returns every inbound domain.
|
||||
func (s *Service) List() ([]store.InboundDomain, error) {
|
||||
return s.store.ListInboundDomains()
|
||||
}
|
||||
|
||||
// Get returns one inbound domain by id.
|
||||
func (s *Service) Get(id int64) (store.InboundDomain, error) {
|
||||
return s.store.GetInboundDomain(id)
|
||||
}
|
||||
|
||||
// Add validates the domain name, inserts it, and rebuilds the maps.
|
||||
func (s *Service) Add(name string) (store.InboundDomain, error) {
|
||||
name = normalizeDomain(name)
|
||||
if err := checkDomain(name); err != nil {
|
||||
return store.InboundDomain{}, err
|
||||
}
|
||||
d, err := s.store.AddInboundDomain(name)
|
||||
if err != nil {
|
||||
return store.InboundDomain{}, err
|
||||
}
|
||||
if err := s.Resync(); err != nil {
|
||||
_ = s.store.DeleteInboundDomain(d.ID)
|
||||
return store.InboundDomain{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SetTransport validates and saves the upstream, then rebuilds the maps.
|
||||
func (s *Service) SetTransport(id int64, host, portRaw, tlsMode string) error {
|
||||
if _, err := s.store.GetInboundDomain(id); err != nil {
|
||||
return err
|
||||
}
|
||||
host = normalizeHost(host)
|
||||
if err := checkHost(host); err != nil {
|
||||
return err
|
||||
}
|
||||
port, err := parsePort(portRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkTLSMode(tlsMode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.UpdateInboundTransport(id, host, port, tlsMode); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Resync()
|
||||
}
|
||||
|
||||
// SetRecipients validates the mode and, in list mode, every address, then
|
||||
// rebuilds the maps.
|
||||
func (s *Service) SetRecipients(id int64, mode string, rawAddresses []string) error {
|
||||
d, err := s.store.GetInboundDomain(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkRecipientMode(mode); err != nil {
|
||||
return err
|
||||
}
|
||||
var addrs []string
|
||||
if mode == store.RecipientModeList {
|
||||
addrs, err = parseRecipientAddresses(rawAddresses, d.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.store.UpdateInboundRecipients(id, mode, addrs); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Resync()
|
||||
}
|
||||
|
||||
// Delete removes the domain and rebuilds the maps.
|
||||
func (s *Service) Delete(id int64) error {
|
||||
if err := s.store.DeleteInboundDomain(id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Resync()
|
||||
}
|
||||
|
||||
// Resync rebuilds the inbound Postfix maps from SQLite.
|
||||
func (s *Service) Resync() error {
|
||||
list, err := s.store.ListInboundDomains()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
routes := make([]postfix.InboundRoute, 0, len(list))
|
||||
for _, d := range list {
|
||||
full, err := s.store.GetInboundDomain(d.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
routes = append(routes, postfix.InboundRoute{
|
||||
Domain: full.Name,
|
||||
Host: full.Host,
|
||||
Port: full.Port,
|
||||
TLSMode: full.TLSMode,
|
||||
RecipientMode: full.RecipientMode,
|
||||
Recipients: full.Recipients,
|
||||
})
|
||||
}
|
||||
return s.maps.RebuildInboundMaps(routes)
|
||||
}
|
||||
|
||||
func parseRecipientAddresses(raw []string, domain string) ([]string, error) {
|
||||
seen := make(map[string]bool)
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
addr := strings.ToLower(strings.TrimSpace(r))
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if err := checkMailbox(addr, domain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[addr] {
|
||||
continue
|
||||
}
|
||||
seen[addr] = true
|
||||
out = append(out, addr)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("listed-recipients mode requires at least one address")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
type fakeMaps struct {
|
||||
routes []postfix.InboundRoute
|
||||
err error
|
||||
rebuild int
|
||||
}
|
||||
|
||||
func (f *fakeMaps) RebuildInboundMaps(routes []postfix.InboundRoute) error {
|
||||
f.rebuild++
|
||||
f.routes = append([]postfix.InboundRoute(nil), routes...)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func testService(t *testing.T) (*Service, *store.Store, *fakeMaps) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
m := &fakeMaps{}
|
||||
return NewService(st, m), st, m
|
||||
}
|
||||
|
||||
func TestAddAndSetTransport(t *testing.T) {
|
||||
s, _, m := testService(t)
|
||||
d, err := s.Add("Lists.Example.COM")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Name != "lists.example.com" {
|
||||
t.Fatalf("name = %q", d.Name)
|
||||
}
|
||||
if m.rebuild != 1 {
|
||||
t.Fatalf("rebuild after add = %d", m.rebuild)
|
||||
}
|
||||
// Empty host is omitted from maps.
|
||||
if len(m.routes) != 1 || m.routes[0].Host != "" {
|
||||
t.Fatalf("routes after add: %+v", m.routes)
|
||||
}
|
||||
|
||||
if err := s.SetTransport(d.ID, "10.0.0.8", "25", store.TLSModeEncrypt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.routes[0].Host != "10.0.0.8" || m.routes[0].TLSMode != store.TLSModeEncrypt {
|
||||
t.Fatalf("routes after transport: %+v", m.routes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetRecipientsValidatesDomain(t *testing.T) {
|
||||
s, _, _ := testService(t)
|
||||
d, err := s.Add("lists.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@other.com"})
|
||||
if err == nil {
|
||||
t.Fatal("expected foreign-domain error")
|
||||
}
|
||||
err = s.SetRecipients(d.ID, store.RecipientModeList, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty-list error")
|
||||
}
|
||||
if err := s.SetRecipients(d.ID, store.RecipientModeList, []string{"staff@lists.example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsUnsafeHost(t *testing.T) {
|
||||
s, _, _ := testService(t)
|
||||
d, err := s.Add("lists.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetTransport(d.ID, "10.0.0.8; rm", "25", store.TLSModeMay); err == nil {
|
||||
t.Fatal("expected unsafe host to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteResyncs(t *testing.T) {
|
||||
s, _, m := testService(t)
|
||||
d, err := s.Add("lists.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Delete(d.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !errors.Is(mustGet(t, s, d.ID), store.ErrInboundDomainNotFound) {
|
||||
t.Fatal("domain still present")
|
||||
}
|
||||
if len(m.routes) != 0 {
|
||||
t.Fatalf("maps after delete: %+v", m.routes)
|
||||
}
|
||||
}
|
||||
|
||||
func mustGet(t *testing.T, s *Service, id int64) error {
|
||||
t.Helper()
|
||||
_, err := s.Get(id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func normalizeDomain(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
func normalizeHost(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func checkDomain(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("domain is required")
|
||||
}
|
||||
if len(name) > 253 {
|
||||
return fmt.Errorf("domain must be at most 253 characters")
|
||||
}
|
||||
labels := strings.Split(name, ".")
|
||||
if len(labels) < 2 {
|
||||
return fmt.Errorf("domain must include at least one dot (e.g. example.com)")
|
||||
}
|
||||
for _, label := range labels {
|
||||
if err := checkLabel(label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkHost(host string) error {
|
||||
if host == "" {
|
||||
return fmt.Errorf("host is required")
|
||||
}
|
||||
if len(host) > 253 {
|
||||
return fmt.Errorf("host must be at most 253 characters")
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return nil
|
||||
}
|
||||
for _, label := range strings.Split(host, ".") {
|
||||
if err := checkLabel(label); err != nil {
|
||||
return fmt.Errorf("host is invalid: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkLabel(label string) error {
|
||||
if len(label) == 0 {
|
||||
return fmt.Errorf("must not contain an empty label")
|
||||
}
|
||||
if len(label) > 63 {
|
||||
return fmt.Errorf("each label must be at most 63 characters")
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return fmt.Errorf("labels must not start or end with '-'")
|
||||
}
|
||||
for i := 0; i < len(label); i++ {
|
||||
c := label[i]
|
||||
lower := c >= 'a' && c <= 'z'
|
||||
digit := c >= '0' && c <= '9'
|
||||
if !lower && !digit && c != '-' {
|
||||
return fmt.Errorf("may contain only lower-case letters, digits, '.' and '-'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePort(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("port is required")
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return 0, fmt.Errorf("port must be between 1 and 65535")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func checkTLSMode(mode string) error {
|
||||
switch mode {
|
||||
case "may", "encrypt", "none":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid TLS mode")
|
||||
}
|
||||
}
|
||||
|
||||
func checkRecipientMode(mode string) error {
|
||||
switch mode {
|
||||
case "list", "any":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid recipient mode")
|
||||
}
|
||||
}
|
||||
|
||||
func checkMailbox(addr, domain string) error {
|
||||
at := strings.LastIndexByte(addr, '@')
|
||||
if at <= 0 || at >= len(addr)-1 {
|
||||
return fmt.Errorf("%q is not a valid email address", addr)
|
||||
}
|
||||
local, host := addr[:at], addr[at+1:]
|
||||
if host != domain {
|
||||
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
|
||||
}
|
||||
if local == "" || local[0] == '.' || local[len(local)-1] == '.' {
|
||||
return fmt.Errorf("%q: invalid local part", addr)
|
||||
}
|
||||
for i := 0; i < len(local); i++ {
|
||||
c := local[i]
|
||||
lower := c >= 'a' && c <= 'z'
|
||||
digit := c >= '0' && c <= '9'
|
||||
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
|
||||
return fmt.Errorf("%q: local part contains invalid characters", addr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// InboundRoute is one inbound domain's Postfix map material: the relay
|
||||
// domain, the next-hop transport, the TLS policy for that hop, and the
|
||||
// recipient list (or a domain catch-all).
|
||||
type InboundRoute struct {
|
||||
Domain string
|
||||
Host string
|
||||
Port int
|
||||
TLSMode string
|
||||
RecipientMode string
|
||||
Recipients []string
|
||||
}
|
||||
|
||||
func (p *Postfix) inboundMapPaths() (relayDomains, transport, recipients, tlsPolicy string) {
|
||||
dir := filepath.Dir(p.senderLoginMapsPath)
|
||||
return filepath.Join(dir, "relay_domains"),
|
||||
filepath.Join(dir, "transport"),
|
||||
filepath.Join(dir, "relay_recipients"),
|
||||
filepath.Join(dir, "tls_policy")
|
||||
}
|
||||
|
||||
// RebuildInboundMaps regenerates the inbound relay lookup tables from the full
|
||||
// set of configured routes and reloads Postfix. Domains with an empty host are
|
||||
// omitted so mail is never accepted with nowhere to send it. Full regeneration
|
||||
// keeps the files a pure function of the registry (security.md).
|
||||
func (p *Postfix) RebuildInboundMaps(routes []InboundRoute) error {
|
||||
relay, transport, recipients, tlsPolicy, err := renderInboundMaps(routes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd, tr, rc, tl := p.inboundMapPaths()
|
||||
if err := writeFileAtomic(rd, relay, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(tr, transport, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(rc, recipients, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(tl, tlsPolicy, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.reload()
|
||||
}
|
||||
|
||||
func renderInboundMaps(routes []InboundRoute) (relay, transport, recipients, tlsPolicy []byte, err error) {
|
||||
sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
|
||||
|
||||
var relayB, transportB, recipB, tlsB strings.Builder
|
||||
for _, r := range routes {
|
||||
if strings.TrimSpace(r.Host) == "" {
|
||||
continue
|
||||
}
|
||||
if err := assertInboundRouteSafe(r); err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
nexthop := inboundNexthop(r.Host, r.Port)
|
||||
fmt.Fprintf(&relayB, "%s OK\n", r.Domain)
|
||||
fmt.Fprintf(&transportB, "%s smtp:%s\n", r.Domain, nexthop)
|
||||
fmt.Fprintf(&tlsB, "%s %s\n", nexthop, r.TLSMode)
|
||||
switch r.RecipientMode {
|
||||
case "any":
|
||||
fmt.Fprintf(&recipB, "@%s OK\n", r.Domain)
|
||||
default:
|
||||
addrs := append([]string(nil), r.Recipients...)
|
||||
sort.Strings(addrs)
|
||||
for _, addr := range addrs {
|
||||
fmt.Fprintf(&recipB, "%s OK\n", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return []byte(relayB.String()), []byte(transportB.String()), []byte(recipB.String()), []byte(tlsB.String()), nil
|
||||
}
|
||||
|
||||
// inboundNexthop is the Postfix next-hop [host]:port form that disables MX
|
||||
// lookup for the explicit upstream.
|
||||
func inboundNexthop(host string, port int) string {
|
||||
if ip := net.ParseIP(host); ip != nil && ip.To4() == nil {
|
||||
return "[" + host + "]:" + strconv.Itoa(port)
|
||||
}
|
||||
return "[" + host + "]:" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
func assertInboundRouteSafe(r InboundRoute) error {
|
||||
if err := assertMapToken(r.Domain, "domain"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertMapToken(r.Host, "host"); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Port < 1 || r.Port > 65535 {
|
||||
return fmt.Errorf("postfix: invalid inbound port %d", r.Port)
|
||||
}
|
||||
switch r.TLSMode {
|
||||
case "may", "encrypt", "none":
|
||||
default:
|
||||
return fmt.Errorf("postfix: invalid tls mode %q", r.TLSMode)
|
||||
}
|
||||
if r.RecipientMode != "list" && r.RecipientMode != "any" {
|
||||
return fmt.Errorf("postfix: invalid recipient mode %q", r.RecipientMode)
|
||||
}
|
||||
for _, addr := range r.Recipients {
|
||||
if err := assertMapToken(addr, "recipient"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assertMapToken rejects values that could break out of a texthash line.
|
||||
func assertMapToken(v, what string) error {
|
||||
if v == "" {
|
||||
return fmt.Errorf("postfix: empty %s", what)
|
||||
}
|
||||
if strings.ContainsAny(v, " \t\r\n,\\") {
|
||||
return fmt.Errorf("postfix: unsafe character in %s %q", what, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package postfix
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderInboundMaps(t *testing.T) {
|
||||
routes := []InboundRoute{
|
||||
{
|
||||
Domain: "zeta.example", Host: "192.0.2.20", Port: 25,
|
||||
TLSMode: "none", RecipientMode: "any",
|
||||
},
|
||||
{
|
||||
Domain: "lists.example.com", Host: "10.0.0.8", Port: 25,
|
||||
TLSMode: "encrypt", RecipientMode: "list",
|
||||
Recipients: []string{"staff@lists.example.com", "abuse@lists.example.com"},
|
||||
},
|
||||
{
|
||||
Domain: "pending.example", Host: "", Port: 25,
|
||||
TLSMode: "may", RecipientMode: "list",
|
||||
},
|
||||
}
|
||||
relay, transport, recipients, tlsPolicy, err := renderInboundMaps(routes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantRelay := "lists.example.com OK\nzeta.example OK\n"
|
||||
if string(relay) != wantRelay {
|
||||
t.Errorf("relay_domains =\n%q\nwant\n%q", relay, wantRelay)
|
||||
}
|
||||
wantTransport := "lists.example.com smtp:[10.0.0.8]:25\nzeta.example smtp:[192.0.2.20]:25\n"
|
||||
if string(transport) != wantTransport {
|
||||
t.Errorf("transport =\n%q\nwant\n%q", transport, wantTransport)
|
||||
}
|
||||
wantRecipients := "abuse@lists.example.com OK\nstaff@lists.example.com OK\n@zeta.example OK\n"
|
||||
if string(recipients) != wantRecipients {
|
||||
t.Errorf("relay_recipients =\n%q\nwant\n%q", recipients, wantRecipients)
|
||||
}
|
||||
wantTLS := "[10.0.0.8]:25 encrypt\n[192.0.2.20]:25 none\n"
|
||||
if string(tlsPolicy) != wantTLS {
|
||||
t.Errorf("tls_policy =\n%q\nwant\n%q", tlsPolicy, wantTLS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInboundMapsIPv6(t *testing.T) {
|
||||
routes := []InboundRoute{{
|
||||
Domain: "v6.example", Host: "2001:db8::1", Port: 25,
|
||||
TLSMode: "may", RecipientMode: "any",
|
||||
}}
|
||||
_, transport, _, tlsPolicy, err := renderInboundMaps(routes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(transport) != "v6.example smtp:[2001:db8::1]:25\n" {
|
||||
t.Errorf("transport = %q", transport)
|
||||
}
|
||||
if string(tlsPolicy) != "[2001:db8::1]:25 may\n" {
|
||||
t.Errorf("tls_policy = %q", tlsPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInboundMapsRejectsInjection(t *testing.T) {
|
||||
bad := []InboundRoute{
|
||||
{Domain: "ex ample.com", Host: "10.0.0.1", Port: 25, TLSMode: "may", RecipientMode: "any"},
|
||||
{Domain: "example.com", Host: "10.0.0.1\nrelay", Port: 25, TLSMode: "may", RecipientMode: "any"},
|
||||
{Domain: "example.com", Host: "10.0.0.1", Port: 25, TLSMode: "evil", RecipientMode: "any"},
|
||||
{Domain: "example.com", Host: "10.0.0.1", Port: 25, TLSMode: "may", RecipientMode: "list",
|
||||
Recipients: []string{"a@example.com OK\nb@evil.com"}},
|
||||
}
|
||||
for i, r := range bad {
|
||||
if _, _, _, _, err := renderInboundMaps([]InboundRoute{r}); err == nil {
|
||||
t.Errorf("case %d: expected injection rejection", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildInboundMapsWritesAndReloads(t *testing.T) {
|
||||
p, reloads := newTestPostfix(t)
|
||||
err := p.RebuildInboundMaps([]InboundRoute{{
|
||||
Domain: "lists.example.com", Host: "10.0.0.8", Port: 25,
|
||||
TLSMode: "encrypt", RecipientMode: "any",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *reloads != 1 {
|
||||
t.Errorf("reload called %d times, want 1", *reloads)
|
||||
}
|
||||
rd, _, _, _ := p.inboundMapPaths()
|
||||
data, err := os.ReadFile(rd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "lists.example.com OK\n" {
|
||||
t.Errorf("relay_domains file = %q", data)
|
||||
}
|
||||
if filepath.Base(rd) != "relay_domains" {
|
||||
t.Errorf("unexpected path %s", rd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildInboundMapsEmptyOmitsPending(t *testing.T) {
|
||||
p, _ := newTestPostfix(t)
|
||||
if err := p.RebuildInboundMaps(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rd, tr, rc, tl := p.inboundMapPaths()
|
||||
for _, path := range []string{rd, tr, rc, tl} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) != 0 {
|
||||
t.Errorf("%s not empty: %q", path, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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 (
|
||||
|
||||
@@ -49,6 +49,15 @@ var globalOnlyRoutes = []route{
|
||||
{"GET", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}},
|
||||
{"POST", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}},
|
||||
{"POST", "/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil},
|
||||
|
||||
{"GET", "/inbound", func(h *Handlers) http.HandlerFunc { return h.HandleInboundList }, nil},
|
||||
{"POST", "/inbound", func(h *Handlers) http.HandlerFunc { return h.HandleAddInbound }, nil},
|
||||
{"GET", "/inbound/1", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDetail }, map[string]string{"id": "1"}},
|
||||
{"POST", "/inbound/1/dns-recheck", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDNSRecheck }, map[string]string{"id": "1"}},
|
||||
{"POST", "/inbound/1/upstream", func(h *Handlers) http.HandlerFunc { return h.HandleInboundTransport }, map[string]string{"id": "1"}},
|
||||
{"POST", "/inbound/1/recipients", func(h *Handlers) http.HandlerFunc { return h.HandleInboundRecipients }, map[string]string{"id": "1"}},
|
||||
{"GET", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDeleteConfirm }, map[string]string{"id": "1"}},
|
||||
{"POST", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDelete }, map[string]string{"id": "1"}},
|
||||
}
|
||||
|
||||
// A domain administrator has an account on the panel, so authentication is not
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
@@ -34,6 +35,9 @@ type Config struct {
|
||||
// when the HTTP role starts (architecture.md). The Mail queue card and
|
||||
// delivery history read it from here; they never call postconf.
|
||||
RetryPolicy postfix.RetryPolicy
|
||||
// InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and
|
||||
// routes exist only when this is true.
|
||||
InboundEnabled bool
|
||||
}
|
||||
|
||||
// Handlers holds dependencies for authenticated panel routes.
|
||||
@@ -41,6 +45,7 @@ type Handlers struct {
|
||||
store *store.Store
|
||||
domains *domain.Service
|
||||
apps *app.Service
|
||||
inbound *inbound.Service
|
||||
cfg Config
|
||||
view *view.Engine
|
||||
dns *dnscheck.Checker
|
||||
@@ -53,6 +58,7 @@ func New(
|
||||
st *store.Store,
|
||||
domains *domain.Service,
|
||||
apps *app.Service,
|
||||
inboundSvc *inbound.Service,
|
||||
cfg Config,
|
||||
v *view.Engine,
|
||||
dns *dnscheck.Checker,
|
||||
@@ -63,6 +69,7 @@ func New(
|
||||
store: st,
|
||||
domains: domains,
|
||||
apps: apps,
|
||||
inbound: inboundSvc,
|
||||
cfg: cfg,
|
||||
view: v,
|
||||
dns: dns,
|
||||
|
||||
@@ -172,6 +172,13 @@ func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if h.cfg.InboundEnabled && h.inbound != nil {
|
||||
if err := h.inbound.Resync(); err != nil {
|
||||
logf("panel: manual reload (inbound): %v", err)
|
||||
http.Error(w, "reload failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
)
|
||||
|
||||
type inboundRow struct {
|
||||
store.InboundDomain
|
||||
DNS health.Status
|
||||
Upstream string
|
||||
TLSLabel string
|
||||
RcptLabel string
|
||||
}
|
||||
|
||||
func (h *Handlers) requireInbound(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
|
||||
if !h.cfg.InboundEnabled || h.inbound == nil {
|
||||
http.NotFound(w, r)
|
||||
return auth.Principal{}, false
|
||||
}
|
||||
return h.requireGlobal(w, r)
|
||||
}
|
||||
|
||||
func (h *Handlers) lookupInbound(w http.ResponseWriter, r *http.Request) (store.InboundDomain, bool) {
|
||||
if h.inbound == nil {
|
||||
http.NotFound(w, r)
|
||||
return store.InboundDomain{}, false
|
||||
}
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return store.InboundDomain{}, false
|
||||
}
|
||||
d, err := h.inbound.Get(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInboundDomainNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return store.InboundDomain{}, false
|
||||
}
|
||||
logf("panel: get inbound domain %d: %v", id, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return store.InboundDomain{}, false
|
||||
}
|
||||
return d, true
|
||||
}
|
||||
|
||||
// HandleInboundList is the inbound-relay domain list (global administrators only).
|
||||
func (h *Handlers) HandleInboundList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireInbound(w, r); !ok {
|
||||
return
|
||||
}
|
||||
h.renderInboundList(w, r, http.StatusOK, "", "")
|
||||
}
|
||||
|
||||
func (h *Handlers) renderInboundList(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
||||
list, err := h.inbound.List()
|
||||
if err != nil {
|
||||
logf("panel: inbound list: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — inbound"
|
||||
data["Active"] = "inbound"
|
||||
data["Domains"] = h.inboundRows(list)
|
||||
data["Error"] = formErr
|
||||
data["FormName"] = formName
|
||||
if r.URL.Query().Get("deleted") != "" {
|
||||
data["Flash"] = "Inbound domain deleted."
|
||||
}
|
||||
h.view.Render(w, status, "inbound", data)
|
||||
}
|
||||
|
||||
func (h *Handlers) inboundRows(domains []store.InboundDomain) []inboundRow {
|
||||
rows := make([]inboundRow, len(domains))
|
||||
var wg sync.WaitGroup
|
||||
for i, d := range domains {
|
||||
rows[i] = inboundRow{
|
||||
InboundDomain: d,
|
||||
DNS: health.StatusUnknown,
|
||||
Upstream: inboundUpstream(d),
|
||||
TLSLabel: tlsLabel(d.TLSMode),
|
||||
RcptLabel: rcptLabel(d),
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if h.dns != nil && h.cfg.Hostname != "" {
|
||||
rows[i].DNS = h.dns.InboundMX(d.Name, h.cfg.Hostname, false).Status
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return rows
|
||||
}
|
||||
|
||||
func inboundUpstream(d store.InboundDomain) string {
|
||||
if d.Host == "" {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%s:%d", d.Host, d.Port)
|
||||
}
|
||||
|
||||
func tlsLabel(mode string) string {
|
||||
switch mode {
|
||||
case store.TLSModeEncrypt:
|
||||
return "required"
|
||||
case store.TLSModeNone:
|
||||
return "off"
|
||||
default:
|
||||
return "opportunistic"
|
||||
}
|
||||
}
|
||||
|
||||
func tlsStatusClass(mode string) string {
|
||||
if mode == store.TLSModeEncrypt {
|
||||
return "ok"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func rcptLabel(d store.InboundDomain) string {
|
||||
if d.RecipientMode == store.RecipientModeAny {
|
||||
return "any"
|
||||
}
|
||||
n := d.RecipientCount
|
||||
if n == 1 {
|
||||
return "1 listed"
|
||||
}
|
||||
return fmt.Sprintf("%d listed", n)
|
||||
}
|
||||
|
||||
// HandleAddInbound validates the name, creates the inbound domain, and
|
||||
// redirects to its page.
|
||||
func (h *Handlers) HandleAddInbound(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireInbound(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderInboundList(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
||||
return
|
||||
}
|
||||
raw := r.PostFormValue("name")
|
||||
name := validate.NormalizeDomain(raw)
|
||||
if err := validate.Domain(name); err != nil {
|
||||
h.renderInboundList(w, r, http.StatusBadRequest, err.Error(), raw)
|
||||
return
|
||||
}
|
||||
d, err := h.inbound.Add(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInboundDomainExists) {
|
||||
h.renderInboundList(w, r, http.StatusConflict, "That inbound domain is already configured.", raw)
|
||||
return
|
||||
}
|
||||
logf("panel: add inbound domain %q: %v", name, err)
|
||||
h.renderInboundList(w, r, http.StatusInternalServerError,
|
||||
"Could not add the domain. Please check the logs and try again.", raw)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/inbound/%d", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleInboundDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireInbound(w, r); !ok {
|
||||
return
|
||||
}
|
||||
d, ok := h.lookupInbound(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.renderInboundDetail(w, r, http.StatusOK, d, inboundDetailView{})
|
||||
}
|
||||
|
||||
type inboundDetailView struct {
|
||||
FormErr string
|
||||
TransportErr string
|
||||
RecipientErr string
|
||||
}
|
||||
|
||||
func (h *Handlers) renderInboundDetail(w http.ResponseWriter, r *http.Request, status int, d store.InboundDomain, extra inboundDetailView) {
|
||||
mx := dnscheck.Result{Status: health.StatusUnknown}
|
||||
if h.dns != nil && h.cfg.Hostname != "" {
|
||||
mx = h.dns.InboundMX(d.Name, h.cfg.Hostname, false)
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — " + d.Name
|
||||
data["Active"] = "inbound"
|
||||
data["Domain"] = d
|
||||
data["MX"] = mx
|
||||
data["MXValue"] = "10 " + strings.TrimSuffix(h.cfg.Hostname, ".") + "."
|
||||
data["Hostname"] = h.cfg.Hostname
|
||||
data["TLSLabel"] = tlsLabel(d.TLSMode)
|
||||
data["TLSClass"] = tlsStatusClass(d.TLSMode)
|
||||
data["RecipientText"] = strings.Join(d.Recipients, "\n")
|
||||
data["Flash"] = inboundFlash(r)
|
||||
data["FormErr"] = extra.FormErr
|
||||
data["TransportErr"] = extra.TransportErr
|
||||
data["RecipientErr"] = extra.RecipientErr
|
||||
h.view.Render(w, status, "inbound_domain", data)
|
||||
}
|
||||
|
||||
func inboundFlash(r *http.Request) string {
|
||||
switch {
|
||||
case r.URL.Query().Get("saved") != "":
|
||||
return "Upstream saved."
|
||||
case r.URL.Query().Get("recipients") != "":
|
||||
return "Recipients saved."
|
||||
case r.URL.Query().Get("rechecked") != "":
|
||||
return "DNS re-checked."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleInboundDNSRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireInbound(w, r); !ok {
|
||||
return
|
||||
}
|
||||
d, ok := h.lookupInbound(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.dns != nil && h.cfg.Hostname != "" {
|
||||
h.dns.InboundMX(d.Name, h.cfg.Hostname, true)
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/inbound/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireInbound(w, r); !ok {
|
||||
return
|
||||
}
|
||||
d, ok := h.lookupInbound(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: "Invalid form submission."})
|
||||
return
|
||||
}
|
||||
host := r.PostFormValue("host")
|
||||
port := r.PostFormValue("port")
|
||||
tlsMode := r.PostFormValue("tls_mode")
|
||||
if err := h.inbound.SetTransport(d.ID, host, port, tlsMode); err != nil {
|
||||
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/inbound/%d?saved=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleInboundRecipients(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireInbound(w, r); !ok {
|
||||
return
|
||||
}
|
||||
d, ok := h.lookupInbound(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: "Invalid form submission."})
|
||||
return
|
||||
}
|
||||
mode := r.PostFormValue("recipient_mode")
|
||||
addrs := splitAddresses(r.PostFormValue("addresses"))
|
||||
if err := h.inbound.SetRecipients(d.ID, mode, addrs); err != nil {
|
||||
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/inbound/%d?recipients=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleInboundDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireInbound(w, r); !ok {
|
||||
return
|
||||
}
|
||||
d, ok := h.lookupInbound(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — delete " + d.Name
|
||||
data["Active"] = "inbound"
|
||||
data["Domain"] = d
|
||||
data["Upstream"] = inboundUpstream(d)
|
||||
h.view.Render(w, http.StatusOK, "inbound_delete", data)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleInboundDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireInbound(w, r); !ok {
|
||||
return
|
||||
}
|
||||
d, ok := h.lookupInbound(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if h.dns != nil {
|
||||
h.dns.Forget(d.Name)
|
||||
}
|
||||
if err := h.inbound.Delete(d.ID); err != nil {
|
||||
if errors.Is(err, store.ErrInboundDomainNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logf("panel: delete inbound domain %d: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/inbound?deleted=1", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
type recordingMaps struct {
|
||||
n int
|
||||
}
|
||||
|
||||
func (r *recordingMaps) RebuildInboundMaps(_ []postfix.InboundRoute) error {
|
||||
r.n++
|
||||
return nil
|
||||
}
|
||||
|
||||
func inboundHandlers(t *testing.T) (*Handlers, *store.Store) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
v := mustView(t)
|
||||
v.SetInboundEnabled(true)
|
||||
h := &Handlers{
|
||||
store: st,
|
||||
inbound: inbound.NewService(st, &recordingMaps{}),
|
||||
view: v,
|
||||
cfg: Config{Version: "test", InboundEnabled: true, Hostname: "mail.example.org"},
|
||||
}
|
||||
return h, st
|
||||
}
|
||||
|
||||
func inboundCall(h *Handlers, method, target string, form url.Values, p auth.Principal) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
var req *http.Request
|
||||
if form != nil {
|
||||
req = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, target, nil)
|
||||
}
|
||||
req = auth.RequestWithPrincipal(req, p)
|
||||
if rest, ok := strings.CutPrefix(req.URL.Path, "/inbound/"); ok {
|
||||
id, _, _ := strings.Cut(rest, "/")
|
||||
if id != "" && id != "delete" {
|
||||
req.SetPathValue("id", id)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case method == http.MethodGet && target == "/inbound":
|
||||
h.HandleInboundList(rec, req)
|
||||
case method == http.MethodPost && target == "/inbound":
|
||||
h.HandleAddInbound(rec, req)
|
||||
case strings.HasSuffix(target, "/delete") && method == http.MethodGet:
|
||||
h.HandleInboundDeleteConfirm(rec, req)
|
||||
case strings.HasSuffix(target, "/delete") && method == http.MethodPost:
|
||||
h.HandleInboundDelete(rec, req)
|
||||
case strings.HasSuffix(target, "/upstream"):
|
||||
h.HandleInboundTransport(rec, req)
|
||||
case strings.HasSuffix(target, "/recipients"):
|
||||
h.HandleInboundRecipients(rec, req)
|
||||
default:
|
||||
h.HandleInboundDetail(rec, req)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestInboundListAndAdd(t *testing.T) {
|
||||
h, _ := inboundHandlers(t)
|
||||
rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list = %d\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Add inbound domain") {
|
||||
t.Fatal("list missing add form")
|
||||
}
|
||||
|
||||
rec = inboundCall(h, http.MethodPost, "/inbound", url.Values{"name": {"lists.example.com"}}, globalPrincipal)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("add = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
|
||||
if !strings.Contains(rec.Body.String(), "lists.example.com") {
|
||||
t.Fatalf("list missing domain:\n%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundDisabledIs404(t *testing.T) {
|
||||
h, _ := inboundHandlers(t)
|
||||
h.cfg.InboundEnabled = false
|
||||
rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("disabled inbound = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundTransportAndRecipients(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
d, err := st.AddInboundDomain("lists.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := itoa(d.ID)
|
||||
|
||||
rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/upstream", url.Values{
|
||||
"host": {"10.0.0.8"}, "port": {"25"}, "tls_mode": {"encrypt"},
|
||||
}, globalPrincipal)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("upstream = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{
|
||||
"recipient_mode": {"list"},
|
||||
"addresses": {"staff@lists.example.com\nabuse@other.com"},
|
||||
}, globalPrincipal)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("foreign recipient = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{
|
||||
"recipient_mode": {"list"},
|
||||
"addresses": {"staff@lists.example.com"},
|
||||
}, globalPrincipal)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("recipients = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = inboundCall(h, http.MethodGet, "/inbound/"+id, nil, globalPrincipal)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "10.0.0.8") {
|
||||
t.Fatalf("detail =\n%s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundDelete(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
d, err := st.AddInboundDomain("lists.example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := itoa(d.ID)
|
||||
rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/delete", nil, globalPrincipal)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := st.GetInboundDomain(d.ID); err == nil {
|
||||
t.Fatal("domain still present")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
@@ -347,7 +348,12 @@ func serverWithTwoDomains(t *testing.T) (*Handlers, map[string]store.Domain) {
|
||||
domains[d.name] = dom
|
||||
}
|
||||
|
||||
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, domains
|
||||
return &Handlers{
|
||||
store: st,
|
||||
inbound: inbound.NewService(st, &recordingMaps{}),
|
||||
view: mustView(t),
|
||||
cfg: Config{Version: "test", InboundEnabled: true},
|
||||
}, domains
|
||||
}
|
||||
|
||||
var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
|
||||
|
||||
@@ -3,6 +3,8 @@ package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
@@ -146,3 +148,104 @@ func Email(addr string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxHostLen = 253
|
||||
|
||||
// NormalizeHost trims, lower-cases, and strips wrapping IPv6 brackets so the
|
||||
// stored value is a bare hostname or IP, safe to wrap again when writing maps.
|
||||
func NormalizeHost(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
host = host[1 : len(host)-1]
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// Host enforces a whitelist for an upstream hostname or IP (security.md): a
|
||||
// dotted domain, a single DNS label (LAN names), or an IPv4/IPv6 address.
|
||||
func Host(host string) error {
|
||||
if host == "" {
|
||||
return fmt.Errorf("host is required")
|
||||
}
|
||||
if len(host) > maxHostLen {
|
||||
return fmt.Errorf("host must be at most %d characters", maxHostLen)
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return nil
|
||||
}
|
||||
labels := strings.Split(host, ".")
|
||||
for _, label := range labels {
|
||||
if err := domainLabel(label); err != nil {
|
||||
return fmt.Errorf("host is invalid: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Port checks a TCP port number parsed from form input.
|
||||
func Port(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("port is required")
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return 0, fmt.Errorf("port must be between 1 and 65535")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// TLSMode checks a Postfix smtp_tls_policy_maps level.
|
||||
func TLSMode(mode string) error {
|
||||
switch mode {
|
||||
case "may", "encrypt", "none":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid TLS mode")
|
||||
}
|
||||
}
|
||||
|
||||
// RecipientMode checks an inbound-domain recipient policy.
|
||||
func RecipientMode(mode string) error {
|
||||
switch mode {
|
||||
case "list", "any":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid recipient mode")
|
||||
}
|
||||
}
|
||||
|
||||
// MailboxInDomain checks that addr is a conservative mailbox on domain
|
||||
// (security.md). domain must already be normalised.
|
||||
func MailboxInDomain(addr, domain string) error {
|
||||
at := strings.LastIndexByte(addr, '@')
|
||||
if at <= 0 || at >= len(addr)-1 {
|
||||
return fmt.Errorf("%q is not a valid email address", addr)
|
||||
}
|
||||
local, host := addr[:at], addr[at+1:]
|
||||
if host != domain {
|
||||
return fmt.Errorf("%q does not belong to domain %s", addr, domain)
|
||||
}
|
||||
if err := mailboxLocalPart(local); err != nil {
|
||||
return fmt.Errorf("%q: %w", addr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mailboxLocalPart(local string) error {
|
||||
if local == "" {
|
||||
return fmt.Errorf("missing the part before '@'")
|
||||
}
|
||||
if local[0] == '.' || local[len(local)-1] == '.' {
|
||||
return fmt.Errorf("local part must not start or end with '.'")
|
||||
}
|
||||
for i := 0; i < len(local); i++ {
|
||||
c := local[i]
|
||||
lower := c >= 'a' && c <= 'z'
|
||||
digit := c >= '0' && c <= '9'
|
||||
if !lower && !digit && c != '.' && c != '-' && c != '_' && c != '+' {
|
||||
return fmt.Errorf("local part may contain only lower-case letters, digits, '.', '-', '_' and '+'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -65,3 +65,69 @@ func TestValidateDomainLongLabelRejected(t *testing.T) {
|
||||
t.Error("expected error for over-long label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostValid(t *testing.T) {
|
||||
valid := []string{
|
||||
"10.0.0.8",
|
||||
"192.0.2.20",
|
||||
"2001:db8::1",
|
||||
"mail.internal.example",
|
||||
"mx1",
|
||||
"mail-1.lan",
|
||||
}
|
||||
for _, h := range valid {
|
||||
if err := Host(h); err != nil {
|
||||
t.Errorf("Host(%q) unexpected error: %v", h, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostInvalid(t *testing.T) {
|
||||
invalid := []string{
|
||||
"",
|
||||
"exa mple",
|
||||
"host/name",
|
||||
"host;rm",
|
||||
"-bad",
|
||||
"bad-",
|
||||
"host\nname",
|
||||
}
|
||||
for _, h := range invalid {
|
||||
if err := Host(h); err == nil {
|
||||
t.Errorf("Host(%q) = nil, want error", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeHostStripsIPv6Brackets(t *testing.T) {
|
||||
if got := NormalizeHost(" [2001:DB8::1] "); got != "2001:db8::1" {
|
||||
t.Errorf("NormalizeHost IPv6 = %q", got)
|
||||
}
|
||||
if got := NormalizeHost("Mail.Example.COM"); got != "mail.example.com" {
|
||||
t.Errorf("NormalizeHost hostname = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPort(t *testing.T) {
|
||||
n, err := Port("25")
|
||||
if err != nil || n != 25 {
|
||||
t.Fatalf("Port(25) = %d, %v", n, err)
|
||||
}
|
||||
for _, raw := range []string{"", "0", "65536", "abc", "-1"} {
|
||||
if _, err := Port(raw); err == nil {
|
||||
t.Errorf("Port(%q) = nil, want error", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailboxInDomain(t *testing.T) {
|
||||
if err := MailboxInDomain("staff@lists.example.com", "lists.example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MailboxInDomain("staff@other.com", "lists.example.com"); err == nil {
|
||||
t.Fatal("expected domain mismatch error")
|
||||
}
|
||||
if err := MailboxInDomain("bad addr@lists.example.com", "lists.example.com"); err == nil {
|
||||
t.Fatal("expected local-part error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>Inbound</h1>
|
||||
|
||||
<p class="muted">Backup-MX / forwarder. Accepts on port 25 only for listed
|
||||
domains. Recipients are either an allow-list or any address at that domain.
|
||||
Off by default in Compose.</p>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Forwarding</h2>
|
||||
<form method="post" action="/inbound">
|
||||
<label for="name">Add inbound domain</label>
|
||||
<div class="input-row">
|
||||
<input id="name" name="name" type="text" placeholder="lists.example.com"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormName}}" autofocus required>
|
||||
<button type="submit">Add domain</button>
|
||||
</div>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
</form>
|
||||
{{if .Domains}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Domains}}
|
||||
<tr>
|
||||
<td><a href="/inbound/{{.ID}}">{{.Name}}</a></td>
|
||||
<td><a class="st st-{{.DNS}}" href="/inbound/{{.ID}}">{{.DNS}}</a></td>
|
||||
<td class="muted">{{.Upstream}}</td>
|
||||
<td>{{.RcptLabel}}</td>
|
||||
<td><span class="st st-{{if eq .TLSMode "encrypt"}}ok{{else}}unknown{{end}}">{{.TLSLabel}}</span></td>
|
||||
<td class="actions"><a class="danger" href="/inbound/{{.ID}}/delete">Delete</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">The <em>DNS</em> badge is the MX check: at least one MX must
|
||||
point at this server. Results are cached for a few minutes; open a domain for
|
||||
the lookup and a <em>Re-check</em> button.</p>
|
||||
{{else}}
|
||||
<p class="muted">No inbound domains yet. Add one above, then set the upstream
|
||||
on its page. Mail is not accepted until an upstream host is saved.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{define "content"}}
|
||||
<h1>Delete {{.Domain.Name}}</h1>
|
||||
|
||||
{{template "back_link" (back (printf "/inbound/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Confirm deletion</h2>
|
||||
<p>You are about to stop accepting inbound mail for <strong>{{.Domain.Name}}</strong>. This will:</p>
|
||||
<ul>
|
||||
<li>remove it from <code>relay_domains</code> and the recipient map;</li>
|
||||
<li>stop forwarding{{if ne .Upstream "—"}} to <strong>{{.Upstream}}</strong>{{end}};</li>
|
||||
<li>leave outbound sending domains untouched.</li>
|
||||
</ul>
|
||||
<p class="muted">This cannot be undone from a backup of inbound maps alone
|
||||
unless you restore one. Remove the MX if you do not plan to re-add the domain.</p>
|
||||
<form method="post" action="/inbound/{{.Domain.ID}}/delete">
|
||||
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,118 @@
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
|
||||
{{template "back_link" (back "/inbound" "All inbound domains")}}
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card" id="dns-status">
|
||||
<h2>DNS status <span class="st st-{{.MX.Status}}">{{.MX.Status}}</span></h2>
|
||||
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing.
|
||||
Unlike outbound, inbound needs an MX pointing at this server.</p>
|
||||
<label>MX <span class="st st-{{.MX.Status}}">{{.MX.Status}}</span></label>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<span class="code">{{.Domain.Name}}</span>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">MX</span>
|
||||
</div>
|
||||
</div>
|
||||
<label>Value</label>
|
||||
{{if .MX.Records}}
|
||||
<span class="code">{{range .MX.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{else}}
|
||||
<span class="code muted">No MX records found.</span>
|
||||
{{end}}
|
||||
{{if eq .MX.Status "ok"}}
|
||||
<p class="muted">{{.MX.Detail}}</p>
|
||||
{{else}}
|
||||
<p class="error">{{.MX.Detail}}</p>
|
||||
{{end}}
|
||||
<form method="post" action="/inbound/{{.Domain.ID}}/dns-recheck">
|
||||
<button type="submit">Re-check</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<h2>Upstream</h2>
|
||||
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
|
||||
<form method="post" action="/inbound/{{.Domain.ID}}/upstream">
|
||||
<label for="host">Host</label>
|
||||
<input id="host" name="host" type="text" value="{{.Domain.Host}}"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
||||
<label for="port">Port</label>
|
||||
<input id="port" name="port" type="text" inputmode="numeric" value="{{.Domain.Port}}" required>
|
||||
<label for="tls_mode">TLS to upstream</label>
|
||||
<select id="tls_mode" name="tls_mode">
|
||||
<option value="may" {{if eq .Domain.TLSMode "may"}}selected{{end}}>Opportunistic</option>
|
||||
<option value="encrypt" {{if eq .Domain.TLSMode "encrypt"}}selected{{end}}>Required</option>
|
||||
<option value="none" {{if eq .Domain.TLSMode "none"}}selected{{end}}>Off</option>
|
||||
</select>
|
||||
{{if .TransportErr}}<p class="error">{{.TransportErr}}</p>{{end}}
|
||||
<button type="submit">Save upstream</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>MX record to publish</h2>
|
||||
<p class="muted">Add this MX so the internet delivers here. Keep any existing
|
||||
primary MX if this is backup-MX.</p>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Domain.Name}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">MX</span>
|
||||
</div>
|
||||
</div>
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.MXValue}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<h2>Valid recipients</h2>
|
||||
<p class="muted">Who this domain accepts on port 25. Same idea as an
|
||||
application's address mode: a list, or any address at the domain.</p>
|
||||
<form method="post" action="/inbound/{{.Domain.ID}}/recipients">
|
||||
<label for="recipient_mode">Who to accept</label>
|
||||
<select id="recipient_mode" name="recipient_mode" data-list-mode="list">
|
||||
<option value="list" {{if eq .Domain.RecipientMode "list"}}selected{{end}}>Listed addresses only</option>
|
||||
<option value="any" {{if eq .Domain.RecipientMode "any"}}selected{{end}}>Any recipient at this domain</option>
|
||||
</select>
|
||||
<div data-addresses>
|
||||
<label for="addresses">Addresses (one per line or comma-separated)</label>
|
||||
<textarea id="addresses" name="addresses" rows="6">{{.RecipientText}}</textarea>
|
||||
<p class="muted">Unknown recipients are rejected at RCPT so this relay
|
||||
does not generate backscatter.</p>
|
||||
</div>
|
||||
<p class="muted">Every address at this domain is accepted and forwarded when
|
||||
“any recipient” is selected. Prefer a list unless the upstream rejects
|
||||
unknowns — otherwise this relay may generate backscatter.</p>
|
||||
{{if .RecipientErr}}<p class="error">{{.RecipientErr}}</p>{{end}}
|
||||
<button type="submit">Save recipients</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Danger zone</h2>
|
||||
<p class="muted">Stops accepting mail for this domain. Does not touch
|
||||
outbound sending domains.</p>
|
||||
<p><a class="danger" href="/inbound/{{.Domain.ID}}/delete">Delete inbound domain</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -70,6 +70,9 @@
|
||||
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
|
||||
{{end}}
|
||||
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
||||
{{if and .IsGlobal .InboundEnabled}}
|
||||
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}}
|
||||
{{end}}
|
||||
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||
{{if .IsGlobal}}
|
||||
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||
@@ -112,6 +115,7 @@
|
||||
templates so the nav above stays one readable line per page. */}}
|
||||
{{define "icon-status"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
|
||||
{{define "icon-domains"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
|
||||
{{define "icon-inbound"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 9.5h11"/><path d="M8 2.75v6.2"/><path d="M5.4 6.4 8 9.05 10.6 6.4"/><path d="M3.2 12.6h9.6"/></svg>{{end}}
|
||||
{{define "icon-deliveries"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
|
||||
{{define "icon-mail-queue"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
|
||||
{{define "icon-system-log"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
|
||||
|
||||
@@ -178,6 +178,35 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
||||
if strings.Index(out, "Status") > strings.Index(out, "Domains") {
|
||||
t.Errorf("Status is not the first navigation entry:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `href="/inbound"`) || strings.Contains(out, "Inbound") {
|
||||
t.Errorf("Inbound nav is shown while InboundEnabled is unset:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavShowsInboundWhenEnabled(t *testing.T) {
|
||||
engine, err := New("test")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
engine.SetInboundEnabled(true)
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
"IsGlobal": true,
|
||||
"InboundEnabled": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `href="/inbound"`) || !strings.Contains(out, "Inbound") {
|
||||
t.Errorf("Inbound nav is missing while InboundEnabled is true:\n%s", out)
|
||||
}
|
||||
dom := strings.Index(out, `href="/domains"`)
|
||||
inb := strings.Index(out, `href="/inbound"`)
|
||||
if dom < 0 || inb < 0 || inb < dom {
|
||||
t.Errorf("Inbound should follow Domains:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Whether a page takes the whole column or the reading measure is declared by
|
||||
@@ -190,7 +219,11 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
wide := map[string]bool{"settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "status": true, "system_log": true, "domain_detail": true}
|
||||
wide := map[string]bool{
|
||||
"settings": true, "deliveries": true, "delivery": true, "mail_queue": true,
|
||||
"status": true, "system_log": true, "domain_detail": true,
|
||||
"inbound": true, "inbound_domain": true,
|
||||
}
|
||||
for name, page := range engine.Pages() {
|
||||
var buf bytes.Buffer
|
||||
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
|
||||
@@ -281,11 +314,13 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
|
||||
|
||||
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
||||
drillDown := map[string]bool{
|
||||
"user_form.html": true,
|
||||
"user_delete.html": true,
|
||||
"domain_detail.html": true,
|
||||
"domain_delete.html": true,
|
||||
"delivery.html": true,
|
||||
"user_form.html": true,
|
||||
"user_delete.html": true,
|
||||
"domain_detail.html": true,
|
||||
"domain_delete.html": true,
|
||||
"inbound_domain.html": true,
|
||||
"inbound_delete.html": true,
|
||||
"delivery.html": true,
|
||||
}
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
if !drillDown[name] {
|
||||
|
||||
+29
-18
@@ -18,9 +18,10 @@ var assetsFS embed.FS
|
||||
|
||||
// Engine holds parsed page and fragment templates.
|
||||
type Engine struct {
|
||||
pages map[string]*template.Template
|
||||
fragments map[string]*template.Template
|
||||
version string
|
||||
pages map[string]*template.Template
|
||||
fragments map[string]*template.Template
|
||||
version string
|
||||
inboundEnabled bool
|
||||
}
|
||||
|
||||
// pageFiles maps a logical page name to its template files. Every page
|
||||
@@ -31,21 +32,24 @@ type Engine struct {
|
||||
// encryption fields on the two secret downloads) list that partial the same
|
||||
// way.
|
||||
var pageFiles = map[string][]string{
|
||||
"setup": {"templates/setup.html"},
|
||||
"login": {"templates/login.html"},
|
||||
"dashboard": {"templates/dashboard.html"},
|
||||
"settings": {"templates/settings.html"},
|
||||
"users": {"templates/users.html"},
|
||||
"user_form": {"templates/user_form.html"},
|
||||
"user_delete": {"templates/user_delete.html"},
|
||||
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
|
||||
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
|
||||
"domain_delete": {"templates/domain_delete.html"},
|
||||
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
|
||||
"delivery": {"templates/delivery.html"},
|
||||
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
||||
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
||||
"status": {"templates/status.html", "templates/status_body.html"},
|
||||
"setup": {"templates/setup.html"},
|
||||
"login": {"templates/login.html"},
|
||||
"dashboard": {"templates/dashboard.html"},
|
||||
"settings": {"templates/settings.html"},
|
||||
"users": {"templates/users.html"},
|
||||
"user_form": {"templates/user_form.html"},
|
||||
"user_delete": {"templates/user_delete.html"},
|
||||
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
|
||||
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
|
||||
"domain_delete": {"templates/domain_delete.html"},
|
||||
"inbound": {"templates/inbound.html"},
|
||||
"inbound_domain": {"templates/inbound_domain.html"},
|
||||
"inbound_delete": {"templates/inbound_delete.html"},
|
||||
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
|
||||
"delivery": {"templates/delivery.html"},
|
||||
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
|
||||
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
|
||||
"status": {"templates/status.html", "templates/status_body.html"},
|
||||
}
|
||||
|
||||
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
||||
@@ -82,6 +86,12 @@ func New(version string) (*Engine, error) {
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// SetInboundEnabled controls whether the Inbound nav item is shown. The
|
||||
// listener and routes are gated the same way (INBOUND_RELAY_ENABLE).
|
||||
func (e *Engine) SetInboundEnabled(v bool) {
|
||||
e.inboundEnabled = v
|
||||
}
|
||||
|
||||
// templateFuncs supplies helpers shared across page templates.
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
@@ -124,6 +134,7 @@ func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any
|
||||
m["Version"] = e.version
|
||||
m["Copyright"] = legal.CopyrightLine
|
||||
m["SourceURL"] = legal.SourceURL
|
||||
m["InboundEnabled"] = e.inboundEnabled
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
||||
|
||||
+18
-2
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/legal"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
@@ -82,6 +83,8 @@ type Config struct {
|
||||
// when the HTTP role starts. Handlers read the cache; they never call
|
||||
// postconf (architecture.md).
|
||||
RetryPolicy postfix.RetryPolicy
|
||||
// InboundEnabled mirrors INBOUND_RELAY_ENABLE.
|
||||
InboundEnabled bool
|
||||
}
|
||||
|
||||
// Server is the panel HTTP application.
|
||||
@@ -96,18 +99,19 @@ type Server struct {
|
||||
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
|
||||
// apps owns application SASL accounts and the Postfix sender map
|
||||
// (architecture.md § Mail path).
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
v, err := view.New(cfg.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.SetInboundEnabled(cfg.InboundEnabled)
|
||||
a := auth.New(st, auth.Config{
|
||||
CookieSecure: cfg.CookieSecure,
|
||||
Hostname: cfg.Hostname,
|
||||
SessionIdleDays: cfg.SessionIdleDays,
|
||||
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
|
||||
}, v, setupTokenPath)
|
||||
h := handlers.New(st, domains, apps, handlers.Config{
|
||||
h := handlers.New(st, domains, apps, inboundSvc, handlers.Config{
|
||||
Hostname: cfg.Hostname,
|
||||
SubmissionEnabled: cfg.SubmissionEnabled,
|
||||
MailLogPath: cfg.MailLogPath,
|
||||
@@ -121,6 +125,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
|
||||
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
|
||||
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
||||
RetryPolicy: cfg.RetryPolicy,
|
||||
InboundEnabled: cfg.InboundEnabled,
|
||||
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
||||
return &Server{cfg: cfg, auth: a, handlers: h}, nil
|
||||
}
|
||||
@@ -167,6 +172,17 @@ func (s *Server) Handler() http.Handler {
|
||||
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
|
||||
authed.HandleFunc("POST /reload", h.HandleReload)
|
||||
|
||||
if s.cfg.InboundEnabled {
|
||||
authed.HandleFunc("GET /inbound", h.HandleInboundList)
|
||||
authed.HandleFunc("POST /inbound", h.HandleAddInbound)
|
||||
authed.HandleFunc("GET /inbound/{id}", h.HandleInboundDetail)
|
||||
authed.HandleFunc("POST /inbound/{id}/dns-recheck", h.HandleInboundDNSRecheck)
|
||||
authed.HandleFunc("POST /inbound/{id}/upstream", h.HandleInboundTransport)
|
||||
authed.HandleFunc("POST /inbound/{id}/recipients", h.HandleInboundRecipients)
|
||||
authed.HandleFunc("GET /inbound/{id}/delete", h.HandleInboundDeleteConfirm)
|
||||
authed.HandleFunc("POST /inbound/{id}/delete", h.HandleInboundDelete)
|
||||
}
|
||||
|
||||
authed.HandleFunc("/settings", h.HandleSettings)
|
||||
authed.HandleFunc("/account", redirectSettings)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user