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
+9
View File
@@ -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
+7
View File
@@ -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)
}
+319
View File
@@ -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}