polishing and DKIM,caldav,cardav
This commit is contained in:
@@ -5,5 +5,6 @@ settings.ini
|
|||||||
tests/
|
tests/
|
||||||
|
|
||||||
.claude/
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
*.sum
|
*.sum
|
||||||
@@ -3,29 +3,43 @@ module mailgoserver
|
|||||||
go 1.26.4
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.1
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.6
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.15
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.6
|
||||||
|
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
|
||||||
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||||
github.com/emersion/go-msgauth v0.7.0
|
github.com/emersion/go-msgauth v0.7.0
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||||
github.com/emersion/go-smtp v0.24.0
|
github.com/emersion/go-smtp v0.24.0
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3
|
||||||
|
github.com/emersion/go-webdav v0.7.0
|
||||||
|
github.com/go-acme/lego/v4 v4.35.2
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
|
github.com/pquerna/otp v1.5.0
|
||||||
|
github.com/teambition/rrule-go v1.8.2
|
||||||
|
go.mozilla.org/pkcs7 v0.10.0
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.55.0
|
||||||
|
golang.org/x/net v0.57.0
|
||||||
|
golang.org/x/oauth2 v0.36.0
|
||||||
|
google.golang.org/api v0.276.0
|
||||||
gopkg.in/ini.v1 v1.67.3
|
gopkg.in/ini.v1 v1.67.3
|
||||||
modernc.org/sqlite v1.56.0
|
modernc.org/sqlite v1.56.0
|
||||||
|
software.sslmate.com/src/go-pkcs12 v0.7.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/auth v0.20.0 // indirect
|
cloud.google.com/go/auth v0.20.0 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect
|
github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
|
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
|
||||||
@@ -37,16 +51,13 @@ require (
|
|||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.2 // indirect
|
github.com/cloudflare/circl v1.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
|
|
||||||
github.com/emersion/go-message v0.18.2 // indirect
|
github.com/emersion/go-message v0.18.2 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/go-acme/lego/v4 v4.35.2 // indirect
|
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/go-webauthn/webauthn v0.17.4 // indirect
|
|
||||||
github.com/go-webauthn/x v0.2.6 // indirect
|
github.com/go-webauthn/x v0.2.6 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/google/go-tpm v0.9.8 // indirect
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
@@ -56,33 +67,26 @@ require (
|
|||||||
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
|
||||||
github.com/miekg/dns v1.1.72 // indirect
|
github.com/miekg/dns v1.1.72 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pquerna/otp v1.5.0 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tinylib/msgp v1.6.4 // indirect
|
github.com/tinylib/msgp v1.6.4 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.mozilla.org/pkcs7 v0.10.0 // indirect
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
golang.org/x/mod v0.38.0 // indirect
|
golang.org/x/mod v0.38.0 // indirect
|
||||||
golang.org/x/net v0.57.0 // indirect
|
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
golang.org/x/tools v0.48.0 // indirect
|
golang.org/x/tools v0.48.0 // indirect
|
||||||
google.golang.org/api v0.276.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||||
google.golang.org/grpc v1.80.0 // indirect
|
google.golang.org/grpc v1.80.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
software.sslmate.com/src/go-pkcs12 v0.7.3 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
|
|||||||
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
||||||
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
@@ -49,23 +48,29 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
|
|||||||
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
|
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
|
||||||
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||||
|
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
|
||||||
|
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||||
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
|
||||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||||
github.com/emersion/go-milter v0.4.1/go.mod h1:erCQVl0mH4SX9jEvwe+wyndit0rQtmvMLH86V6NGtkI=
|
|
||||||
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
|
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
|
||||||
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
|
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 h1:B9YK+Tck5mTccyDhtxBzWyqGYcFxLyB6+noMNW4/VgI=
|
||||||
|
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||||
|
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
|
||||||
|
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||||
@@ -87,8 +92,14 @@ github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
|||||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||||
@@ -113,9 +124,9 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
|
|||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@@ -130,6 +141,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||||
|
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
@@ -139,22 +152,28 @@ go.mozilla.org/pkcs7 v0.10.0 h1:jmljzDzNYFzaP1dFlgmCiQml9e+iEMmv8/NNs4evQbg=
|
|||||||
go.mozilla.org/pkcs7 v0.10.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
|
go.mozilla.org/pkcs7 v0.10.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@@ -168,8 +187,6 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7
|
|||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -183,7 +200,6 @@ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
@@ -191,17 +207,23 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
|
||||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY=
|
google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY=
|
||||||
google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
|
google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
|
||||||
|
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||||
|
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package abuseguard
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/ini.v1"
|
"gopkg.in/ini.v1"
|
||||||
@@ -54,18 +55,30 @@ func RecordFailureAndMaybeBlacklist(database *db.DB, cfg *ini.File, logger *tool
|
|||||||
|
|
||||||
// guardedListener wraps a net.Listener so Accept() silently drops connections from
|
// guardedListener wraps a net.Listener so Accept() silently drops connections from
|
||||||
// blacklisted IPs (never returning them to the caller) before any protocol banner is
|
// blacklisted IPs (never returning them to the caller) before any protocol banner is
|
||||||
// written, and keeps looping rather than returning an error.
|
// written, and keeps looping rather than returning an error. Also caps concurrent
|
||||||
|
// connections per source IP — a resource-exhaustion guard distinct from the
|
||||||
|
// failed-auth-triggered blacklist above (a connection flood doesn't need to fail auth
|
||||||
|
// even once to tie up every worker/file-descriptor this server has).
|
||||||
type guardedListener struct {
|
type guardedListener struct {
|
||||||
net.Listener
|
net.Listener
|
||||||
database *db.DB
|
database *db.DB
|
||||||
logger *toolbox.Logger
|
logger *toolbox.Logger
|
||||||
|
maxPerIP int // <=0 means unlimited
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
counts map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
// GuardListener wraps inner so every accepted connection is checked against the IP
|
// GuardListener wraps inner so every accepted connection is checked against the IP
|
||||||
// blacklist (skipping the check entirely for abuse-whitelisted IPs) before the caller
|
// blacklist (skipping the check entirely for abuse-whitelisted IPs) and the per-IP
|
||||||
// ever sees it.
|
// concurrent-connection cap ([Security] max_connections_per_ip, default 20) before the
|
||||||
func GuardListener(inner net.Listener, database *db.DB, logger *toolbox.Logger) net.Listener {
|
// caller ever sees it.
|
||||||
return &guardedListener{Listener: inner, database: database, logger: logger}
|
func GuardListener(inner net.Listener, database *db.DB, cfg *ini.File, logger *toolbox.Logger) net.Listener {
|
||||||
|
maxPerIP := 20
|
||||||
|
if cfg != nil {
|
||||||
|
maxPerIP = cfg.Section("Security").Key("max_connections_per_ip").MustInt(20)
|
||||||
|
}
|
||||||
|
return &guardedListener{Listener: inner, database: database, logger: logger, maxPerIP: maxPerIP, counts: make(map[string]int)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *guardedListener) Accept() (net.Conn, error) {
|
func (g *guardedListener) Accept() (net.Conn, error) {
|
||||||
@@ -78,19 +91,58 @@ func (g *guardedListener) Accept() (net.Conn, error) {
|
|||||||
if splitErr != nil {
|
if splitErr != nil {
|
||||||
host = conn.RemoteAddr().String()
|
host = conn.RemoteAddr().String()
|
||||||
}
|
}
|
||||||
|
abuseWhitelisted := false
|
||||||
if whitelisted, wErr := g.database.IsIPAbuseWhitelisted(host); wErr == nil && whitelisted {
|
if whitelisted, wErr := g.database.IsIPAbuseWhitelisted(host); wErr == nil && whitelisted {
|
||||||
return conn, nil
|
abuseWhitelisted = true
|
||||||
}
|
}
|
||||||
blocked, bErr := g.database.IsIPBlacklisted(host)
|
if !abuseWhitelisted {
|
||||||
if bErr != nil {
|
blocked, bErr := g.database.IsIPBlacklisted(host)
|
||||||
return conn, nil // fail open on a DB error
|
if bErr == nil && blocked {
|
||||||
|
if g.logger != nil {
|
||||||
|
g.logger.Warning("abuseguard: rejected connection from blacklisted IP %s", host)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !blocked {
|
// The concurrent-connection cap applies even to an abuse-whitelisted IP —
|
||||||
return conn, nil
|
// whitelisting exempts an IP from being auto-blacklisted over failed auth, not
|
||||||
|
// from basic resource-exhaustion protection, a different concern.
|
||||||
|
if g.maxPerIP > 0 {
|
||||||
|
g.mu.Lock()
|
||||||
|
if g.counts[host] >= g.maxPerIP {
|
||||||
|
g.mu.Unlock()
|
||||||
|
if g.logger != nil {
|
||||||
|
g.logger.Warning("abuseguard: rejected connection from %s: at the concurrent-connection limit (%d)", host, g.maxPerIP)
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g.counts[host]++
|
||||||
|
g.mu.Unlock()
|
||||||
|
conn = &countedConn{Conn: conn, g: g, host: host}
|
||||||
}
|
}
|
||||||
if g.logger != nil {
|
return conn, nil
|
||||||
g.logger.Warning("abuseguard: rejected connection from blacklisted IP %s", host)
|
|
||||||
}
|
|
||||||
conn.Close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countedConn decrements guardedListener's per-IP counter exactly once, however Close
|
||||||
|
// ends up getting called (explicitly, via a defer, or both).
|
||||||
|
type countedConn struct {
|
||||||
|
net.Conn
|
||||||
|
g *guardedListener
|
||||||
|
host string
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countedConn) Close() error {
|
||||||
|
c.once.Do(func() {
|
||||||
|
c.g.mu.Lock()
|
||||||
|
c.g.counts[c.host]--
|
||||||
|
if c.g.counts[c.host] <= 0 {
|
||||||
|
delete(c.g.counts, c.host)
|
||||||
|
}
|
||||||
|
c.g.mu.Unlock()
|
||||||
|
})
|
||||||
|
return c.Conn.Close()
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gopkg.in/ini.v1"
|
"gopkg.in/ini.v1"
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ func TestGuardListenerRejectsBlacklistedIP(t *testing.T) {
|
|||||||
defer blockedPeer.Close()
|
defer blockedPeer.Close()
|
||||||
|
|
||||||
inner := newFakeListener(&addrOverrideConn{Conn: blockedConn, remote: hostPortAddr(blockedIP)})
|
inner := newFakeListener(&addrOverrideConn{Conn: blockedConn, remote: hostPortAddr(blockedIP)})
|
||||||
guarded := GuardListener(inner, database, nil)
|
guarded := GuardListener(inner, database, nil, nil)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
guarded.Accept()
|
guarded.Accept()
|
||||||
@@ -144,3 +145,67 @@ type hostPortAddr string
|
|||||||
|
|
||||||
func (hostPortAddr) Network() string { return "tcp" }
|
func (hostPortAddr) Network() string { return "tcp" }
|
||||||
func (a hostPortAddr) String() string { return string(a) + ":12345" }
|
func (a hostPortAddr) String() string { return string(a) + ":12345" }
|
||||||
|
|
||||||
|
// TestGuardListenerCapsConcurrentConnectionsPerIP confirms the max_connections_per_ip
|
||||||
|
// limit is enforced independently of the blacklist check, and that closing an accepted
|
||||||
|
// connection frees up its slot for a subsequent one from the same IP.
|
||||||
|
func TestGuardListenerCapsConcurrentConnectionsPerIP(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
const ip = "198.51.100.99"
|
||||||
|
cfg := ini.Empty()
|
||||||
|
sec, _ := cfg.NewSection("Security")
|
||||||
|
sec.NewKey("max_connections_per_ip", "2")
|
||||||
|
|
||||||
|
newConnFromIP := func() (*addrOverrideConn, net.Conn) {
|
||||||
|
local, peer := net.Pipe()
|
||||||
|
return &addrOverrideConn{Conn: local, remote: hostPortAddr(ip)}, peer
|
||||||
|
}
|
||||||
|
|
||||||
|
c1, peer1 := newConnFromIP()
|
||||||
|
c2, peer2 := newConnFromIP()
|
||||||
|
c3, peer3 := newConnFromIP()
|
||||||
|
defer peer1.Close()
|
||||||
|
defer peer2.Close()
|
||||||
|
defer peer3.Close()
|
||||||
|
|
||||||
|
inner := newFakeListener(c1, c2, c3)
|
||||||
|
guarded := GuardListener(inner, database, cfg, nil)
|
||||||
|
|
||||||
|
accepted := make(chan net.Conn, 3)
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
c, err := guarded.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accepted <- c
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
got1 := <-accepted
|
||||||
|
got2 := <-accepted
|
||||||
|
|
||||||
|
// The 3rd connection is over the cap of 2 — its peer should observe a close, and
|
||||||
|
// nothing further should arrive on the accepted channel for it.
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
if _, err := peer3.Read(buf); err == nil {
|
||||||
|
t.Fatal("expected the 3rd connection (over the cap) to be closed rather than accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing one of the first two frees a slot — a 4th connection from the same IP
|
||||||
|
// should now be accepted.
|
||||||
|
got1.Close()
|
||||||
|
c4, peer4 := newConnFromIP()
|
||||||
|
defer peer4.Close()
|
||||||
|
inner.conns <- c4
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got4 := <-accepted:
|
||||||
|
if got4 == nil {
|
||||||
|
t.Fatal("expected the 4th connection to be accepted after freeing a slot")
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected the 4th connection to be accepted after closing one of the first two, but Accept never returned it")
|
||||||
|
}
|
||||||
|
_ = got2
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/mailstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// domainSanitize neuters filesystem-unsafe characters in a mailbox email address so it
|
||||||
|
// can be used as a directory name — mirrors mailstore's own unexported
|
||||||
|
// sanitizePathSegment, duplicated here rather than exported since it's three lines.
|
||||||
|
func domainSanitize(s string) string {
|
||||||
|
for _, c := range []string{"/", "\\", ":", "@"} {
|
||||||
|
s = strings.ReplaceAll(s, c, "_")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
type mailboxMeta struct {
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
|
QuotaBytes int64
|
||||||
|
GroupMessages bool
|
||||||
|
RemoteImagesMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageMeta struct {
|
||||||
|
MessageIDHeader string
|
||||||
|
Flags string
|
||||||
|
InternalDate time.Time
|
||||||
|
CachedFrom string
|
||||||
|
CachedSubject string
|
||||||
|
}
|
||||||
|
|
||||||
|
type contactExport struct {
|
||||||
|
UID, Email, Name, Phone, GivenName, FamilyName, Org string
|
||||||
|
}
|
||||||
|
|
||||||
|
type eventExport struct {
|
||||||
|
UID, Summary, Description, Location, RRule, Color string
|
||||||
|
StartAt, EndAt time.Time
|
||||||
|
AllDay bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ruleExport struct {
|
||||||
|
Priority int
|
||||||
|
Conditions []db.RuleCondition
|
||||||
|
MatchType string
|
||||||
|
Name string
|
||||||
|
Action string
|
||||||
|
ActionValue string
|
||||||
|
ActionOptionsJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
type signatureExport struct {
|
||||||
|
Name string
|
||||||
|
ContentHTML string
|
||||||
|
IsDefaultNew bool
|
||||||
|
IsDefaultReply bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(path string, v any) error {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, b, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSON(path string, v any) error {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteDomain exports every mailbox in domainID — messages (decrypted), contacts,
|
||||||
|
// calendar events, filter rules, and signatures — into a self-contained archive
|
||||||
|
// re-encrypted under passphrase (mandatory: unlike WriteServer's server-local backup,
|
||||||
|
// this archive is meant to be portable to a different server that doesn't share this
|
||||||
|
// one's master key, so passphrase-derived encryption is the only protection it has).
|
||||||
|
// MFA/WebAuthn/app-passwords are deliberately not exported — server-specific
|
||||||
|
// credentials that wouldn't make sense to carry to a different server; a mailbox
|
||||||
|
// arrives on the far side with just its password (unlock via RestoreDomain) and no MFA
|
||||||
|
// enrolled, same starting state as a freshly created mailbox.
|
||||||
|
func WriteDomain(w io.Writer, database *db.DB, store *mailstore.Store, domainID int64, passphrase string) error {
|
||||||
|
if passphrase == "" {
|
||||||
|
return errors.New("a passphrase is required to export a domain")
|
||||||
|
}
|
||||||
|
mailboxes, err := database.ListMailboxesForDomain(domainID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
staging, err := os.MkdirTemp("", "mtadomainexport-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(staging)
|
||||||
|
|
||||||
|
for _, mbox := range mailboxes {
|
||||||
|
mboxDir := filepath.Join(staging, "mailboxes", domainSanitize(mbox.Email))
|
||||||
|
if err := os.MkdirAll(mboxDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeJSON(filepath.Join(mboxDir, "meta.json"), mailboxMeta{
|
||||||
|
Email: mbox.Email, PasswordHash: mbox.PasswordHash, QuotaBytes: mbox.QuotaBytes,
|
||||||
|
GroupMessages: mbox.GroupMessages, RemoteImagesMode: mbox.RemoteImagesMode,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
folders, err := database.AllFoldersForMailbox(mbox.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, folder := range folders {
|
||||||
|
msgs, err := database.ListMessagesInFolder(mbox.ID, folder)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
folderDir := filepath.Join(mboxDir, "messages", domainSanitize(folder))
|
||||||
|
if err := os.MkdirAll(folderDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, msg := range msgs {
|
||||||
|
raw, err := store.FetchMessage(mbox.ID, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch message %d in %s/%s: %w", msg.ID, mbox.Email, folder, err)
|
||||||
|
}
|
||||||
|
base := fmt.Sprintf("%d", msg.ID)
|
||||||
|
if err := os.WriteFile(filepath.Join(folderDir, base+".eml"), raw, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeJSON(filepath.Join(folderDir, base+".json"), messageMeta{
|
||||||
|
MessageIDHeader: msg.MessageIDHeader, Flags: msg.Flags, InternalDate: msg.InternalDate,
|
||||||
|
CachedFrom: msg.CachedFrom, CachedSubject: msg.CachedSubject,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contacts, err := database.ListContacts(mbox.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
contactsOut := make([]contactExport, len(contacts))
|
||||||
|
for i, c := range contacts {
|
||||||
|
contactsOut[i] = contactExport{UID: c.UID, Email: c.Email, Name: c.Name, Phone: c.Phone, GivenName: c.GivenName, FamilyName: c.FamilyName, Org: c.Org}
|
||||||
|
}
|
||||||
|
if err := writeJSON(filepath.Join(mboxDir, "contacts.json"), contactsOut); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := database.ListAllEvents(mbox.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
eventsOut := make([]eventExport, len(events))
|
||||||
|
for i, e := range events {
|
||||||
|
eventsOut[i] = eventExport{UID: e.UID, Summary: e.Summary, Description: e.Description, Location: e.Location, StartAt: e.StartAt, EndAt: e.EndAt, AllDay: e.AllDay, RRule: e.RRule, Color: e.Color}
|
||||||
|
}
|
||||||
|
if err := writeJSON(filepath.Join(mboxDir, "calendar.json"), eventsOut); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := database.ListRulesForMailbox(mbox.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rulesOut := make([]ruleExport, len(rules))
|
||||||
|
for i, r := range rules {
|
||||||
|
conds, matchType := r.Conditions()
|
||||||
|
rulesOut[i] = ruleExport{Priority: r.Priority, Conditions: conds, MatchType: matchType, Name: r.Name, Action: r.Action, ActionValue: r.ActionValue, ActionOptionsJSON: r.ActionOptionsJSON}
|
||||||
|
}
|
||||||
|
if err := writeJSON(filepath.Join(mboxDir, "rules.json"), rulesOut); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sigs, err := database.ListSignatures(mbox.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sigsOut := make([]signatureExport, len(sigs))
|
||||||
|
for i, s := range sigs {
|
||||||
|
sigsOut[i] = signatureExport{Name: s.Name, ContentHTML: s.ContentHTML, IsDefaultNew: s.IsDefaultNew, IsDefaultReply: s.IsDefaultReply}
|
||||||
|
}
|
||||||
|
if err := writeJSON(filepath.Join(mboxDir, "signatures.json"), sigsOut); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return WriteServer(w, staging, passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreDomain imports an archive written by WriteDomain into domainID (an existing
|
||||||
|
// domain on this server — possibly a different server than the one it was exported
|
||||||
|
// from). A mailbox whose email address already exists anywhere on this server is
|
||||||
|
// skipped entirely (not merged) — importing into an address that already has its own
|
||||||
|
// mail/contacts/etc. is a conflict this function declines to resolve automatically;
|
||||||
|
// re-run against a domain with no overlapping addresses, or remove the conflicting
|
||||||
|
// mailbox first. Returns the list of skipped emails (nil if none) alongside any error.
|
||||||
|
func RestoreDomain(r io.Reader, database *db.DB, store *mailstore.Store, domainID int64, passphrase string) (skipped []string, err error) {
|
||||||
|
if passphrase == "" {
|
||||||
|
return nil, errors.New("a passphrase is required to import a domain export")
|
||||||
|
}
|
||||||
|
staging, err := os.MkdirTemp("", "mtadomainimport-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(staging)
|
||||||
|
|
||||||
|
if err := RestoreServer(r, staging, passphrase, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mboxRoot := filepath.Join(staging, "mailboxes")
|
||||||
|
entries, err := os.ReadDir(mboxRoot)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mboxDir := filepath.Join(mboxRoot, entry.Name())
|
||||||
|
var meta mailboxMeta
|
||||||
|
if err := readJSON(filepath.Join(mboxDir, "meta.json"), &meta); err != nil {
|
||||||
|
return skipped, fmt.Errorf("reading %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, err := database.GetMailboxByEmail(meta.Email); err != nil {
|
||||||
|
return skipped, err
|
||||||
|
} else if existing != nil {
|
||||||
|
skipped = append(skipped, meta.Email)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dek := mailstore.GenerateDEK()
|
||||||
|
wrapped, nonce, err := store.WrapDEK(dek)
|
||||||
|
if err != nil {
|
||||||
|
return skipped, err
|
||||||
|
}
|
||||||
|
mailboxID, err := database.CreateMailbox(meta.Email, meta.PasswordHash, domainID, meta.QuotaBytes, wrapped, nonce)
|
||||||
|
if err != nil {
|
||||||
|
return skipped, fmt.Errorf("creating mailbox %s: %w", meta.Email, err)
|
||||||
|
}
|
||||||
|
if meta.GroupMessages {
|
||||||
|
database.SetMailboxGroupMessages(mailboxID, true)
|
||||||
|
}
|
||||||
|
if meta.RemoteImagesMode != "" {
|
||||||
|
database.SetMailboxRemoteImagesMode(mailboxID, meta.RemoteImagesMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesDir := filepath.Join(mboxDir, "messages")
|
||||||
|
if folderEntries, err := os.ReadDir(messagesDir); err == nil {
|
||||||
|
for _, fe := range folderEntries {
|
||||||
|
if !fe.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
folder := fe.Name()
|
||||||
|
folderDir := filepath.Join(messagesDir, folder)
|
||||||
|
files, err := os.ReadDir(folderDir)
|
||||||
|
if err != nil {
|
||||||
|
return skipped, err
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if !strings.HasSuffix(f.Name(), ".eml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := strings.TrimSuffix(f.Name(), ".eml")
|
||||||
|
raw, err := os.ReadFile(filepath.Join(folderDir, base+".eml"))
|
||||||
|
if err != nil {
|
||||||
|
return skipped, err
|
||||||
|
}
|
||||||
|
var msgMeta messageMeta
|
||||||
|
if err := readJSON(filepath.Join(folderDir, base+".json"), &msgMeta); err != nil {
|
||||||
|
return skipped, err
|
||||||
|
}
|
||||||
|
uid, err := store.StoreMessageWithDate(mailboxID, folder, raw, msgMeta.MessageIDHeader, msgMeta.CachedFrom, msgMeta.CachedSubject, msgMeta.InternalDate)
|
||||||
|
if err != nil {
|
||||||
|
return skipped, fmt.Errorf("storing message for %s: %w", meta.Email, err)
|
||||||
|
}
|
||||||
|
if msgMeta.Flags != "" {
|
||||||
|
database.SetMessageFlags(mailboxID, uid, msgMeta.Flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var contacts []contactExport
|
||||||
|
readJSON(filepath.Join(mboxDir, "contacts.json"), &contacts)
|
||||||
|
for _, c := range contacts {
|
||||||
|
database.CreateContactWithUID(mailboxID, c.UID, c.Email, c.Name, c.Phone, c.GivenName, c.FamilyName, c.Org)
|
||||||
|
}
|
||||||
|
|
||||||
|
var events []eventExport
|
||||||
|
readJSON(filepath.Join(mboxDir, "calendar.json"), &events)
|
||||||
|
for _, e := range events {
|
||||||
|
database.CreateEventWithUID(mailboxID, e.UID, e.Summary, e.Description, e.Location, e.StartAt, e.EndAt, e.AllDay, e.RRule, e.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rules []ruleExport
|
||||||
|
readJSON(filepath.Join(mboxDir, "rules.json"), &rules)
|
||||||
|
for _, ru := range rules {
|
||||||
|
database.CreateRuleMulti(mailboxID, ru.Priority, ru.Conditions, ru.MatchType, ru.Name, ru.Action, ru.ActionValue, ru.ActionOptionsJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sigs []signatureExport
|
||||||
|
readJSON(filepath.Join(mboxDir, "signatures.json"), &sigs)
|
||||||
|
for _, sg := range sigs {
|
||||||
|
sigID, err := database.CreateSignature(mailboxID, sg.Name, sg.ContentHTML)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sg.IsDefaultNew {
|
||||||
|
database.SetDefaultSignature(mailboxID, sigID, false)
|
||||||
|
}
|
||||||
|
if sg.IsDefaultReply {
|
||||||
|
database.SetDefaultSignature(mailboxID, sigID, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return skipped, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/mailstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestDomainStore(t *testing.T) (*db.DB, *mailstore.Store, int64) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
store := mailstore.New(database, mailstore.GenerateDEK(), filepath.Join(dir, "mailstore"))
|
||||||
|
|
||||||
|
domainID, err := database.CreateDomain("example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return database, store, domainID
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedMailbox(t *testing.T, database *db.DB, store *mailstore.Store, domainID int64, email string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
hash, err := db.HashPassword("test-password-123!")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dek := mailstore.GenerateDEK()
|
||||||
|
wrapped, nonce, err := store.WrapDEK(dek)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mailboxID, err := database.CreateMailbox(email, hash, domainID, 5*1024*1024*1024, wrapped, nonce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.StoreMessage(mailboxID, "INBOX", []byte("From: sender@example.org\r\nSubject: hi\r\n\r\nbody text"), "<msg1@example.org>", "sender@example.org", "hi"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.CreateContactWithUID(mailboxID, "contact-uid-1", "friend@example.org", "A Friend", "555-1234", "A", "Friend", "Acme"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.CreateEventWithUID(mailboxID, "event-uid-1", "Meeting", "desc", "Room 1", time.Now(), time.Now().Add(time.Hour), false, "", "#ff0000"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.CreateRuleMulti(mailboxID, 0, []db.RuleCondition{{Field: "from", Op: "contains", Value: "example.org"}}, "all", "my rule", "move_to_folder", "Important", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.CreateSignature(mailboxID, "Default", "<p>Best,<br>Me</p>"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return mailboxID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteRestoreDomainRoundTrip(t *testing.T) {
|
||||||
|
srcDB, srcStore, domainID := newTestDomainStore(t)
|
||||||
|
mailboxID := seedMailbox(t, srcDB, srcStore, domainID, "alice@example.com")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteDomain(&buf, srcDB, srcStore, domainID, "export-passphrase"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dstDB, dstStore, dstDomainID := newTestDomainStore(t)
|
||||||
|
skipped, err := RestoreDomain(&buf, dstDB, dstStore, dstDomainID, "export-passphrase")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(skipped) != 0 {
|
||||||
|
t.Fatalf("expected nothing skipped on a fresh target, got %v", skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := dstDB.GetMailboxByEmail("alice@example.com")
|
||||||
|
if err != nil || restored == nil {
|
||||||
|
t.Fatalf("restored mailbox not found: %v", err)
|
||||||
|
}
|
||||||
|
if restored.DomainID != dstDomainID {
|
||||||
|
t.Fatalf("restored mailbox landed in domain %d, want %d", restored.DomainID, dstDomainID)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := dstDB.ListMessagesInFolder(restored.ID, "INBOX")
|
||||||
|
if err != nil || len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 restored message, got %d (err=%v)", len(msgs), err)
|
||||||
|
}
|
||||||
|
raw, err := dstStore.FetchMessage(restored.ID, msgs[0].ID)
|
||||||
|
if err != nil || !bytes.Contains(raw, []byte("body text")) {
|
||||||
|
t.Fatalf("restored message content wrong: %q, err=%v", raw, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contacts, err := dstDB.ListContacts(restored.ID)
|
||||||
|
if err != nil || len(contacts) != 1 || contacts[0].Email != "friend@example.org" {
|
||||||
|
t.Fatalf("contacts = %+v, err=%v", contacts, err)
|
||||||
|
}
|
||||||
|
events, err := dstDB.ListAllEvents(restored.ID)
|
||||||
|
if err != nil || len(events) != 1 || events[0].Summary != "Meeting" {
|
||||||
|
t.Fatalf("events = %+v, err=%v", events, err)
|
||||||
|
}
|
||||||
|
rules, err := dstDB.ListRulesForMailbox(restored.ID)
|
||||||
|
if err != nil || len(rules) != 1 || rules[0].Action != "move_to_folder" {
|
||||||
|
t.Fatalf("rules = %+v, err=%v", rules, err)
|
||||||
|
}
|
||||||
|
sigs, err := dstDB.ListSignatures(restored.ID)
|
||||||
|
if err != nil || len(sigs) != 1 || sigs[0].Name != "Default" {
|
||||||
|
t.Fatalf("signatures = %+v, err=%v", sigs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = mailboxID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreDomainSkipsExistingEmail(t *testing.T) {
|
||||||
|
srcDB, srcStore, domainID := newTestDomainStore(t)
|
||||||
|
seedMailbox(t, srcDB, srcStore, domainID, "bob@example.com")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteDomain(&buf, srcDB, srcStore, domainID, "pw"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dstDB, dstStore, dstDomainID := newTestDomainStore(t)
|
||||||
|
// bob@example.com already exists on the target server (perhaps in a different
|
||||||
|
// domain even) — restore must skip it, not error out or clobber it.
|
||||||
|
seedMailbox(t, dstDB, dstStore, dstDomainID, "bob@example.com")
|
||||||
|
|
||||||
|
skipped, err := RestoreDomain(&buf, dstDB, dstStore, dstDomainID, "pw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(skipped) != 1 || skipped[0] != "bob@example.com" {
|
||||||
|
t.Fatalf("expected bob@example.com to be reported skipped, got %v", skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteDomainRequiresPassphrase(t *testing.T) {
|
||||||
|
database, store, domainID := newTestDomainStore(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteDomain(&buf, database, store, domainID, ""); err == nil {
|
||||||
|
t.Fatal("expected an error exporting a domain with no passphrase")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/mailstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteMailboxZip streams mailboxID's own data — messages (decrypted), contacts,
|
||||||
|
// calendar events, filter rules, and signatures — as a plain (unencrypted) zip to w.
|
||||||
|
// Unlike WriteDomain, this is a GDPR-style self-service "download my data" export: it
|
||||||
|
// never leaves the process in transit (streamed directly to the owner's own HTTP
|
||||||
|
// request), so there's no passphrase/re-encryption step, and it dumps the real DB
|
||||||
|
// model structs as JSON directly rather than WriteDomain's re-import-shaped ones —
|
||||||
|
// this is meant to be a human-readable/portable record, not something this specific
|
||||||
|
// schema needs to parse back in.
|
||||||
|
func WriteMailboxZip(w io.Writer, database *db.DB, store *mailstore.Store, mailboxID int64) error {
|
||||||
|
mbox, err := database.GetMailboxByID(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if mbox == nil {
|
||||||
|
return fmt.Errorf("mailbox %d not found", mailboxID)
|
||||||
|
}
|
||||||
|
|
||||||
|
zw := zip.NewWriter(w)
|
||||||
|
defer zw.Close()
|
||||||
|
|
||||||
|
writeJSONEntry := func(name string, v any) error {
|
||||||
|
f, err := zw.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = f.Write(b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
folders, err := database.AllFoldersForMailbox(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, folder := range folders {
|
||||||
|
msgs, err := database.ListMessagesInFolder(mailboxID, folder)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, msg := range msgs {
|
||||||
|
raw, err := store.FetchMessage(mailboxID, msg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch message %d in %s: %w", msg.ID, folder, err)
|
||||||
|
}
|
||||||
|
f, err := zw.Create(fmt.Sprintf("messages/%s/%d.eml", domainSanitize(folder), msg.ID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := f.Write(raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contacts, err := database.ListContacts(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeJSONEntry("contacts.json", contacts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := database.ListAllEvents(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeJSONEntry("calendar.json", events); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rules, err := database.ListRulesForMailbox(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeJSONEntry("filter_rules.json", rules); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sigs, err := database.ListSignatures(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeJSONEntry("signatures.json", sigs)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteMailboxZipContainsExpectedEntries(t *testing.T) {
|
||||||
|
database, store, domainID := newTestDomainStore(t)
|
||||||
|
mailboxID := seedMailbox(t, database, store, domainID, "zipuser@example.com")
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteMailboxZip(&buf, database, store, mailboxID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
names := map[string]*zip.File{}
|
||||||
|
for _, f := range zr.File {
|
||||||
|
names[f.Name] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []string{"contacts.json", "calendar.json", "filter_rules.json", "signatures.json"} {
|
||||||
|
if _, ok := names[want]; !ok {
|
||||||
|
t.Errorf("missing zip entry %q, got entries: %v", want, zr.File)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msgFile, ok := names["messages/INBOX/1.eml"]
|
||||||
|
if !ok {
|
||||||
|
var got []string
|
||||||
|
for name := range names {
|
||||||
|
got = append(got, name)
|
||||||
|
}
|
||||||
|
t.Fatalf("missing messages/INBOX/1.eml, got: %v", got)
|
||||||
|
}
|
||||||
|
rc, err := msgFile.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
content, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(content, []byte("body text")) {
|
||||||
|
t.Fatalf("message content = %q, want it to contain the seeded body", content)
|
||||||
|
}
|
||||||
|
|
||||||
|
contactsFile := names["contacts.json"]
|
||||||
|
rc2, _ := contactsFile.Open()
|
||||||
|
defer rc2.Close()
|
||||||
|
contactsJSON, _ := io.ReadAll(rc2)
|
||||||
|
if !bytes.Contains(contactsJSON, []byte("friend@example.org")) {
|
||||||
|
t.Fatalf("contacts.json = %s, want the seeded contact", contactsJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteMailboxZipUnknownMailbox(t *testing.T) {
|
||||||
|
database, store, _ := newTestDomainStore(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteMailboxZip(&buf, database, store, 99999); err == nil {
|
||||||
|
t.Fatal("expected an error for a nonexistent mailbox ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scheduledBackupPrefix/Ext identify files this file's own functions wrote, so listing
|
||||||
|
// and pruning never touch anything an admin drops into the same directory by hand.
|
||||||
|
const (
|
||||||
|
scheduledBackupPrefix = "scheduled-"
|
||||||
|
scheduledBackupExt = ".tar.gz"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IntervalForSchedule maps a [Backup] "schedule" setting to a duration. ok is false for
|
||||||
|
// "" (manual only — the default) or any unrecognized value.
|
||||||
|
func IntervalForSchedule(schedule string) (time.Duration, bool) {
|
||||||
|
switch schedule {
|
||||||
|
case "daily":
|
||||||
|
return 24 * time.Hour, true
|
||||||
|
case "weekly":
|
||||||
|
return 7 * 24 * time.Hour, true
|
||||||
|
case "monthly":
|
||||||
|
return 30 * 24 * time.Hour, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunScheduled snapshots the server consistently (see WriteServerConsistentExcluding;
|
||||||
|
// always unencrypted — this never leaves the server) into a fresh timestamped archive
|
||||||
|
// under backupDir, creating backupDir if needed, then deletes the oldest archives this
|
||||||
|
// function wrote beyond keep (keep <= 0 disables pruning). Returns the path written.
|
||||||
|
func RunScheduled(execer DBExecer, dataDir, dbPath, backupDir string, keep int) (string, error) {
|
||||||
|
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
excludeRelDir := ""
|
||||||
|
if rel, err := filepath.Rel(dataDir, backupDir); err == nil && !strings.HasPrefix(rel, "..") {
|
||||||
|
excludeRelDir = filepath.ToSlash(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := scheduledBackupPrefix + time.Now().UTC().Format("2006-01-02-150405") + scheduledBackupExt
|
||||||
|
finalPath := filepath.Join(backupDir, name)
|
||||||
|
// Write under a dotfile name first, then rename into place — so a backup still
|
||||||
|
// being written is never itself picked up mid-write by dueForRun/prune/listing.
|
||||||
|
tmpPath := filepath.Join(backupDir, "."+name+".tmp")
|
||||||
|
f, err := os.Create(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := WriteServerConsistentExcluding(execer, f, dataDir, dbPath, excludeRelDir, ""); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalPath, prune(backupDir, keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dueForRun reports whether enough time has passed since the newest existing
|
||||||
|
// scheduled backup in backupDir for a new one to be due. No prior backup (including an
|
||||||
|
// unreadable/missing directory) always counts as due.
|
||||||
|
func dueForRun(backupDir string, interval time.Duration) bool {
|
||||||
|
entries, err := os.ReadDir(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var newest time.Time
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.Type().IsRegular() || !strings.HasPrefix(e.Name(), scheduledBackupPrefix) || !strings.HasSuffix(e.Name(), scheduledBackupExt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.ModTime().After(newest) {
|
||||||
|
newest = info.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newest.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Since(newest) >= interval
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunScheduledIfDue calls RunScheduled only if dueForRun says enough time has elapsed
|
||||||
|
// since the last one. Returns ("", nil) when it wasn't due yet.
|
||||||
|
func RunScheduledIfDue(execer DBExecer, dataDir, dbPath, backupDir string, keep int, interval time.Duration) (string, error) {
|
||||||
|
if !dueForRun(backupDir, interval) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return RunScheduled(execer, dataDir, dbPath, backupDir, keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prune deletes the oldest scheduled-*.tar.gz files in backupDir beyond keep. keep <= 0
|
||||||
|
// disables pruning entirely (treated as "unlimited"), not "delete everything".
|
||||||
|
func prune(backupDir string, keep int) error {
|
||||||
|
if keep <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Type().IsRegular() && strings.HasPrefix(e.Name(), scheduledBackupPrefix) && strings.HasSuffix(e.Name(), scheduledBackupExt) {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names) // the embedded UTC timestamp sorts chronologically as text
|
||||||
|
for len(names) > keep {
|
||||||
|
if err := os.Remove(filepath.Join(backupDir, names[0])); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
names = names[1:]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduledBackup describes one existing archive, for listing in the admin UI.
|
||||||
|
type ScheduledBackup struct {
|
||||||
|
Name string
|
||||||
|
Size int64
|
||||||
|
ModTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListScheduled returns every scheduled-*.tar.gz in backupDir, newest first. A missing
|
||||||
|
// directory (nothing has run yet) returns an empty list, not an error.
|
||||||
|
func ListScheduled(backupDir string) ([]ScheduledBackup, error) {
|
||||||
|
entries, err := os.ReadDir(backupDir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []ScheduledBackup
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.Type().IsRegular() || !strings.HasPrefix(e.Name(), scheduledBackupPrefix) || !strings.HasSuffix(e.Name(), scheduledBackupExt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, ScheduledBackup{Name: e.Name(), Size: info.Size(), ModTime: info.ModTime()})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ModTime.After(out[j].ModTime) })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openTestSQL(t *testing.T, dbPath string) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
sqlDB, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { sqlDB.Close() })
|
||||||
|
if _, err := sqlDB.Exec(`CREATE TABLE t (v TEXT)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return sqlDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunScheduledExcludesOwnBackupDirAndPrunes(t *testing.T) {
|
||||||
|
src := seedDir(t)
|
||||||
|
dbPath := filepath.Join(src, "app.db")
|
||||||
|
sqlDB := openTestSQL(t, dbPath)
|
||||||
|
backupDir := filepath.Join(src, "backups") // nested under dataDir, like the default
|
||||||
|
|
||||||
|
var paths []string
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
path, err := RunScheduled(sqlDB, src, dbPath, backupDir, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
paths = append(paths, path)
|
||||||
|
time.Sleep(1100 * time.Millisecond) // filenames are second-precision; keep them distinct
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected pruning to leave exactly 2 backups, got %d: %v", len(entries), entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two survivors must be the two most recently written ones.
|
||||||
|
remaining := map[string]bool{}
|
||||||
|
for _, e := range entries {
|
||||||
|
remaining[e.Name()] = true
|
||||||
|
}
|
||||||
|
if !remaining[filepath.Base(paths[2])] || !remaining[filepath.Base(paths[3])] {
|
||||||
|
t.Fatalf("expected the newest two backups to survive pruning, got %v", entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The third backup must not contain the first two backup archives nested inside it
|
||||||
|
// — proves the backups directory excluded itself from its own snapshot instead of
|
||||||
|
// recursively bundling up prior backups.
|
||||||
|
f, err := os.Open(paths[3])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
restoreDst := t.TempDir()
|
||||||
|
if err := RestoreServer(f, restoreDst, "", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(restoreDst, "backups")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected no nested backups/ directory in the restored archive, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunScheduledIfDueSkipsWhenNotDue(t *testing.T) {
|
||||||
|
src := seedDir(t)
|
||||||
|
dbPath := filepath.Join(src, "app.db")
|
||||||
|
sqlDB := openTestSQL(t, dbPath)
|
||||||
|
backupDir := filepath.Join(src, "backups")
|
||||||
|
|
||||||
|
path, err := RunScheduledIfDue(sqlDB, src, dbPath, backupDir, 3, 24*time.Hour)
|
||||||
|
if err != nil || path == "" {
|
||||||
|
t.Fatalf("expected a backup on first (no prior) run, got path=%q err=%v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path2, err := RunScheduledIfDue(sqlDB, src, dbPath, backupDir, 3, 24*time.Hour)
|
||||||
|
if err != nil || path2 != "" {
|
||||||
|
t.Fatalf("expected no backup immediately after the first, got path=%q err=%v", path2, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path3, err := RunScheduledIfDue(sqlDB, src, dbPath, backupDir, 3, 0)
|
||||||
|
if err != nil || path3 == "" {
|
||||||
|
t.Fatalf("expected a backup with a zero interval (always due), got path=%q err=%v", path3, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
// Package backup implements whole-server backup/restore for server_data — the SQLite
|
||||||
|
// DB, the encrypted mailstore blobs, and the master key that unlocks them (see
|
||||||
|
// internal/mailstore for why losing that key makes every already-stored message
|
||||||
|
// permanently unrecoverable, even for admins). A backup is a tar.gz of the directory,
|
||||||
|
// optionally sealed under an admin-supplied passphrase (scrypt-derived key,
|
||||||
|
// AES-256-GCM) so the resulting file is safe to store somewhere the operator doesn't
|
||||||
|
// otherwise fully trust.
|
||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/scrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// magicPlain/magicEncrypted are the first 8 bytes of every archive this package
|
||||||
|
// writes, letting Restore tell the two forms apart (and reject anything else) without
|
||||||
|
// guessing from gzip's own magic bytes, which an encrypted blob could in principle
|
||||||
|
// collide with.
|
||||||
|
var (
|
||||||
|
magicPlain = [8]byte{'M', 'T', 'A', 'B', 'K', 'U', 'P', '0'}
|
||||||
|
magicEncrypted = [8]byte{'M', 'T', 'A', 'B', 'K', 'U', 'P', '1'}
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
scryptN = 1 << 15 // 32768 — interactive-login-strength cost, fine for an admin-initiated one-off operation
|
||||||
|
scryptR = 8
|
||||||
|
scryptP = 1
|
||||||
|
scryptKeyLen = 32 // AES-256
|
||||||
|
saltSize = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteServer tars+gzips every file under dataDir into w. If passphrase is non-empty,
|
||||||
|
// the archive is additionally sealed with a fresh scrypt-derived key — this buffers the
|
||||||
|
// full compressed archive (in a temp file, not memory) before sealing, since AES-GCM
|
||||||
|
// has no streaming mode in the standard library; the plain (no-passphrase) path streams
|
||||||
|
// directly with no buffering.
|
||||||
|
//
|
||||||
|
// ponytail: buffers the whole archive on the encrypted path. Fine for a mail server's
|
||||||
|
// server_data/ at any size this project is likely to see; move to a chunked/streaming
|
||||||
|
// AEAD construction if a deployment's mailstore grows large enough for that to matter.
|
||||||
|
func WriteServer(w io.Writer, dataDir, passphrase string) error {
|
||||||
|
return WriteServerWithOverride(w, dataDir, "", "", passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteServerExcluding behaves like WriteServer, but skips the directory at
|
||||||
|
// dataDir/excludeRelDir (a "/"-separated path relative to dataDir) entirely. Used by
|
||||||
|
// scheduled backups, whose destination directory lives inside dataDir itself (the
|
||||||
|
// default server_data/backups layout) — without this, each new backup would bundle up
|
||||||
|
// every prior backup already sitting in that directory, ballooning in size forever.
|
||||||
|
func WriteServerExcluding(w io.Writer, dataDir, excludeRelDir, passphrase string) error {
|
||||||
|
return writeServer(w, dataDir, "", "", excludeRelDir, passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBExecer is the minimal interface WriteServerConsistent needs to snapshot a live
|
||||||
|
// SQLite database via "VACUUM INTO" — satisfied directly by *sql.DB, and by
|
||||||
|
// mailgoserver/internal/db.DB, which embeds one.
|
||||||
|
type DBExecer interface {
|
||||||
|
Exec(query string, args ...any) (sql.Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteServerConsistent snapshots the database reachable through execer via SQLite's
|
||||||
|
// "VACUUM INTO" (a consistent point-in-time copy, safe even while the live server
|
||||||
|
// keeps writing to dbPath — a raw filesystem copy of a SQLite file caught mid-write
|
||||||
|
// could otherwise capture a torn, inconsistent state) and substitutes that snapshot for
|
||||||
|
// the live database file when archiving dataDir. Falls back to archiving dataDir
|
||||||
|
// exactly as WriteServer would (no substitution) if dbPath isn't actually located
|
||||||
|
// under dataDir (a non-default DATABASE_URL) — a known, accepted limitation, see this
|
||||||
|
// package's doc comment.
|
||||||
|
func WriteServerConsistent(execer DBExecer, w io.Writer, dataDir, dbPath, passphrase string) error {
|
||||||
|
return WriteServerConsistentExcluding(execer, w, dataDir, dbPath, "", passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteServerConsistentExcluding behaves like WriteServerConsistent, but additionally
|
||||||
|
// skips the directory at dataDir/excludeRelDir — see WriteServerExcluding.
|
||||||
|
func WriteServerConsistentExcluding(execer DBExecer, w io.Writer, dataDir, dbPath, excludeRelDir, passphrase string) error {
|
||||||
|
snapshot, err := os.CreateTemp("", "mtabackup-db-*.db")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
snapshotPath := snapshot.Name()
|
||||||
|
snapshot.Close()
|
||||||
|
os.Remove(snapshotPath) // VACUUM INTO refuses to write to a file that already exists
|
||||||
|
defer os.Remove(snapshotPath)
|
||||||
|
|
||||||
|
if _, err := execer.Exec(`VACUUM INTO ?`, snapshotPath); err != nil {
|
||||||
|
return fmt.Errorf("snapshot database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbRel, err := filepath.Rel(dataDir, dbPath)
|
||||||
|
if err != nil || strings.HasPrefix(dbRel, "..") {
|
||||||
|
return writeServer(w, dataDir, "", "", excludeRelDir, passphrase)
|
||||||
|
}
|
||||||
|
return writeServer(w, dataDir, filepath.ToSlash(dbRel), snapshotPath, excludeRelDir, passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteServerWithOverride behaves like WriteServer, but the file at dataDir/relPath (a
|
||||||
|
// "/"-separated path relative to dataDir) has its content read from overridePath
|
||||||
|
// instead of its own on-disk location — used to substitute a point-in-time-consistent
|
||||||
|
// database snapshot (produced via SQLite's "VACUUM INTO") for the live database file,
|
||||||
|
// which could otherwise be caught mid-write, without needing to stage a full copy of
|
||||||
|
// dataDir just to swap out one file. Pass relPath == "" to disable the substitution
|
||||||
|
// (equivalent to WriteServer).
|
||||||
|
func WriteServerWithOverride(w io.Writer, dataDir, relPath, overridePath, passphrase string) error {
|
||||||
|
return writeServer(w, dataDir, relPath, overridePath, "", passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeServer(w io.Writer, dataDir, relPath, overridePath, excludeRelDir, passphrase string) error {
|
||||||
|
if passphrase == "" {
|
||||||
|
if _, err := w.Write(magicPlain[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tarGzDir(w, dataDir, relPath, overridePath, excludeRelDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := os.CreateTemp("", "mtabackup-*.tar.gz")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(tmp.Name())
|
||||||
|
defer tmp.Close()
|
||||||
|
|
||||||
|
if err := tarGzDir(tmp, dataDir, relPath, overridePath, excludeRelDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
plaintext, err := io.ReadAll(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
salt := make([]byte, saltSize)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key, err := scrypt.Key([]byte(passphrase), salt, scryptN, scryptR, scryptP, scryptKeyLen)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
|
||||||
|
|
||||||
|
if _, err := w.Write(magicEncrypted[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(salt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(nonce); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(ciphertext)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func tarGzDir(w io.Writer, dataDir, overrideRelPath, overridePath, excludeRelDir string) error {
|
||||||
|
gzw := gzip.NewWriter(w)
|
||||||
|
tw := tar.NewWriter(gzw)
|
||||||
|
err := filepath.Walk(dataDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(dataDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
relSlash := filepath.ToSlash(rel)
|
||||||
|
if excludeRelDir != "" && (relSlash == excludeRelDir || strings.HasPrefix(relSlash, excludeRelDir+"/")) {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
readPath := path
|
||||||
|
if overrideRelPath != "" && relSlash == overrideRelPath {
|
||||||
|
path = overridePath
|
||||||
|
readPath = overridePath
|
||||||
|
if info, err = os.Stat(overridePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hdr, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hdr.Name = relSlash
|
||||||
|
if info.IsDir() {
|
||||||
|
hdr.Name += "/"
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, err := os.Open(readPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = io.Copy(tw, f)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return gzw.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreServer extracts an archive written by WriteServer into dataDir, refusing to
|
||||||
|
// overwrite a non-empty dataDir unless force is true. passphrase is required (and
|
||||||
|
// must match) for an archive that was sealed with one; ignored for a plain archive.
|
||||||
|
func RestoreServer(r io.Reader, dataDir, passphrase string, force bool) error {
|
||||||
|
entries, err := os.ReadDir(dataDir)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(entries) > 0 && !force {
|
||||||
|
return fmt.Errorf("restore target %s is not empty (pass force to overwrite)", dataDir)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var magic [8]byte
|
||||||
|
if _, err := io.ReadFull(r, magic[:]); err != nil {
|
||||||
|
return fmt.Errorf("reading archive header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tarGzReader io.Reader
|
||||||
|
switch magic {
|
||||||
|
case magicPlain:
|
||||||
|
tarGzReader = r
|
||||||
|
case magicEncrypted:
|
||||||
|
if passphrase == "" {
|
||||||
|
return errors.New("this backup is passphrase-protected; a passphrase is required to restore it")
|
||||||
|
}
|
||||||
|
salt := make([]byte, saltSize)
|
||||||
|
if _, err := io.ReadFull(r, salt); err != nil {
|
||||||
|
return fmt.Errorf("reading salt: %w", err)
|
||||||
|
}
|
||||||
|
key, err := scrypt.Key([]byte(passphrase), salt, scryptN, scryptR, scryptP, scryptKeyLen)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(r, nonce); err != nil {
|
||||||
|
return fmt.Errorf("reading nonce: %w", err)
|
||||||
|
}
|
||||||
|
ciphertext, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("wrong passphrase or corrupted backup")
|
||||||
|
}
|
||||||
|
tarGzReader = strings.NewReader(string(plaintext))
|
||||||
|
default:
|
||||||
|
return errors.New("not a recognized backup file")
|
||||||
|
}
|
||||||
|
|
||||||
|
gzr, err := gzip.NewReader(tarGzReader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening archive: %w", err)
|
||||||
|
}
|
||||||
|
defer gzr.Close()
|
||||||
|
tr := tar.NewReader(gzr)
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// filepath.Join already collapses ".." segments against dataDir, but a
|
||||||
|
// crafted "../../etc/passwd" entry could still walk outside it — reject
|
||||||
|
// anything that doesn't stay under dataDir rather than trust archive
|
||||||
|
// contents, since this may be restoring a file an admin downloaded/moved.
|
||||||
|
target := filepath.Join(dataDir, filepath.FromSlash(hdr.Name))
|
||||||
|
if !strings.HasPrefix(target, filepath.Clean(dataDir)+string(os.PathSeparator)) && target != filepath.Clean(dataDir) {
|
||||||
|
return fmt.Errorf("archive entry %q escapes the restore target", hdr.Name)
|
||||||
|
}
|
||||||
|
switch hdr.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(f, tr); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "sub", "b.key"), []byte("secret-bytes"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteRestoreServerRoundTripPlain(t *testing.T) {
|
||||||
|
src := seedDir(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteServer(&buf, src, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(t.TempDir(), "restored")
|
||||||
|
if err := RestoreServer(&buf, dst, "", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(filepath.Join(dst, "sub", "b.key"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != "secret-bytes" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteRestoreServerRoundTripEncrypted(t *testing.T) {
|
||||||
|
src := seedDir(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteServer(&buf, src, "correct horse"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(t.TempDir(), "restored")
|
||||||
|
if err := RestoreServer(bytes.NewReader(buf.Bytes()), dst, "correct horse", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(filepath.Join(dst, "a.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != "hello" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong passphrase must fail, not silently produce garbage.
|
||||||
|
if err := RestoreServer(bytes.NewReader(buf.Bytes()), filepath.Join(t.TempDir(), "wrong"), "wrong passphrase", false); err == nil {
|
||||||
|
t.Fatal("expected an error restoring with the wrong passphrase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreServerRefusesNonEmptyTargetWithoutForce(t *testing.T) {
|
||||||
|
src := seedDir(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteServer(&buf, src, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dst, "existing.txt"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := RestoreServer(&buf, dst, "", false); err == nil {
|
||||||
|
t.Fatal("expected restore into a non-empty directory to be refused without force")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteServerWithOverrideSubstitutesFileContent(t *testing.T) {
|
||||||
|
src := seedDir(t)
|
||||||
|
override := filepath.Join(t.TempDir(), "snapshot.txt")
|
||||||
|
if err := os.WriteFile(override, []byte("snapshot-content"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteServerWithOverride(&buf, src, "a.txt", override, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(t.TempDir(), "restored")
|
||||||
|
if err := RestoreServer(&buf, dst, "", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(filepath.Join(dst, "a.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != "snapshot-content" {
|
||||||
|
t.Fatalf("got %q, want the override file's content substituted in place of the live a.txt", got)
|
||||||
|
}
|
||||||
|
// The untouched sibling file should still come through unmodified.
|
||||||
|
if got, err := os.ReadFile(filepath.Join(dst, "sub", "b.key")); err != nil || string(got) != "secret-bytes" {
|
||||||
|
t.Fatalf("sub/b.key = %q, %v; expected untouched", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteServerConsistentSnapshotsLiveDatabase(t *testing.T) {
|
||||||
|
src := seedDir(t)
|
||||||
|
dbPath := filepath.Join(src, "app.db")
|
||||||
|
sqlDB, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
if _, err := sqlDB.Exec(`CREATE TABLE t (v TEXT)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := sqlDB.Exec(`INSERT INTO t (v) VALUES ('before-backup')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteServerConsistent(sqlDB, &buf, src, dbPath, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A write after the snapshot was taken must not appear in the archive — proves
|
||||||
|
// VACUUM INTO's point-in-time semantics are actually being used, not just a raw
|
||||||
|
// copy of the (possibly since-changed) live file.
|
||||||
|
if _, err := sqlDB.Exec(`INSERT INTO t (v) VALUES ('after-backup')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(t.TempDir(), "restored")
|
||||||
|
if err := RestoreServer(&buf, dst, "", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
restoredDB, err := sql.Open("sqlite", filepath.Join(dst, "app.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer restoredDB.Close()
|
||||||
|
var rows []string
|
||||||
|
res, err := restoredDB.Query(`SELECT v FROM t ORDER BY v`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer res.Close()
|
||||||
|
for res.Next() {
|
||||||
|
var v string
|
||||||
|
res.Scan(&v)
|
||||||
|
rows = append(rows, v)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0] != "before-backup" {
|
||||||
|
t.Fatalf("got %v, want exactly [\"before-backup\"] (the post-snapshot insert must not appear)", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreServerForceOverwritesNonEmptyTarget(t *testing.T) {
|
||||||
|
src := seedDir(t)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := WriteServer(&buf, src, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dst, "existing.txt"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := RestoreServer(&buf, dst, "", true); err != nil {
|
||||||
|
t.Fatalf("expected force restore to succeed, got: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dst, "a.txt")); err != nil {
|
||||||
|
t.Fatalf("expected restored file to exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,25 @@ var defaults = []struct {
|
|||||||
{"DKIM_KEY_SIZE", "2048", ""},
|
{"DKIM_KEY_SIZE", "2048", ""},
|
||||||
{"", "", "Provide Public IP address of server, used for SPF in case detection fails"},
|
{"", "", "Provide Public IP address of server, used for SPF in case detection fails"},
|
||||||
{"SPF_SERVER_IP", "192.168.1.1", ""},
|
{"SPF_SERVER_IP", "192.168.1.1", ""},
|
||||||
|
{"", "", "Show a rotation-due warning badge on the DKIM admin page once an active key reaches this age"},
|
||||||
|
{"key_age_warning_days", "180", ""},
|
||||||
|
{"", "", "-- Shared/global DKIM key (CNAME delegation) --"},
|
||||||
|
{"", "", "Hostname the shared DKIM key's TXT record is published under - what a domain's"},
|
||||||
|
{"", "", "one-time CNAME points at. Leave blank to keep global DKIM unavailable."},
|
||||||
|
{"global_dkim_hostname", "", ""},
|
||||||
|
{"", "", "Auto-publish the shared key's TXT record via a DNS provider API on regenerate:"},
|
||||||
|
{"", "", "cloudflare, route53, digitalocean, gcloud. Leave blank to publish manually instead."},
|
||||||
|
{"global_dkim_provider", "", ""},
|
||||||
|
{"global_dkim_cloudflare_api_token", "", ""},
|
||||||
|
{"global_dkim_route53_access_key_id", "", ""},
|
||||||
|
{"global_dkim_route53_secret_access_key", "", ""},
|
||||||
|
{"global_dkim_route53_region", "", ""},
|
||||||
|
{"global_dkim_digitalocean_api_token", "", ""},
|
||||||
|
{"global_dkim_gcloud_project", "", ""},
|
||||||
|
{"global_dkim_gcloud_service_account_json", "", ""},
|
||||||
|
{"", "", "Auto-regenerate the shared key every N days (rotates every opted-in domain at"},
|
||||||
|
{"", "", "once). Blank/0 = manual only (default) - use the Regenerate button instead."},
|
||||||
|
{"global_dkim_rotation_days", "", ""},
|
||||||
}},
|
}},
|
||||||
{"Attachments", []defaultKV{
|
{"Attachments", []defaultKV{
|
||||||
{"", "", "Directory where stored message attachments are written (fixed: missing in the Python defaults)"},
|
{"", "", "Directory where stored message attachments are written (fixed: missing in the Python defaults)"},
|
||||||
@@ -123,6 +142,10 @@ var defaults = []struct {
|
|||||||
{"", "", "First-offense block duration; doubles on each repeat offense up to the cap below"},
|
{"", "", "First-offense block duration; doubles on each repeat offense up to the cap below"},
|
||||||
{"abuse_blacklist_base_hours", "12", ""},
|
{"abuse_blacklist_base_hours", "12", ""},
|
||||||
{"abuse_blacklist_max_hours", "168", ""},
|
{"abuse_blacklist_max_hours", "168", ""},
|
||||||
|
{"", "", "Reject a new SMTP/IMAP connection once this many are already open from the same"},
|
||||||
|
{"", "", "source IP — a resource-exhaustion guard, separate from the failed-auth blacklist"},
|
||||||
|
{"", "", "above (a connection flood doesn't need to fail auth to hurt)"},
|
||||||
|
{"max_connections_per_ip", "20", ""},
|
||||||
}},
|
}},
|
||||||
{"IMAP", []defaultKV{
|
{"IMAP", []defaultKV{
|
||||||
{"", "", "IMAP server configuration for mailbox retrieval (Thunderbird, etc.)"},
|
{"", "", "IMAP server configuration for mailbox retrieval (Thunderbird, etc.)"},
|
||||||
@@ -145,6 +168,21 @@ var defaults = []struct {
|
|||||||
{"app_password_min_length", "25", ""},
|
{"app_password_min_length", "25", ""},
|
||||||
{"", "", "Reject messages scoring at or above this built-in heuristic spam score"},
|
{"", "", "Reject messages scoring at or above this built-in heuristic spam score"},
|
||||||
{"spam_reject_score", "5", ""},
|
{"spam_reject_score", "5", ""},
|
||||||
|
{"", "", "Quarantine inbound mail with a failing/missing DKIM signature to Junk"},
|
||||||
|
{"enforce_dkim", "true", ""},
|
||||||
|
{"", "", "Quarantine inbound mail that fails SPF to Junk"},
|
||||||
|
{"enforce_spf", "true", ""},
|
||||||
|
{"", "", "Enforce the From: domain's published DMARC policy (reject/quarantine) on inbound mail"},
|
||||||
|
{"enforce_dmarc", "true", ""},
|
||||||
|
{"", "", "Reject an inbound SMTP message larger than this many bytes (25 MiB)"},
|
||||||
|
{"max_message_bytes", "26214400", ""},
|
||||||
|
{"", "", "Reject an inbound SMTP message naming more than this many recipients in one envelope"},
|
||||||
|
{"max_recipients", "100", ""},
|
||||||
|
{"", "", "Scan inbound mail for viruses via an external clamd instance (off by default;"},
|
||||||
|
{"", "", "requires ClamAV's clamd running separately — this does not install/manage it)"},
|
||||||
|
{"virus_scan_enabled", "false", ""},
|
||||||
|
{"", "", "Address of the clamd instance to scan through, if enabled above"},
|
||||||
|
{"clamd_address", "127.0.0.1:3310", ""},
|
||||||
}},
|
}},
|
||||||
{"Rspamd", []defaultKV{
|
{"Rspamd", []defaultKV{
|
||||||
{"", "", "Optional rspamd integration for spam scoring (off by default; the built-in"},
|
{"", "", "Optional rspamd integration for spam scoring (off by default; the built-in"},
|
||||||
@@ -155,6 +193,17 @@ var defaults = []struct {
|
|||||||
{"", "", "Reject messages rspamd scores at or above this threshold"},
|
{"", "", "Reject messages rspamd scores at or above this threshold"},
|
||||||
{"reject_score", "15", ""},
|
{"reject_score", "15", ""},
|
||||||
}},
|
}},
|
||||||
|
{"Backup", []defaultKV{
|
||||||
|
{"", "", "Scheduled whole-server backups, written straight to a folder on this server"},
|
||||||
|
{"", "", "(separate from the on-demand 'Download whole-server backup now' button, which"},
|
||||||
|
{"", "", "streams to the browser instead). Interval: blank = manual only (default), or"},
|
||||||
|
{"", "", "daily, weekly, monthly."},
|
||||||
|
{"schedule", "", ""},
|
||||||
|
{"", "", "Folder scheduled backups are written into"},
|
||||||
|
{"dir", "server_data/backups", ""},
|
||||||
|
{"", "", "How many scheduled backups to keep - older ones are deleted automatically."},
|
||||||
|
{"keep", "3", ""},
|
||||||
|
}},
|
||||||
{"LetsEncrypt", []defaultKV{
|
{"LetsEncrypt", []defaultKV{
|
||||||
{"", "", "Let's Encrypt (ACME, DNS-01) automatic certificate configuration. This obtains a"},
|
{"", "", "Let's Encrypt (ACME, DNS-01) automatic certificate configuration. This obtains a"},
|
||||||
{"", "", "separate certificate from [LetsEncryptHTTP] below - assign each independently to"},
|
{"", "", "separate certificate from [LetsEncryptHTTP] below - assign each independently to"},
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func TestAddAllowBlockJunkEntryWorksAfterMigration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { database.Close() })
|
t.Cleanup(func() { database.Close() })
|
||||||
|
|
||||||
if _, err := database.AddAllowBlockEntry(1, "junk", "spammer@example.com"); err != nil {
|
if _, err := database.AddAllowBlockEntry(1, "junk", "spammer@example.com", "all"); err != nil {
|
||||||
t.Fatalf("AddAllowBlockEntry with 'junk' after migrating a legacy DB: %v", err)
|
t.Fatalf("AddAllowBlockEntry with 'junk' after migrating a legacy DB: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateEventUID returns a random 32-hex-char stable resource identity for an
|
||||||
|
// event's CalDAV path — same randomness/format as generateContactUID/
|
||||||
|
// generateVerificationToken.
|
||||||
|
func generateEventUID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarEventColumns = `e.id, e.mailbox_id, e.uid, e.summary, e.description, e.location, e.start_at, e.end_at, e.all_day, e.rrule, e.color, e.created_at, e.updated_at, r.minutes_before`
|
||||||
|
|
||||||
|
// calendarEventSelect is shared by every read path — a LEFT JOIN against the
|
||||||
|
// at-most-one-row reminders table, so callers get ReminderMinutes for free instead of
|
||||||
|
// a second query per event.
|
||||||
|
const calendarEventSelect = `SELECT ` + calendarEventColumns + `
|
||||||
|
FROM esrv_mailbox_calendar_events e
|
||||||
|
LEFT JOIN esrv_mailbox_calendar_reminders r ON r.event_id = e.id`
|
||||||
|
|
||||||
|
func scanCalendarEvent(scan func(dest ...any) error) (CalendarEvent, error) {
|
||||||
|
var e CalendarEvent
|
||||||
|
var startAt, endAt, createdAt, updatedAt string
|
||||||
|
var reminderMinutes sql.NullInt64
|
||||||
|
err := scan(&e.ID, &e.MailboxID, &e.UID, &e.Summary, &e.Description, &e.Location,
|
||||||
|
&startAt, &endAt, &e.AllDay, &e.RRule, &e.Color, &createdAt, &updatedAt, &reminderMinutes)
|
||||||
|
if err != nil {
|
||||||
|
return e, err
|
||||||
|
}
|
||||||
|
e.StartAt, _ = parseTime(startAt)
|
||||||
|
e.EndAt, _ = parseTime(endAt)
|
||||||
|
e.CreatedAt, _ = parseTime(createdAt)
|
||||||
|
e.UpdatedAt, _ = parseTime(updatedAt)
|
||||||
|
if reminderMinutes.Valid {
|
||||||
|
m := int(reminderMinutes.Int64)
|
||||||
|
e.ReminderMinutes = &m
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllEvents returns every event for a mailbox, alphabetical-free (chronological) —
|
||||||
|
// used by CalDAV's non-time-range ListCalendarObjects.
|
||||||
|
func (d *DB) ListAllEvents(mailboxID int64) ([]CalendarEvent, error) {
|
||||||
|
rows, err := d.Query(calendarEventSelect+` WHERE e.mailbox_id = ? ORDER BY e.start_at`, mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []CalendarEvent
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanCalendarEvent(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEventsInRange returns every non-recurring event overlapping [start,end), plus
|
||||||
|
// every recurring event regardless of its own start_at/end_at — occurrence expansion
|
||||||
|
// for a recurring event happens in Go (see expandOccurrences), not SQL, so a recurring
|
||||||
|
// event whose own start_at predates the queried range but still produces occurrences
|
||||||
|
// inside it (a weekly meeting that started months ago) isn't missed.
|
||||||
|
func (d *DB) ListEventsInRange(mailboxID int64, start, end time.Time) ([]CalendarEvent, error) {
|
||||||
|
rows, err := d.Query(calendarEventSelect+`
|
||||||
|
WHERE e.mailbox_id = ? AND (e.rrule != '' OR (e.start_at < ? AND e.end_at > ?))
|
||||||
|
ORDER BY e.start_at`,
|
||||||
|
mailboxID, end.UTC().Format("2006-01-02 15:04:05"), start.UTC().Format("2006-01-02 15:04:05"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []CalendarEvent
|
||||||
|
for rows.Next() {
|
||||||
|
e, err := scanCalendarEvent(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEventByID scopes the lookup to mailboxID, same discipline as GetContactByID.
|
||||||
|
func (d *DB) GetEventByID(mailboxID, id int64) (*CalendarEvent, error) {
|
||||||
|
row := d.QueryRow(calendarEventSelect+` WHERE e.id = ? AND e.mailbox_id = ?`, id, mailboxID)
|
||||||
|
e, err := scanCalendarEvent(row.Scan)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEventByUID is GetEventByID keyed by the CalDAV path's uid segment instead of the
|
||||||
|
// numeric id — mirrors GetContactByUID.
|
||||||
|
func (d *DB) GetEventByUID(mailboxID int64, uid string) (*CalendarEvent, error) {
|
||||||
|
row := d.QueryRow(calendarEventSelect+` WHERE e.uid = ? AND e.mailbox_id = ?`, uid, mailboxID)
|
||||||
|
e, err := scanCalendarEvent(row.Scan)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEvent inserts a new event with a freshly generated uid, returning its id.
|
||||||
|
func (d *DB) CreateEvent(mailboxID int64, summary, description, location string, startAt, endAt time.Time, allDay bool, rrule, color string) (int64, error) {
|
||||||
|
return d.CreateEventWithUID(mailboxID, generateEventUID(), summary, description, location, startAt, endAt, allDay, rrule, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEventWithUID is CreateEvent with a caller-chosen uid — needed for CalDAV's
|
||||||
|
// PutCalendarObject, where the client (not this server) picks the new event's
|
||||||
|
// resource identity via its PUT request path, same reasoning as
|
||||||
|
// CreateContactWithUID.
|
||||||
|
func (d *DB) CreateEventWithUID(mailboxID int64, uid, summary, description, location string, startAt, endAt time.Time, allDay bool, rrule, color string) (int64, error) {
|
||||||
|
res, err := d.Exec(`INSERT INTO esrv_mailbox_calendar_events
|
||||||
|
(mailbox_id, uid, summary, description, location, start_at, end_at, all_day, rrule, color, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||||
|
mailboxID, uid, summary, description, location, startAt, endAt, allDay, rrule, color)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateEvent(mailboxID, id int64, summary, description, location string, startAt, endAt time.Time, allDay bool, rrule, color string) error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_mailbox_calendar_events
|
||||||
|
SET summary = ?, description = ?, location = ?, start_at = ?, end_at = ?, all_day = ?, rrule = ?, color = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND mailbox_id = ?`,
|
||||||
|
summary, description, location, startAt, endAt, allDay, rrule, color, id, mailboxID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEventReminder replaces eventID's reminder (delete-then-insert keeps the "at most
|
||||||
|
// one reminder row" invariant without a DB constraint) — pass nil to clear it.
|
||||||
|
func (d *DB) SetEventReminder(eventID int64, minutesBefore *int) error {
|
||||||
|
if _, err := d.Exec(`DELETE FROM esrv_mailbox_calendar_reminders WHERE event_id = ?`, eventID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if minutesBefore == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := d.Exec(`INSERT INTO esrv_mailbox_calendar_reminders (event_id, minutes_before) VALUES (?, ?)`, eventID, *minutesBefore)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteEvent(mailboxID, id int64) error {
|
||||||
|
if _, err := d.Exec(`DELETE FROM esrv_mailbox_calendar_reminders WHERE event_id = ?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := d.Exec(`DELETE FROM esrv_mailbox_calendar_events WHERE id = ? AND mailbox_id = ?`, id, mailboxID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEventByUID mirrors DeleteEvent, keyed by uid instead of the numeric id — what
|
||||||
|
// CalDAV's DeleteCalendarObject has (the path's uid segment), not an id.
|
||||||
|
func (d *DB) DeleteEventByUID(mailboxID int64, uid string) error {
|
||||||
|
e, err := d.GetEventByUID(mailboxID, uid)
|
||||||
|
if err != nil || e == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return d.DeleteEvent(mailboxID, e.ID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalendarEventCreateUpdateDelete(t *testing.T) {
|
||||||
|
d := openTestDB(t)
|
||||||
|
const mailboxID = int64(1)
|
||||||
|
|
||||||
|
start := time.Date(2026, 3, 10, 14, 0, 0, 0, time.UTC)
|
||||||
|
end := start.Add(time.Hour)
|
||||||
|
id, err := d.CreateEvent(mailboxID, "Standup", "daily sync", "Room 1", start, end, false, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := d.GetEventByID(mailboxID, id)
|
||||||
|
if err != nil || got == nil || got.Summary != "Standup" || got.UID == "" {
|
||||||
|
t.Fatalf("expected created event with a generated uid, got %+v (err=%v)", got, err)
|
||||||
|
}
|
||||||
|
if got.ReminderMinutes != nil {
|
||||||
|
t.Fatalf("expected no reminder by default, got %+v", got.ReminderMinutes)
|
||||||
|
}
|
||||||
|
if got.Color != "" {
|
||||||
|
t.Fatalf("expected no color stored by default, got %q", got.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
newStart := start.Add(24 * time.Hour)
|
||||||
|
if err := d.UpdateEvent(mailboxID, id, "Standup (moved)", "daily sync", "Room 2", newStart, newStart.Add(time.Hour), false, "FREQ=WEEKLY", "#ff0000"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
updated, err := d.GetEventByID(mailboxID, id)
|
||||||
|
if err != nil || updated == nil || updated.Summary != "Standup (moved)" || updated.RRule != "FREQ=WEEKLY" || updated.UID != got.UID || updated.Color != "#ff0000" {
|
||||||
|
t.Fatalf("expected updated event with uid preserved and color set, got %+v (err=%v)", updated, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := 15
|
||||||
|
if err := d.SetEventReminder(id, &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
withReminder, err := d.GetEventByID(mailboxID, id)
|
||||||
|
if err != nil || withReminder == nil || withReminder.ReminderMinutes == nil || *withReminder.ReminderMinutes != 15 {
|
||||||
|
t.Fatalf("expected reminder set to 15, got %+v (err=%v)", withReminder, err)
|
||||||
|
}
|
||||||
|
if err := d.SetEventReminder(id, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cleared, err := d.GetEventByID(mailboxID, id)
|
||||||
|
if err != nil || cleared == nil || cleared.ReminderMinutes != nil {
|
||||||
|
t.Fatalf("expected reminder cleared, got %+v (err=%v)", cleared, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.DeleteEvent(mailboxID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gone, err := d.GetEventByID(mailboxID, id)
|
||||||
|
if err != nil || gone != nil {
|
||||||
|
t.Fatalf("expected event deleted, got %+v (err=%v)", gone, err)
|
||||||
|
}
|
||||||
|
var reminderRows int
|
||||||
|
if err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_calendar_reminders`).Scan(&reminderRows); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reminderRows != 0 {
|
||||||
|
t.Fatalf("expected DeleteEvent to also remove any reminder row, got %d left", reminderRows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalendarEventUIDUniquePerMailbox(t *testing.T) {
|
||||||
|
d := openTestDB(t)
|
||||||
|
const mailboxID = int64(1)
|
||||||
|
start := time.Date(2026, 3, 10, 14, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
id, err := d.CreateEvent(mailboxID, "First", "", "", start, start.Add(time.Hour), false, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ev, _ := d.GetEventByID(mailboxID, id)
|
||||||
|
|
||||||
|
if _, err := d.CreateEventWithUID(mailboxID, ev.UID, "Duplicate uid", "", "", start, start.Add(time.Hour), false, "", ""); err == nil {
|
||||||
|
t.Fatal("expected a UNIQUE constraint error for a duplicate uid in the same mailbox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListEventsInRangeBoundaries confirms non-recurring events overlapping the
|
||||||
|
// queried window are included regardless of whether they start before, end after, or
|
||||||
|
// sit fully inside it, while an event entirely outside the window is excluded — and a
|
||||||
|
// recurring event is always included (occurrence expansion happens in Go, not SQL).
|
||||||
|
func TestListEventsInRangeBoundaries(t *testing.T) {
|
||||||
|
d := openTestDB(t)
|
||||||
|
const mailboxID = int64(1)
|
||||||
|
rangeStart := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)
|
||||||
|
rangeEnd := time.Date(2026, 3, 11, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
startsBefore := time.Date(2026, 3, 9, 23, 0, 0, 0, time.UTC)
|
||||||
|
if _, err := d.CreateEvent(mailboxID, "Starts before, overlaps", "", "", startsBefore, startsBefore.Add(2*time.Hour), false, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
insideStart := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC)
|
||||||
|
if _, err := d.CreateEvent(mailboxID, "Fully inside", "", "", insideStart, insideStart.Add(time.Hour), false, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
endsAfterStart := time.Date(2026, 3, 10, 23, 0, 0, 0, time.UTC)
|
||||||
|
if _, err := d.CreateEvent(mailboxID, "Ends after, overlaps", "", "", endsAfterStart, endsAfterStart.Add(2*time.Hour), false, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entirelyOutside := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
if _, err := d.CreateEvent(mailboxID, "Entirely outside", "", "", entirelyOutside, entirelyOutside.Add(time.Hour), false, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
longAgoRecurring := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
if _, err := d.CreateEvent(mailboxID, "Old recurring weekly", "", "", longAgoRecurring, longAgoRecurring.Add(time.Hour), false, "FREQ=WEEKLY", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := d.ListEventsInRange(mailboxID, rangeStart, rangeEnd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, e := range events {
|
||||||
|
got[e.Summary] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Starts before, overlaps", "Fully inside", "Ends after, overlaps", "Old recurring weekly"} {
|
||||||
|
if !got[want] {
|
||||||
|
t.Errorf("expected %q in range results, got %+v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got["Entirely outside"] {
|
||||||
|
t.Error("expected 'Entirely outside' excluded from range results")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRemoveDomainCascadeDeletesEverythingScoped(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
domainID, err := database.CreateDomain("gone.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := HashPassword("irrelevant")
|
||||||
|
if _, err := database.CreateSender("sender@gone.example", hash, domainID, false, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.CreateWhitelistedIP("203.0.113.1", domainID, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.Exec(`INSERT INTO esrv_dkim_keys (domain_id, selector, private_key, public_key) VALUES (?, 'sel1', 'priv', 'pub')`, domainID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
senders, ips, dkimKeys, headers, err := database.RemoveDomainCascade(domainID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if senders != 1 || ips != 1 || dkimKeys != 1 || headers != 0 {
|
||||||
|
t.Fatalf("got senders=%d ips=%d dkimKeys=%d headers=%d, want 1,1,1,0", senders, ips, dkimKeys, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
if dom, err := database.GetDomainByID(domainID); err != nil || dom != nil {
|
||||||
|
t.Fatalf("expected the domain row itself to be gone, got %+v (err=%v)", dom, err)
|
||||||
|
}
|
||||||
|
var senderCount int
|
||||||
|
database.QueryRow(`SELECT COUNT(*) FROM esrv_senders WHERE domain_id = ?`, domainID).Scan(&senderCount)
|
||||||
|
if senderCount != 0 {
|
||||||
|
t.Fatalf("expected the sender row to be gone, found %d", senderCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeactivateActiveDKIMKeysForDomain(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
domainID, err := database.CreateDomain("example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := database.Exec(`INSERT INTO esrv_dkim_keys (domain_id, selector, private_key, public_key, is_active) VALUES (?, 'sel1', 'priv', 'pub', 1)`, domainID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keyID, _ := res.LastInsertId()
|
||||||
|
|
||||||
|
if err := database.DeactivateActiveDKIMKeysForDomain(domainID, "2026-01-01 00:00:00"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := database.GetDKIMKeyByID(keyID)
|
||||||
|
if err != nil || key == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if key.IsActive {
|
||||||
|
t.Fatal("expected the key to be deactivated")
|
||||||
|
}
|
||||||
|
if key.ReplacedAt == nil {
|
||||||
|
t.Fatal("expected replaced_at to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkAllReadInFolder(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
const mailboxID = int64(1)
|
||||||
|
uid1, err := database.InsertMessage(mailboxID, "INBOX", "", "", time.Now(), 10, "/dev/null", []byte("nonce"), "a@x.com", "me@x.com", "hi", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
uid2, err := database.InsertMessage(mailboxID, "INBOX", "", `\Flagged`, time.Now(), 10, "/dev/null", []byte("nonce"), "b@x.com", "me@x.com", "hi2", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.MarkAllReadInFolder(mailboxID, "INBOX"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m1, err := database.GetMessageByUID(mailboxID, uid1)
|
||||||
|
if err != nil || m1 == nil || !strings.Contains(m1.Flags, `\Seen`) {
|
||||||
|
t.Fatalf("expected message 1 marked \\Seen, got %+v (err=%v)", m1, err)
|
||||||
|
}
|
||||||
|
m2, err := database.GetMessageByUID(mailboxID, uid2)
|
||||||
|
if err != nil || m2 == nil || !strings.Contains(m2.Flags, `\Seen`) || !isStarred(m2.Flags) {
|
||||||
|
t.Fatalf("expected message 2 marked \\Seen while keeping \\Flagged, got %+v (err=%v)", m2, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToggleMessageStarred(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
const mailboxID = int64(1)
|
||||||
|
uid, err := database.InsertMessage(mailboxID, "INBOX", "", "", time.Now(), 10, "/dev/null", []byte("nonce"), "a@x.com", "me@x.com", "hi", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.ToggleMessageStarred(mailboxID, uid); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msg, err := database.GetMessageByUID(mailboxID, uid)
|
||||||
|
if err != nil || msg == nil || !isStarred(msg.Flags) {
|
||||||
|
t.Fatalf("expected \\Flagged after first toggle, got %+v (err=%v)", msg, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.ToggleMessageStarred(mailboxID, uid); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msg, err = database.GetMessageByUID(mailboxID, uid)
|
||||||
|
if err != nil || msg == nil || isStarred(msg.Flags) {
|
||||||
|
t.Fatalf("expected \\Flagged cleared after second toggle, got %+v (err=%v)", msg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DomainDNSCredentials holds one domain's DNS provider credentials, used to
|
||||||
|
// auto-publish its DKIM TXT record when dkim_dns_automation is "automatic" — see
|
||||||
|
// internal/dnspublish for what each field means and schema.go's
|
||||||
|
// esrv_domain_dns_credentials comment for the storage precedent (plaintext, matching
|
||||||
|
// [LetsEncrypt]'s own DNS-01 credentials).
|
||||||
|
type DomainDNSCredentials struct {
|
||||||
|
DomainID int64
|
||||||
|
Provider string
|
||||||
|
ZoneName string
|
||||||
|
CloudflareAPIToken string
|
||||||
|
Route53AccessKeyID string
|
||||||
|
Route53SecretAccessKey string
|
||||||
|
Route53Region string
|
||||||
|
DigitalOceanAPIToken string
|
||||||
|
GCloudProject string
|
||||||
|
GCloudServiceAccountJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainDNSCredentialsColumns = `domain_id, provider, zone_name, cloudflare_api_token, route53_access_key_id, route53_secret_access_key, route53_region, digitalocean_api_token, gcloud_project, gcloud_service_account_json`
|
||||||
|
|
||||||
|
// GetDomainDNSCredentials returns nil (not an error) if the domain has none configured.
|
||||||
|
func (d *DB) GetDomainDNSCredentials(domainID int64) (*DomainDNSCredentials, error) {
|
||||||
|
row := d.QueryRow(`SELECT `+domainDNSCredentialsColumns+` FROM esrv_domain_dns_credentials WHERE domain_id = ?`, domainID)
|
||||||
|
var c DomainDNSCredentials
|
||||||
|
if err := row.Scan(&c.DomainID, &c.Provider, &c.ZoneName, &c.CloudflareAPIToken,
|
||||||
|
&c.Route53AccessKeyID, &c.Route53SecretAccessKey, &c.Route53Region,
|
||||||
|
&c.DigitalOceanAPIToken, &c.GCloudProject, &c.GCloudServiceAccountJSON); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDomainDNSCredentials creates or replaces the one credentials row for
|
||||||
|
// c.DomainID (upsert, keyed on the primary key).
|
||||||
|
func (d *DB) SetDomainDNSCredentials(c DomainDNSCredentials) error {
|
||||||
|
_, err := d.Exec(`INSERT INTO esrv_domain_dns_credentials (`+domainDNSCredentialsColumns+`)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(domain_id) DO UPDATE SET
|
||||||
|
provider = excluded.provider,
|
||||||
|
zone_name = excluded.zone_name,
|
||||||
|
cloudflare_api_token = excluded.cloudflare_api_token,
|
||||||
|
route53_access_key_id = excluded.route53_access_key_id,
|
||||||
|
route53_secret_access_key = excluded.route53_secret_access_key,
|
||||||
|
route53_region = excluded.route53_region,
|
||||||
|
digitalocean_api_token = excluded.digitalocean_api_token,
|
||||||
|
gcloud_project = excluded.gcloud_project,
|
||||||
|
gcloud_service_account_json = excluded.gcloud_service_account_json`,
|
||||||
|
c.DomainID, c.Provider, c.ZoneName, c.CloudflareAPIToken,
|
||||||
|
c.Route53AccessKeyID, c.Route53SecretAccessKey, c.Route53Region,
|
||||||
|
c.DigitalOceanAPIToken, c.GCloudProject, c.GCloudServiceAccountJSON)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) DeleteDomainDNSCredentials(domainID int64) error {
|
||||||
|
_, err := d.Exec(`DELETE FROM esrv_domain_dns_credentials WHERE domain_id = ?`, domainID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDomainDNSCredentialsRoundTrip(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
domainID, err := database.CreateDomain("dnsauto.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, err := database.GetDomainDNSCredentials(domainID); err != nil || got != nil {
|
||||||
|
t.Fatalf("expected no credentials configured yet, got %+v (err=%v)", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := DomainDNSCredentials{
|
||||||
|
DomainID: domainID, Provider: "cloudflare", ZoneName: "dnsauto.example",
|
||||||
|
CloudflareAPIToken: "tok1",
|
||||||
|
}
|
||||||
|
if err := database.SetDomainDNSCredentials(creds); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := database.GetDomainDNSCredentials(domainID)
|
||||||
|
if err != nil || got == nil {
|
||||||
|
t.Fatalf("expected credentials to round-trip, got %+v (err=%v)", got, err)
|
||||||
|
}
|
||||||
|
if got.Provider != "cloudflare" || got.CloudflareAPIToken != "tok1" {
|
||||||
|
t.Fatalf("unexpected credentials: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert: setting again with a different provider replaces, not duplicates.
|
||||||
|
creds.Provider = "route53"
|
||||||
|
creds.Route53AccessKeyID = "AKIA123"
|
||||||
|
creds.CloudflareAPIToken = ""
|
||||||
|
if err := database.SetDomainDNSCredentials(creds); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err = database.GetDomainDNSCredentials(domainID)
|
||||||
|
if err != nil || got == nil || got.Provider != "route53" || got.Route53AccessKeyID != "AKIA123" {
|
||||||
|
t.Fatalf("expected upsert to replace the row, got %+v (err=%v)", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DeleteDomainDNSCredentials(domainID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := database.GetDomainDNSCredentials(domainID); err != nil || got != nil {
|
||||||
|
t.Fatalf("expected credentials gone after delete, got %+v (err=%v)", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetDomainDKIMDNSAutomation(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
domainID, err := database.CreateDomain("auto.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dom, err := database.GetDomainByID(domainID)
|
||||||
|
if err != nil || dom == nil || dom.DKIMDNSAutomation != "manual" {
|
||||||
|
t.Fatalf("expected dkim_dns_automation to default to manual, got %+v (err=%v)", dom, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.SetDomainDKIMDNSAutomation(domainID, "automatic"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dom, err = database.GetDomainByID(domainID)
|
||||||
|
if err != nil || dom == nil || dom.DKIMDNSAutomation != "automatic" {
|
||||||
|
t.Fatalf("expected dkim_dns_automation to be automatic, got %+v (err=%v)", dom, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -17,7 +18,9 @@ func (d *DB) ListDomains() ([]Domain, error) {
|
|||||||
var dm Domain
|
var dm Domain
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var verifiedAt *string
|
var verifiedAt *string
|
||||||
if err := rows.Scan(&dm.ID, &dm.DomainName, &dm.IsActive, &createdAt, &dm.VerificationToken, &dm.IsVerified, &verifiedAt, &dm.MFAExempt); err != nil {
|
var catchall sql.NullInt64
|
||||||
|
var rateLimit sql.NullInt64
|
||||||
|
if err := rows.Scan(&dm.ID, &dm.DomainName, &dm.IsActive, &createdAt, &dm.VerificationToken, &dm.IsVerified, &verifiedAt, &dm.MFAExempt, &catchall, &rateLimit, &dm.MTASTSMode, &dm.CalDAVEnabled, &dm.CardDAVEnabled, &dm.DKIMDNSAutomation, &dm.UseGlobalDKIM); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dm.CreatedAt, _ = parseTime(createdAt)
|
dm.CreatedAt, _ = parseTime(createdAt)
|
||||||
@@ -25,11 +28,66 @@ func (d *DB) ListDomains() ([]Domain, error) {
|
|||||||
t, _ := parseTime(*verifiedAt)
|
t, _ := parseTime(*verifiedAt)
|
||||||
dm.VerifiedAt = &t
|
dm.VerifiedAt = &t
|
||||||
}
|
}
|
||||||
|
if catchall.Valid {
|
||||||
|
dm.CatchallMailboxID = &catchall.Int64
|
||||||
|
}
|
||||||
|
if rateLimit.Valid {
|
||||||
|
n := int(rateLimit.Int64)
|
||||||
|
dm.SendRateLimitPerHour = &n
|
||||||
|
}
|
||||||
out = append(out, dm)
|
out = append(out, dm)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDomainCatchall sets (or clears, with a nil mailboxID) the domain's opt-in
|
||||||
|
// catch-all fallback mailbox — see mailstore.ResolveRecipient.
|
||||||
|
func (d *DB) SetDomainCatchall(domainID int64, mailboxID *int64) error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_domains SET catchall_mailbox_id = ? WHERE id = ?`, mailboxID, domainID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDomainSendRateLimit sets (or clears, with a nil limit) the domain's outbound
|
||||||
|
// send-rate cap — see schema.go's send_rate_limit_per_hour column comment.
|
||||||
|
func (d *DB) SetDomainSendRateLimit(domainID int64, limit *int) error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_domains SET send_rate_limit_per_hour = ? WHERE id = ?`, limit, domainID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDomainMTASTSMode sets the domain's served MTA-STS policy mode ("testing" or
|
||||||
|
// "enforce") — see schema.go's mta_sts_mode column comment.
|
||||||
|
func (d *DB) SetDomainMTASTSMode(domainID int64, mode string) error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_domains SET mta_sts_mode = ? WHERE id = ?`, mode, domainID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDomainCalDAVEnabled/SetDomainCardDAVEnabled toggle this domain's master switch for
|
||||||
|
// each protocol — see schema.go's caldav_enabled/carddav_enabled column comments.
|
||||||
|
func (d *DB) SetDomainCalDAVEnabled(domainID int64, enabled bool) error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_domains SET caldav_enabled = ? WHERE id = ?`, enabled, domainID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SetDomainCardDAVEnabled(domainID int64, enabled bool) error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_domains SET carddav_enabled = ? WHERE id = ?`, enabled, domainID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDomainDKIMDNSAutomation sets this domain's DKIM DNS-publish mode — "manual"
|
||||||
|
// (default) or "automatic". Not validated here — callers restrict to those two values
|
||||||
|
// (internal/webui/dkim.go).
|
||||||
|
func (d *DB) SetDomainDKIMDNSAutomation(domainID int64, mode string) error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_domains SET dkim_dns_automation = ? WHERE id = ?`, mode, domainID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDomainUseGlobalDKIM opts this domain in or out of signing with the shared/global
|
||||||
|
// DKIM key — see schema.go's use_global_dkim column comment.
|
||||||
|
func (d *DB) SetDomainUseGlobalDKIM(domainID int64, use bool) error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_domains SET use_global_dkim = ? WHERE id = ?`, use, domainID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ListActiveDomains mirrors the `domains` query used to populate <select> lists on the
|
// ListActiveDomains mirrors the `domains` query used to populate <select> lists on the
|
||||||
// add/edit sender and IP forms.
|
// add/edit sender and IP forms.
|
||||||
func (d *DB) ListActiveDomains() ([]Domain, error) {
|
func (d *DB) ListActiveDomains() ([]Domain, error) {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetActiveGlobalDKIMKey returns the current shared/global DKIM key, or nil (not an
|
||||||
|
// error) if none has been generated yet — see schema.go's esrv_global_dkim_key
|
||||||
|
// comment.
|
||||||
|
func (d *DB) GetActiveGlobalDKIMKey() (*DKIMKey, error) {
|
||||||
|
row := d.QueryRow(`SELECT id, selector, private_key, public_key, is_active, created_at
|
||||||
|
FROM esrv_global_dkim_key WHERE is_active = 1`)
|
||||||
|
var k DKIMKey
|
||||||
|
var createdAt string
|
||||||
|
if err := row.Scan(&k.ID, &k.Selector, &k.PrivateKey, &k.PublicKey, &k.IsActive, &createdAt); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
k.CreatedAt, _ = parseTime(createdAt)
|
||||||
|
return &k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateActiveGlobalDKIMKey deactivates whatever global key is currently active —
|
||||||
|
// mirrors DeactivateActiveDKIMKeysForDomain, table-scoped instead of domain-scoped.
|
||||||
|
func (d *DB) DeactivateActiveGlobalDKIMKey() error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_global_dkim_key SET is_active = 0 WHERE is_active = 1`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertGlobalDKIMKey inserts a new active global key. Callers deactivate the previous
|
||||||
|
// one first (DeactivateActiveGlobalDKIMKey) — not done atomically here, matching how
|
||||||
|
// esrv_dkim_keys rotation is composed by its own callers too.
|
||||||
|
func (d *DB) InsertGlobalDKIMKey(selector, privPEM, pubPEM string) (int64, error) {
|
||||||
|
res, err := d.Exec(`INSERT INTO esrv_global_dkim_key (selector, private_key, public_key, is_active) VALUES (?, ?, ?, 1)`,
|
||||||
|
selector, privPEM, pubPEM)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestGlobalDKIMKeyLifecycle(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
if got, err := database.GetActiveGlobalDKIMKey(); err != nil || got != nil {
|
||||||
|
t.Fatalf("expected no global key yet, got %+v (err=%v)", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id1, err := database.InsertGlobalDKIMKey("sel1", "priv1", "pub1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := database.GetActiveGlobalDKIMKey()
|
||||||
|
if err != nil || got == nil || got.ID != id1 || got.Selector != "sel1" {
|
||||||
|
t.Fatalf("expected the inserted key active, got %+v (err=%v)", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DeactivateActiveGlobalDKIMKey(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id2, err := database.InsertGlobalDKIMKey("sel2", "priv2", "pub2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err = database.GetActiveGlobalDKIMKey()
|
||||||
|
if err != nil || got == nil || got.ID != id2 || got.Selector != "sel2" {
|
||||||
|
t.Fatalf("expected only the second key active after rotation, got %+v (err=%v)", got, err)
|
||||||
|
}
|
||||||
|
_ = id1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetDomainUseGlobalDKIM(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
domainID, err := database.CreateDomain("global.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dom, err := database.GetDomainByID(domainID)
|
||||||
|
if err != nil || dom == nil || dom.UseGlobalDKIM {
|
||||||
|
t.Fatalf("expected use_global_dkim to default to false, got %+v (err=%v)", dom, err)
|
||||||
|
}
|
||||||
|
if err := database.SetDomainUseGlobalDKIM(domainID, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dom, err = database.GetDomainByID(domainID)
|
||||||
|
if err != nil || dom == nil || !dom.UseGlobalDKIM {
|
||||||
|
t.Fatalf("expected use_global_dkim to be true, got %+v (err=%v)", dom, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ type IPAbuseWhitelistEntry struct {
|
|||||||
// smtpImapAuthTypes are the esrv_auth_logs auth_type values that count toward abuse
|
// smtpImapAuthTypes are the esrv_auth_logs auth_type values that count toward abuse
|
||||||
// detection: SMTP/IMAP traffic, not admin/webmail dashboard logins (those already have
|
// detection: SMTP/IMAP traffic, not admin/webmail dashboard logins (those already have
|
||||||
// their own lockout in internal/webui/ratelimit.go).
|
// their own lockout in internal/webui/ratelimit.go).
|
||||||
const smtpImapAuthTypesSQL = `auth_type IN ('sender', 'mailbox', 'sender_validation', 'mailbox_validation', 'ip', 'imap_login')`
|
const smtpImapAuthTypesSQL = `auth_type IN ('sender', 'mailbox', 'sender_validation', 'mailbox_validation', 'ip', 'imap_login', 'dav_login')`
|
||||||
|
|
||||||
// CountFailedAuthAttemptsByIP mirrors CountRecentFailedAttempts (queries.go:140) exactly,
|
// CountFailedAuthAttemptsByIP mirrors CountRecentFailedAttempts (queries.go:140) exactly,
|
||||||
// including its documented since.UTC().Format("2006-01-02 15:04:05") requirement, but
|
// including its documented since.UTC().Format("2006-01-02 15:04:05") requirement, but
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
// HasRecentAutoReply reports whether mailboxID already sent an auto-reply to
|
||||||
|
// senderAddr within the last 24 hours — the fixed loop/storm-prevention window, see
|
||||||
|
// schema.go's esrv_mailbox_autoreply_log comment.
|
||||||
|
func (d *DB) HasRecentAutoReply(mailboxID int64, senderAddr string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_autoreply_log
|
||||||
|
WHERE mailbox_id = ? AND lower(sender_addr) = lower(?) AND sent_at > datetime('now', '-24 hours')`,
|
||||||
|
mailboxID, senderAddr).Scan(&n)
|
||||||
|
return n > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordAutoReply logs that mailboxID just sent an auto-reply to senderAddr, for
|
||||||
|
// HasRecentAutoReply's next check.
|
||||||
|
func (d *DB) RecordAutoReply(mailboxID int64, senderAddr string) error {
|
||||||
|
_, err := d.Exec(`INSERT INTO esrv_mailbox_autoreply_log (mailbox_id, sender_addr) VALUES (?, ?)`, mailboxID, senderAddr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -1,20 +1,35 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
const contactColumns = `id, mailbox_id, email, name, phone, created_at`
|
const contactColumns = `id, mailbox_id, email, name, phone, uid, given_name, family_name, org, created_at, updated_at`
|
||||||
|
|
||||||
|
// generateContactUID returns a random 32-hex-char stable resource identity for a
|
||||||
|
// contact's CardDAV path — same randomness/format as generateVerificationToken
|
||||||
|
// (crud_domains.go).
|
||||||
|
func generateContactUID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
func scanContact(scan func(dest ...any) error) (MailboxContact, error) {
|
func scanContact(scan func(dest ...any) error) (MailboxContact, error) {
|
||||||
var c MailboxContact
|
var c MailboxContact
|
||||||
var createdAt string
|
var createdAt string
|
||||||
err := scan(&c.ID, &c.MailboxID, &c.Email, &c.Name, &c.Phone, &createdAt)
|
var updatedAt sql.NullString
|
||||||
|
err := scan(&c.ID, &c.MailboxID, &c.Email, &c.Name, &c.Phone, &c.UID, &c.GivenName, &c.FamilyName, &c.Org, &createdAt, &updatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
c.CreatedAt, _ = parseTime(createdAt)
|
c.CreatedAt, _ = parseTime(createdAt)
|
||||||
|
if updatedAt.Valid {
|
||||||
|
c.UpdatedAt, _ = parseTime(updatedAt.String)
|
||||||
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,15 +67,58 @@ func (d *DB) GetContactByID(mailboxID, id int64) (*MailboxContact, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) CreateContact(mailboxID int64, email, name, phone string) (int64, error) {
|
func (d *DB) CreateContact(mailboxID int64, email, name, phone string) (int64, error) {
|
||||||
res, err := d.Exec(`INSERT INTO esrv_mailbox_contacts (mailbox_id, email, name, phone) VALUES (?, ?, ?, ?)`, mailboxID, email, name, phone)
|
return d.CreateContactCard(mailboxID, email, name, phone, "", "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) UpdateContact(mailboxID, id int64, email, name, phone string) error {
|
||||||
|
return d.UpdateContactCard(mailboxID, id, email, name, phone, "", "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateContactCard/UpdateContactCard are CreateContact/UpdateContact plus the extra
|
||||||
|
// vCard fields CardDAV round-trips (structured name parts, organization) — a stable
|
||||||
|
// uid is generated once at insert and never changes; updated_at is stamped on every
|
||||||
|
// write, both needed for CardDAV's ETag/sync-token support (see internal/webui/carddav.go).
|
||||||
|
func (d *DB) CreateContactCard(mailboxID int64, email, name, phone, givenName, familyName, org string) (int64, error) {
|
||||||
|
return d.CreateContactWithUID(mailboxID, generateContactUID(), email, name, phone, givenName, familyName, org)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateContactWithUID is CreateContactCard with a caller-chosen uid instead of a
|
||||||
|
// generated one — needed for CardDAV's PutAddressObject, where the client (not this
|
||||||
|
// server) picks the new contact's resource identity via its PUT request path.
|
||||||
|
func (d *DB) CreateContactWithUID(mailboxID int64, uid, email, name, phone, givenName, familyName, org string) (int64, error) {
|
||||||
|
res, err := d.Exec(`INSERT INTO esrv_mailbox_contacts (mailbox_id, email, name, phone, uid, given_name, family_name, org, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||||
|
mailboxID, email, name, phone, uid, givenName, familyName, org)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return res.LastInsertId()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) UpdateContact(mailboxID, id int64, email, name, phone string) error {
|
func (d *DB) UpdateContactCard(mailboxID, id int64, email, name, phone, givenName, familyName, org string) error {
|
||||||
_, err := d.Exec(`UPDATE esrv_mailbox_contacts SET email = ?, name = ?, phone = ? WHERE id = ? AND mailbox_id = ?`, email, name, phone, id, mailboxID)
|
_, err := d.Exec(`UPDATE esrv_mailbox_contacts SET email = ?, name = ?, phone = ?, given_name = ?, family_name = ?, org = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND mailbox_id = ?`, email, name, phone, givenName, familyName, org, id, mailboxID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContactByUID scopes the lookup to mailboxID, same discipline as GetContactByID —
|
||||||
|
// the lookup key CardDAV paths use (esrv_mailbox_contacts.uid), not the numeric id.
|
||||||
|
func (d *DB) GetContactByUID(mailboxID int64, uid string) (*MailboxContact, error) {
|
||||||
|
row := d.QueryRow(`SELECT `+contactColumns+` FROM esrv_mailbox_contacts WHERE uid = ? AND mailbox_id = ?`, uid, mailboxID)
|
||||||
|
c, err := scanContact(row.Scan)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteContactByUID mirrors DeleteContact, keyed by uid instead of the numeric id —
|
||||||
|
// what CardDAV's DeleteAddressObject has (the path's uid segment), not an id.
|
||||||
|
func (d *DB) DeleteContactByUID(mailboxID int64, uid string) error {
|
||||||
|
_, err := d.Exec(`DELETE FROM esrv_mailbox_contacts WHERE uid = ? AND mailbox_id = ?`, uid, mailboxID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +128,8 @@ func (d *DB) UpdateContact(mailboxID, id int64, email, name, phone string) error
|
|||||||
// is left untouched (INSERT OR IGNORE against the mailbox+email UNIQUE constraint), so
|
// is left untouched (INSERT OR IGNORE against the mailbox+email UNIQUE constraint), so
|
||||||
// this can never clobber a manual edit.
|
// this can never clobber a manual edit.
|
||||||
func (d *DB) UpsertContactFromSend(mailboxID int64, email, name string) error {
|
func (d *DB) UpsertContactFromSend(mailboxID int64, email, name string) error {
|
||||||
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_contacts (mailbox_id, email, name, phone) VALUES (?, ?, ?, '')`, mailboxID, email, name)
|
_, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_contacts (mailbox_id, email, name, phone, uid, updated_at) VALUES (?, ?, ?, '', ?, CURRENT_TIMESTAMP)`,
|
||||||
|
mailboxID, email, name, generateContactUID())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package db
|
|||||||
import "strings"
|
import "strings"
|
||||||
|
|
||||||
func (d *DB) ListAllowBlock(mailboxID int64) ([]MailboxAllowBlockEntry, error) {
|
func (d *DB) ListAllowBlock(mailboxID int64) ([]MailboxAllowBlockEntry, error) {
|
||||||
rows, err := d.Query(`SELECT id, mailbox_id, list_type, pattern, created_at
|
rows, err := d.Query(`SELECT id, mailbox_id, list_type, pattern, scope, created_at
|
||||||
FROM esrv_mailbox_allowblock WHERE mailbox_id = ? ORDER BY list_type, pattern`, mailboxID)
|
FROM esrv_mailbox_allowblock WHERE mailbox_id = ? ORDER BY list_type, pattern`, mailboxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -13,7 +13,7 @@ func (d *DB) ListAllowBlock(mailboxID int64) ([]MailboxAllowBlockEntry, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e MailboxAllowBlockEntry
|
var e MailboxAllowBlockEntry
|
||||||
var createdAt string
|
var createdAt string
|
||||||
if err := rows.Scan(&e.ID, &e.MailboxID, &e.ListType, &e.Pattern, &createdAt); err != nil {
|
if err := rows.Scan(&e.ID, &e.MailboxID, &e.ListType, &e.Pattern, &e.Scope, &createdAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.CreatedAt, _ = parseTime(createdAt)
|
e.CreatedAt, _ = parseTime(createdAt)
|
||||||
@@ -22,9 +22,11 @@ func (d *DB) ListAllowBlock(mailboxID int64) ([]MailboxAllowBlockEntry, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) AddAllowBlockEntry(mailboxID int64, listType, pattern string) (int64, error) {
|
// AddAllowBlockEntry stores scope as given for "allow" entries; block/junk entries
|
||||||
res, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_allowblock (mailbox_id, list_type, pattern) VALUES (?, ?, ?)`,
|
// don't use scope (the caller passes "all", meaningless but harmless there).
|
||||||
mailboxID, listType, strings.ToLower(pattern))
|
func (d *DB) AddAllowBlockEntry(mailboxID int64, listType, pattern, scope string) (int64, error) {
|
||||||
|
res, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_allowblock (mailbox_id, list_type, pattern, scope) VALUES (?, ?, ?, ?)`,
|
||||||
|
mailboxID, listType, strings.ToLower(pattern), scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,30 @@ func (d *DB) IsAllowed(mailboxID int64, senderAddr string) (bool, error) {
|
|||||||
return matchesAllowBlock(d, mailboxID, "allow", senderAddr)
|
return matchesAllowBlock(d, mailboxID, "allow", senderAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AllowScope reports the scope ("all"|"spf"|"dkim"|"spam") of the "allow" entry
|
||||||
|
// matching senderAddr, if any — used by deliverLocally to decide which of the three
|
||||||
|
// inbound security checks a whitelist entry suppresses for this sender, rather than
|
||||||
|
// IsAllowed's all-or-nothing bool.
|
||||||
|
func (d *DB) AllowScope(mailboxID int64, senderAddr string) (scope string, matched bool, err error) {
|
||||||
|
senderAddr = strings.ToLower(senderAddr)
|
||||||
|
domain := domainPart(senderAddr)
|
||||||
|
rows, err := d.Query(`SELECT pattern, scope FROM esrv_mailbox_allowblock WHERE mailbox_id = ? AND list_type = 'allow'`, mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var pattern, s string
|
||||||
|
if err := rows.Scan(&pattern, &s); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if (strings.HasPrefix(pattern, "@") && pattern[1:] == domain) || pattern == senderAddr {
|
||||||
|
return s, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// IsJunked reports whether senderAddr matches the mailbox owner's own self-service
|
// IsJunked reports whether senderAddr matches the mailbox owner's own self-service
|
||||||
// Blocklist ('junk' entries — see esrv_mailbox_allowblock's schema comment for how
|
// Blocklist ('junk' entries — see esrv_mailbox_allowblock's schema comment for how
|
||||||
// this differs from admin's IsBlocked).
|
// this differs from admin's IsBlocked).
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestAllowScopeExactAndDomainMatch confirms AllowScope resolves both an exact-address
|
||||||
|
// pattern and a "@domain.com" wildcard, returning the matched entry's scope rather than
|
||||||
|
// IsAllowed's bare bool.
|
||||||
|
func TestAllowScopeExactAndDomainMatch(t *testing.T) {
|
||||||
|
d := openTestDB(t)
|
||||||
|
const mailboxID = int64(1)
|
||||||
|
|
||||||
|
if _, err := d.AddAllowBlockEntry(mailboxID, "allow", "friend@example.com", "dkim"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := d.AddAllowBlockEntry(mailboxID, "allow", "@trusted.com", "spf"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope, matched, err := d.AllowScope(mailboxID, "friend@example.com")
|
||||||
|
if err != nil || !matched || scope != "dkim" {
|
||||||
|
t.Fatalf("exact match: scope=%q matched=%v err=%v, want dkim/true", scope, matched, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope, matched, err = d.AllowScope(mailboxID, "anyone@trusted.com")
|
||||||
|
if err != nil || !matched || scope != "spf" {
|
||||||
|
t.Fatalf("domain wildcard match: scope=%q matched=%v err=%v, want spf/true", scope, matched, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope, matched, err = d.AllowScope(mailboxID, "stranger@nowhere.com")
|
||||||
|
if err != nil || matched || scope != "" {
|
||||||
|
t.Fatalf("no match: scope=%q matched=%v err=%v, want ''/false", scope, matched, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAllowScopeIgnoresJunkAndBlockEntries confirms AllowScope only ever matches
|
||||||
|
// list_type='allow' rows — a "junk" or "block" entry for the same sender is a
|
||||||
|
// different tool for a different job (see esrv_mailbox_allowblock's schema comment)
|
||||||
|
// and must never be mistaken for a whitelist scope.
|
||||||
|
func TestAllowScopeIgnoresJunkAndBlockEntries(t *testing.T) {
|
||||||
|
d := openTestDB(t)
|
||||||
|
const mailboxID = int64(1)
|
||||||
|
|
||||||
|
if _, err := d.AddAllowBlockEntry(mailboxID, "junk", "spammer@example.com", "all"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scope, matched, err := d.AllowScope(mailboxID, "spammer@example.com")
|
||||||
|
if err != nil || matched || scope != "" {
|
||||||
|
t.Fatalf("junk entry must not satisfy AllowScope: scope=%q matched=%v err=%v", scope, matched, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAllowScopeDefaultsToAllForPreExistingRows confirms a row written before the
|
||||||
|
// scope column existed (simulated here by inserting without it, relying on the
|
||||||
|
// column's own DEFAULT 'all') keeps its full-bypass behavior — the backward
|
||||||
|
// compatibility guarantee migrateAddedColumns' comment documents.
|
||||||
|
func TestAllowScopeDefaultsToAllForPreExistingRows(t *testing.T) {
|
||||||
|
d := openTestDB(t)
|
||||||
|
const mailboxID = int64(1)
|
||||||
|
|
||||||
|
if _, err := d.Exec(`INSERT INTO esrv_mailbox_allowblock (mailbox_id, list_type, pattern) VALUES (?, 'allow', 'legacy@example.com')`, mailboxID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scope, matched, err := d.AllowScope(mailboxID, "legacy@example.com")
|
||||||
|
if err != nil || !matched || scope != "all" {
|
||||||
|
t.Fatalf("scope=%q matched=%v err=%v, want all/true", scope, matched, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListAllowBlockRoundTripsScope confirms every scope value survives
|
||||||
|
// Add/List — used by the webmail Whitelist page's per-entry badge.
|
||||||
|
func TestListAllowBlockRoundTripsScope(t *testing.T) {
|
||||||
|
d := openTestDB(t)
|
||||||
|
const mailboxID = int64(1)
|
||||||
|
|
||||||
|
for _, scope := range []string{"all", "spf", "dkim", "spam"} {
|
||||||
|
if _, err := d.AddAllowBlockEntry(mailboxID, "allow", scope+"@example.com", scope); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries, err := d.ListAllowBlock(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]string{}
|
||||||
|
for _, e := range entries {
|
||||||
|
got[e.Pattern] = e.Scope
|
||||||
|
}
|
||||||
|
for _, scope := range []string{"all", "spf", "dkim", "spam"} {
|
||||||
|
if got[scope+"@example.com"] != scope {
|
||||||
|
t.Fatalf("entry for %s@example.com has scope %q, want %q", scope, got[scope+"@example.com"], scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,15 @@ func newMailboxSessionToken() string {
|
|||||||
return hex.EncodeToString(b)
|
return hex.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) CreateMailboxSession(mailboxID int64, mfaVerified bool, ttl time.Duration) (string, error) {
|
// CreateMailboxSession creates a session, best-effort-tagged with userAgent/ip for the
|
||||||
|
// self-service "Active sessions" list (webmail account settings) to show something
|
||||||
|
// recognizable per row — pass "" for either when unavailable (e.g. a test), which just
|
||||||
|
// makes that one list row blanker, never a functional problem (session validity is the
|
||||||
|
// token alone).
|
||||||
|
func (d *DB) CreateMailboxSession(mailboxID int64, mfaVerified bool, ttl time.Duration, userAgent, ip string) (string, error) {
|
||||||
token := newMailboxSessionToken()
|
token := newMailboxSessionToken()
|
||||||
_, err := d.Exec(`INSERT INTO esrv_mailbox_sessions (token, mailbox_id, mfa_verified, expires_at) VALUES (?, ?, ?, ?)`,
|
_, err := d.Exec(`INSERT INTO esrv_mailbox_sessions (token, mailbox_id, mfa_verified, expires_at, user_agent, ip_address) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
token, mailboxID, mfaVerified, time.Now().Add(ttl))
|
token, mailboxID, mfaVerified, time.Now().Add(ttl), userAgent, ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -27,10 +32,10 @@ func (d *DB) CreateMailboxSession(mailboxID int64, mfaVerified bool, ttl time.Du
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) GetMailboxSession(token string) (*MailboxSession, error) {
|
func (d *DB) GetMailboxSession(token string) (*MailboxSession, error) {
|
||||||
row := d.QueryRow(`SELECT token, mailbox_id, mfa_verified, created_at, expires_at FROM esrv_mailbox_sessions WHERE token = ?`, token)
|
row := d.QueryRow(`SELECT token, mailbox_id, mfa_verified, created_at, expires_at, user_agent, ip_address FROM esrv_mailbox_sessions WHERE token = ?`, token)
|
||||||
var s MailboxSession
|
var s MailboxSession
|
||||||
var createdAt, expiresAt string
|
var createdAt, expiresAt string
|
||||||
if err := row.Scan(&s.Token, &s.MailboxID, &s.MFAVerified, &createdAt, &expiresAt); err != nil {
|
if err := row.Scan(&s.Token, &s.MailboxID, &s.MFAVerified, &createdAt, &expiresAt, &s.UserAgent, &s.IPAddress); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -51,6 +56,43 @@ func (d *DB) DeleteMailboxSession(token string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListMailboxSessions returns every current (not-yet-expired) session for mailboxID,
|
||||||
|
// newest first — the self-service "Active sessions" list.
|
||||||
|
//
|
||||||
|
// The cutoff is a Go-computed time.Now(), not SQL's CURRENT_TIMESTAMP: expires_at is
|
||||||
|
// itself a Go-bound time.Time parameter (CreateMailboxSession), and modernc.org/sqlite
|
||||||
|
// stores that in a different text format than CURRENT_TIMESTAMP's own default — a
|
||||||
|
// plain "expires_at > CURRENT_TIMESTAMP" comparison silently doesn't reflect
|
||||||
|
// chronological order at all (same gotcha already documented on the auth-log query in
|
||||||
|
// queries.go). Two Go-bound values compare correctly against each other.
|
||||||
|
func (d *DB) ListMailboxSessions(mailboxID int64) ([]MailboxSession, error) {
|
||||||
|
rows, err := d.Query(`SELECT token, mailbox_id, mfa_verified, created_at, expires_at, user_agent, ip_address
|
||||||
|
FROM esrv_mailbox_sessions WHERE mailbox_id = ? AND expires_at > ? ORDER BY created_at DESC`, mailboxID, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []MailboxSession
|
||||||
|
for rows.Next() {
|
||||||
|
var s MailboxSession
|
||||||
|
var createdAt, expiresAt string
|
||||||
|
if err := rows.Scan(&s.Token, &s.MailboxID, &s.MFAVerified, &createdAt, &expiresAt, &s.UserAgent, &s.IPAddress); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.CreatedAt, _ = parseTime(createdAt)
|
||||||
|
s.ExpiresAt, _ = parseTime(expiresAt)
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeMailboxSession deletes one session, scoped to mailboxID so a mailbox owner
|
||||||
|
// can't revoke a token belonging to a different mailbox by guessing it.
|
||||||
|
func (d *DB) RevokeMailboxSession(token string, mailboxID int64) error {
|
||||||
|
_, err := d.Exec(`DELETE FROM esrv_mailbox_sessions WHERE token = ? AND mailbox_id = ?`, token, mailboxID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// --- WebAuthn credentials --- (mirrors crud_admin.go, parallel schema)
|
// --- WebAuthn credentials --- (mirrors crud_admin.go, parallel schema)
|
||||||
|
|
||||||
func (d *DB) ListMailboxWebAuthnCredentials(mailboxID int64) ([]MailboxWebAuthnCredential, error) {
|
func (d *DB) ListMailboxWebAuthnCredentials(mailboxID int64) ([]MailboxWebAuthnCredential, error) {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openSessionTestDB(t *testing.T) (*DB, int64) {
|
||||||
|
t.Helper()
|
||||||
|
database, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
domainID, err := database.CreateDomain("example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := HashPassword("irrelevant-password")
|
||||||
|
mailboxID, err := database.CreateMailbox("user@example.com", hash, domainID, 1<<30, []byte{1}, []byte{2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return database, mailboxID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListMailboxSessionsExcludesExpired(t *testing.T) {
|
||||||
|
database, mailboxID := openSessionTestDB(t)
|
||||||
|
|
||||||
|
activeToken, err := database.CreateMailboxSession(mailboxID, true, time.Hour, "Mozilla/5.0", "203.0.113.5")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A session that's already expired (negative TTL) must not appear in the list —
|
||||||
|
// this specifically exercises the Go-bound-vs-Go-bound expiry cutoff comparison
|
||||||
|
// (see ListMailboxSessions' doc comment on why it can't compare against SQL's
|
||||||
|
// own CURRENT_TIMESTAMP).
|
||||||
|
if _, err := database.CreateMailboxSession(mailboxID, true, -time.Hour, "OldClient/1.0", "203.0.113.9"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := database.ListMailboxSessions(mailboxID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(sessions) != 1 || sessions[0].Token != activeToken {
|
||||||
|
t.Fatalf("got %d sessions, want exactly the 1 non-expired one; got %+v", len(sessions), sessions)
|
||||||
|
}
|
||||||
|
if sessions[0].UserAgent != "Mozilla/5.0" || sessions[0].IPAddress != "203.0.113.5" {
|
||||||
|
t.Fatalf("expected user_agent/ip_address to round-trip, got %+v", sessions[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeMailboxSessionScopedToOwnMailbox(t *testing.T) {
|
||||||
|
database, mailboxID := openSessionTestDB(t)
|
||||||
|
domainID, err := database.CreateDomain("other.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := HashPassword("irrelevant-password")
|
||||||
|
otherMailboxID, err := database.CreateMailbox("other@other.example", hash, domainID, 1<<30, []byte{1}, []byte{2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := database.CreateMailboxSession(mailboxID, true, time.Hour, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoking with the WRONG mailbox ID must not touch another mailbox's session.
|
||||||
|
if err := database.RevokeMailboxSession(token, otherMailboxID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sessions, _ := database.ListMailboxSessions(mailboxID); len(sessions) != 1 {
|
||||||
|
t.Fatalf("expected the session to survive a revoke scoped to a different mailbox, got %d", len(sessions))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.RevokeMailboxSession(token, mailboxID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sessions, _ := database.ListMailboxSessions(mailboxID); len(sessions) != 0 {
|
||||||
|
t.Fatalf("expected the session revoked, got %d remaining", len(sessions))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled, mfa_exempt, group_messages, remote_images_mode`
|
const mailboxColumns = `id, email, domain_id, password_hash, is_active, quota_bytes, used_bytes, dek_wrapped, dek_nonce, created_at, created_by, totp_secret, totp_enabled, mfa_exempt, group_messages, remote_images_mode, forward_to, forward_keep_copy, caldav_enabled, carddav_enabled`
|
||||||
|
|
||||||
func scanMailbox(row *sql.Row) (*Mailbox, error) {
|
func scanMailbox(row *sql.Row) (*Mailbox, error) {
|
||||||
var m Mailbox
|
var m Mailbox
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var createdBy sql.NullInt64
|
var createdBy sql.NullInt64
|
||||||
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode); err != nil {
|
var forwardTo sql.NullString
|
||||||
|
if err := row.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode, &forwardTo, &m.ForwardKeepCopy, &m.CalDAVEnabled, &m.CardDAVEnabled); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,9 @@ func scanMailbox(row *sql.Row) (*Mailbox, error) {
|
|||||||
if createdBy.Valid {
|
if createdBy.Valid {
|
||||||
m.CreatedBy = &createdBy.Int64
|
m.CreatedBy = &createdBy.Int64
|
||||||
}
|
}
|
||||||
|
if forwardTo.Valid {
|
||||||
|
m.ForwardTo = &forwardTo.String
|
||||||
|
}
|
||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ type MailboxWithDomain struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
||||||
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, m.mfa_exempt, m.group_messages, m.remote_images_mode, dm.domain_name
|
rows, err := d.Query(`SELECT m.id, m.email, m.domain_id, m.password_hash, m.is_active, m.quota_bytes, m.used_bytes, m.dek_wrapped, m.dek_nonce, m.created_at, m.created_by, m.totp_secret, m.totp_enabled, m.mfa_exempt, m.group_messages, m.remote_images_mode, m.forward_to, m.forward_keep_copy, m.caldav_enabled, m.carddav_enabled, dm.domain_name
|
||||||
FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`)
|
FROM esrv_mailboxes m JOIN esrv_domains dm ON dm.id = m.domain_id ORDER BY m.email`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -42,13 +46,17 @@ func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
|||||||
var m MailboxWithDomain
|
var m MailboxWithDomain
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var createdBy sql.NullInt64
|
var createdBy sql.NullInt64
|
||||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode, &m.DomainName); err != nil {
|
var forwardTo sql.NullString
|
||||||
|
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode, &forwardTo, &m.ForwardKeepCopy, &m.CalDAVEnabled, &m.CardDAVEnabled, &m.DomainName); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.CreatedAt, _ = parseTime(createdAt)
|
m.CreatedAt, _ = parseTime(createdAt)
|
||||||
if createdBy.Valid {
|
if createdBy.Valid {
|
||||||
m.CreatedBy = &createdBy.Int64
|
m.CreatedBy = &createdBy.Int64
|
||||||
}
|
}
|
||||||
|
if forwardTo.Valid {
|
||||||
|
m.ForwardTo = &forwardTo.String
|
||||||
|
}
|
||||||
out = append(out, m)
|
out = append(out, m)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
@@ -65,13 +73,17 @@ func (d *DB) ListMailboxesForDomain(domainID int64) ([]Mailbox, error) {
|
|||||||
var m Mailbox
|
var m Mailbox
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var createdBy sql.NullInt64
|
var createdBy sql.NullInt64
|
||||||
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode); err != nil {
|
var forwardTo sql.NullString
|
||||||
|
if err := rows.Scan(&m.ID, &m.Email, &m.DomainID, &m.PasswordHash, &m.IsActive, &m.QuotaBytes, &m.UsedBytes, &m.DEKWrapped, &m.DEKNonce, &createdAt, &createdBy, &m.TOTPSecret, &m.TOTPEnabled, &m.MFAExempt, &m.GroupMessages, &m.RemoteImagesMode, &forwardTo, &m.ForwardKeepCopy, &m.CalDAVEnabled, &m.CardDAVEnabled); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.CreatedAt, _ = parseTime(createdAt)
|
m.CreatedAt, _ = parseTime(createdAt)
|
||||||
if createdBy.Valid {
|
if createdBy.Valid {
|
||||||
m.CreatedBy = &createdBy.Int64
|
m.CreatedBy = &createdBy.Int64
|
||||||
}
|
}
|
||||||
|
if forwardTo.Valid {
|
||||||
|
m.ForwardTo = &forwardTo.String
|
||||||
|
}
|
||||||
out = append(out, m)
|
out = append(out, m)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
@@ -133,6 +145,25 @@ func (d *DB) SetMailboxRemoteImagesMode(id int64, mode string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMailboxForwarding sets (or clears, with an empty forwardTo) this mailbox's
|
||||||
|
// persistent forward target — see schema.go's forward_to column comment.
|
||||||
|
func (d *DB) SetMailboxForwarding(id int64, forwardTo string, keepCopy bool) error {
|
||||||
|
var val any
|
||||||
|
if forwardTo != "" {
|
||||||
|
val = forwardTo
|
||||||
|
}
|
||||||
|
_, err := d.Exec(`UPDATE esrv_mailboxes SET forward_to = ?, forward_keep_copy = ? WHERE id = ?`, val, keepCopy, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMailboxDAVEnabled sets this mailbox owner's own opt-in for CalDAV/CardDAV sync —
|
||||||
|
// see schema.go's caldav_enabled/carddav_enabled column comments. Only takes effect
|
||||||
|
// once the owning domain's matching switch is also on.
|
||||||
|
func (d *DB) SetMailboxDAVEnabled(id int64, caldav, carddav bool) error {
|
||||||
|
_, err := d.Exec(`UPDATE esrv_mailboxes SET caldav_enabled = ?, carddav_enabled = ? WHERE id = ?`, caldav, carddav, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error {
|
func (d *DB) SetMailboxQuota(id int64, quotaBytes int64) error {
|
||||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
|
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// VolumeBucket is one hour's worth of esrv_email_logs, split by overall status
|
||||||
|
// (relayed/partial/failed — see relay.overallStatus).
|
||||||
|
type VolumeBucket struct {
|
||||||
|
Hour time.Time // truncated to the hour, UTC
|
||||||
|
Relayed int
|
||||||
|
Partial int
|
||||||
|
Failed int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageVolumeByHour returns one bucket per hour for the last `hours` hours (oldest
|
||||||
|
// first), zero-filled so a quiet hour still shows as a zero-height bar rather than a
|
||||||
|
// gap in the admin dashboard's chart.
|
||||||
|
//
|
||||||
|
// Bucketing happens in Go, not SQL: esrv_email_logs.timestamp is a Go-bound
|
||||||
|
// time.Time parameter (see InsertEmailLog), which modernc.org/sqlite stores in
|
||||||
|
// RFC3339-with-nanoseconds text ("2026-08-19T18:01:39.238736245Z") — confirmed live
|
||||||
|
// that SQLite's own strftime() can't parse that format (returns NULL, silently
|
||||||
|
// matching nothing), so grouping by hour has to happen after parsing each row back
|
||||||
|
// into a time.Time in Go instead. The plain "timestamp >= ?" cutoff comparison below
|
||||||
|
// doesn't have this problem — two Go-bound values compare correctly against each
|
||||||
|
// other; it's only strftime()/date-function parsing of the stored text that breaks.
|
||||||
|
func (d *DB) MessageVolumeByHour(hours int) ([]VolumeBucket, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
since := now.Add(-time.Duration(hours) * time.Hour)
|
||||||
|
|
||||||
|
rows, err := d.Query(`SELECT timestamp, status FROM esrv_email_logs WHERE timestamp >= ? ORDER BY timestamp`, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
byHour := make(map[time.Time]*VolumeBucket)
|
||||||
|
for rows.Next() {
|
||||||
|
var ts, status string
|
||||||
|
if err := rows.Scan(&ts, &status); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t, err := parseTime(ts)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := t.UTC().Truncate(time.Hour)
|
||||||
|
b, ok := byHour[key]
|
||||||
|
if !ok {
|
||||||
|
b = &VolumeBucket{Hour: key}
|
||||||
|
byHour[key] = b
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case "relayed":
|
||||||
|
b.Relayed++
|
||||||
|
case "partial":
|
||||||
|
b.Partial++
|
||||||
|
default:
|
||||||
|
b.Failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
start := since.Truncate(time.Hour)
|
||||||
|
end := now.Truncate(time.Hour)
|
||||||
|
out := make([]VolumeBucket, 0, hours+1)
|
||||||
|
for h := start; !h.After(end); h = h.Add(time.Hour) {
|
||||||
|
if b, ok := byHour[h]; ok {
|
||||||
|
out = append(out, *b)
|
||||||
|
} else {
|
||||||
|
out = append(out, VolumeBucket{Hour: h})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryStats totals per-recipient delivery outcomes over the last `hours` hours —
|
||||||
|
// success is a recipient actually delivered/quarantined-but-accepted; failed is
|
||||||
|
// everything else (hard rejects, relay failures).
|
||||||
|
func (d *DB) DeliveryStats(hours int) (success, failed int, err error) {
|
||||||
|
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||||
|
err = d.QueryRow(`
|
||||||
|
SELECT COALESCE(SUM(CASE WHEN r.status = 'success' THEN 1 ELSE 0 END), 0),
|
||||||
|
COALESCE(SUM(CASE WHEN r.status != 'success' THEN 1 ELSE 0 END), 0)
|
||||||
|
FROM esrv_email_recipient_logs r
|
||||||
|
JOIN esrv_email_logs l ON l.id = r.email_log_id
|
||||||
|
WHERE l.timestamp >= ?`, since).Scan(&success, &failed)
|
||||||
|
return success, failed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainSendCount is one sending domain's message count within a window.
|
||||||
|
type DomainSendCount struct {
|
||||||
|
Domain string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendCountsByDomain returns the busiest sending domains (by envelope MAIL FROM) over
|
||||||
|
// the last `hours` hours, most first.
|
||||||
|
func (d *DB) SendCountsByDomain(hours int) ([]DomainSendCount, error) {
|
||||||
|
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT substr(mail_from, instr(mail_from, '@') + 1) AS domain, COUNT(*) AS n
|
||||||
|
FROM esrv_email_logs
|
||||||
|
WHERE timestamp >= ? AND instr(mail_from, '@') > 0
|
||||||
|
GROUP BY domain
|
||||||
|
ORDER BY n DESC
|
||||||
|
LIMIT 20`, since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []DomainSendCount
|
||||||
|
for rows.Next() {
|
||||||
|
var c DomainSendCount
|
||||||
|
if err := rows.Scan(&c.Domain, &c.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountRecentSendsForDomain counts esrv_email_logs rows from domain since the given
|
||||||
|
// cutoff — backs Session.domainSendRateLimited's per-hour outbound cap. Counts every
|
||||||
|
// logged DATA transaction from the domain (local delivery included, not just external
|
||||||
|
// relay) — a deliberate scope simplification: esrv_email_logs doesn't distinguish
|
||||||
|
// local-only from relay-included rows, and a compromised account's send burst shows up
|
||||||
|
// in this count either way.
|
||||||
|
func (d *DB) CountRecentSendsForDomain(domain string, since time.Time) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_email_logs
|
||||||
|
WHERE substr(mail_from, instr(mail_from, '@') + 1) = ? AND timestamp >= ?`,
|
||||||
|
domain, since.UTC()).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailboxUsage is one mailbox's current quota consumption — a snapshot, not a
|
||||||
|
// historical trend (no periodic usage snapshots are collected; building that
|
||||||
|
// collection job is out of scope for what this admin view needs).
|
||||||
|
type MailboxUsage struct {
|
||||||
|
Email string
|
||||||
|
UsedBytes int64
|
||||||
|
QuotaBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopMailboxesByQuotaUsage returns the mailboxes closest to their quota, fullest
|
||||||
|
// first.
|
||||||
|
func (d *DB) TopMailboxesByQuotaUsage(limit int) ([]MailboxUsage, error) {
|
||||||
|
rows, err := d.Query(`SELECT email, used_bytes, quota_bytes FROM esrv_mailboxes
|
||||||
|
WHERE quota_bytes > 0 ORDER BY (CAST(used_bytes AS REAL) / quota_bytes) DESC LIMIT ?`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []MailboxUsage
|
||||||
|
for rows.Next() {
|
||||||
|
var m MailboxUsage
|
||||||
|
if err := rows.Scan(&m.Email, &m.UsedBytes, &m.QuotaBytes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openMonitoringTestDB(t *testing.T) *DB {
|
||||||
|
t.Helper()
|
||||||
|
database, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
return database
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageVolumeByHourBucketsRealStoredTimestamps(t *testing.T) {
|
||||||
|
database := openMonitoringTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
logID1, err := database.InsertEmailLog(EmailLog{MessageID: "m1@x", Timestamp: now, MailFrom: "a@sender.example", EmailHeaders: "h", Status: "relayed"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.InsertEmailLog(EmailLog{MessageID: "m2@x", Timestamp: now.Add(-30 * time.Minute), MailFrom: "a@sender.example", EmailHeaders: "h", Status: "failed"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Outside the 2-hour window requested below — must not be counted.
|
||||||
|
if _, err := database.InsertEmailLog(EmailLog{MessageID: "m3@x", Timestamp: now.Add(-5 * time.Hour), MailFrom: "a@sender.example", EmailHeaders: "h", Status: "relayed"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets, err := database.MessageVolumeByHour(2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(buckets) == 0 {
|
||||||
|
t.Fatal("expected at least one bucket")
|
||||||
|
}
|
||||||
|
var totalRelayed, totalFailed int
|
||||||
|
for _, b := range buckets {
|
||||||
|
totalRelayed += b.Relayed
|
||||||
|
totalFailed += b.Failed
|
||||||
|
}
|
||||||
|
if totalRelayed != 1 || totalFailed != 1 {
|
||||||
|
t.Fatalf("got relayed=%d failed=%d, want 1 and 1 (the 5h-old message must be excluded)", totalRelayed, totalFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-fill: every hour in the window must appear even with no data.
|
||||||
|
if len(buckets) < 2 {
|
||||||
|
t.Fatalf("expected the window's hours to be zero-filled, got %d buckets", len(buckets))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = logID1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliveryStatsCountsSuccessAndFailure(t *testing.T) {
|
||||||
|
database := openMonitoringTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
logID, err := database.InsertEmailLog(EmailLog{MessageID: "m1@x", Timestamp: now, MailFrom: "a@sender.example", EmailHeaders: "h", Status: "partial"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.InsertEmailRecipientLog(EmailRecipientLog{EmailLogID: logID, Recipient: "a@b.com", RecipientType: "to", Status: "success"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.InsertEmailRecipientLog(EmailRecipientLog{EmailLogID: logID, Recipient: "c@d.com", RecipientType: "to", Status: "failed"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
success, failed, err := database.DeliveryStats(24)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if success != 1 || failed != 1 {
|
||||||
|
t.Fatalf("got success=%d failed=%d, want 1 and 1", success, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendCountsByDomainGroupsAndOrders(t *testing.T) {
|
||||||
|
database := openMonitoringTestDB(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := database.InsertEmailLog(EmailLog{MessageID: "busy" + string(rune('a'+i)) + "@x", Timestamp: now, MailFrom: "user@busy.example", EmailHeaders: "h", Status: "relayed"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := database.InsertEmailLog(EmailLog{MessageID: "quiet@x", Timestamp: now, MailFrom: "user@quiet.example", EmailHeaders: "h", Status: "relayed"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, err := database.SendCountsByDomain(24)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(counts) != 2 || counts[0].Domain != "busy.example" || counts[0].Count != 3 {
|
||||||
|
t.Fatalf("got %+v, want busy.example first with count 3", counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTopMailboxesByQuotaUsage(t *testing.T) {
|
||||||
|
database := openMonitoringTestDB(t)
|
||||||
|
domainID, err := database.CreateDomain("example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := HashPassword("irrelevant-password")
|
||||||
|
if _, err := database.CreateMailbox("full@example.com", hash, domainID, 100, []byte{1}, []byte{2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.CreateMailbox("empty@example.com", hash, domainID, 100, []byte{1}, []byte{2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fullID, err := database.GetMailboxByEmail("full@example.com")
|
||||||
|
if err != nil || fullID == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.AddMailboxUsedBytes(fullID.ID, 90); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, err := database.TopMailboxesByQuotaUsage(10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(usage) != 2 || usage[0].Email != "full@example.com" || usage[0].UsedBytes != 90 {
|
||||||
|
t.Fatalf("got %+v, want full@example.com first with 90 used bytes", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,15 @@ type Mailbox struct {
|
|||||||
// RemoteImagesMode is "ask" (default), "trusted", or "always" — see the
|
// RemoteImagesMode is "ask" (default), "trusted", or "always" — see the
|
||||||
// remote_images_mode column comment in schema.go.
|
// remote_images_mode column comment in schema.go.
|
||||||
RemoteImagesMode string
|
RemoteImagesMode string
|
||||||
|
// ForwardTo is a persistent mailbox-level forward target, distinct from a filter
|
||||||
|
// rule's own "forward" action — nil means forwarding is off. See schema.go.
|
||||||
|
ForwardTo *string
|
||||||
|
ForwardKeepCopy bool
|
||||||
|
// CalDAVEnabled/CardDAVEnabled are this mailbox owner's own opt-in for each
|
||||||
|
// protocol — off by default, and only actually reachable when the owning domain's
|
||||||
|
// matching switch is also on (DAVBasicAuth checks both). See schema.go.
|
||||||
|
CalDAVEnabled bool
|
||||||
|
CardDAVEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxSession is a self-service webmail portal login — a parallel schema to
|
// MailboxSession is a self-service webmail portal login — a parallel schema to
|
||||||
@@ -41,6 +50,8 @@ type MailboxSession struct {
|
|||||||
MFAVerified bool
|
MFAVerified bool
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
|
UserAgent string
|
||||||
|
IPAddress string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxWebAuthnCredential is a mailbox owner's passkey — a parallel schema to
|
// MailboxWebAuthnCredential is a mailbox owner's passkey — a parallel schema to
|
||||||
@@ -74,17 +85,43 @@ type MailboxAllowBlockEntry struct {
|
|||||||
MailboxID int64
|
MailboxID int64
|
||||||
ListType string // "allow" | "block" | "junk"
|
ListType string // "allow" | "block" | "junk"
|
||||||
Pattern string
|
Pattern string
|
||||||
|
Scope string // "all" | "spf" | "dkim" | "spam" — which check(s) an "allow" entry suppresses
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxContact is one entry in a mailbox owner's own address book.
|
// MailboxContact is one entry in a mailbox owner's own address book.
|
||||||
type MailboxContact struct {
|
type MailboxContact struct {
|
||||||
ID int64
|
ID int64
|
||||||
MailboxID int64
|
MailboxID int64
|
||||||
Email string
|
Email string
|
||||||
Name string
|
Name string
|
||||||
Phone string
|
Phone string
|
||||||
CreatedAt time.Time
|
UID string // stable CardDAV resource identity, generated at insert time
|
||||||
|
GivenName string
|
||||||
|
FamilyName string
|
||||||
|
Org string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time // CardDAV ETag/REV source, bumped on every write
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarEvent is one entry in a mailbox owner's own calendar — CalDAV
|
||||||
|
// (internal/webui/caldav.go) and the webmail Calendar page
|
||||||
|
// (internal/webui/webmail_calendar.go) are two views onto the same rows.
|
||||||
|
type CalendarEvent struct {
|
||||||
|
ID int64
|
||||||
|
MailboxID int64
|
||||||
|
UID string // stable CalDAV resource identity, generated at insert time
|
||||||
|
Summary string
|
||||||
|
Description string
|
||||||
|
Location string
|
||||||
|
StartAt time.Time
|
||||||
|
EndAt time.Time
|
||||||
|
AllDay bool
|
||||||
|
RRule string // raw RRULE value, e.g. "FREQ=WEEKLY"; empty = non-recurring
|
||||||
|
Color string // "#rrggbb"; empty = use the UI's default chip color
|
||||||
|
ReminderMinutes *int // nil = no reminder set
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time // CalDAV ETag/DTSTAMP source, bumped on every write
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxFilterRule is one priority-ordered, first-match-wins delivery rule.
|
// MailboxFilterRule is one priority-ordered, first-match-wins delivery rule.
|
||||||
@@ -137,6 +174,19 @@ func (r MailboxFilterRule) ActionOptions() RuleActionOptions {
|
|||||||
return opts
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AutoReplyBody parses the 'auto_reply' action's own ActionOptionsJSON shape (see
|
||||||
|
// mailstore.autoReplyOptions, which this mirrors) — separate from ActionOptions since
|
||||||
|
// that struct's fields are specific to the 'forward' action.
|
||||||
|
func (r MailboxFilterRule) AutoReplyBody() string {
|
||||||
|
var opts struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
if r.ActionOptionsJSON != "" {
|
||||||
|
json.Unmarshal([]byte(r.ActionOptionsJSON), &opts)
|
||||||
|
}
|
||||||
|
return opts.Body
|
||||||
|
}
|
||||||
|
|
||||||
// Conditions returns this rule's conditions and how they combine ("all"=AND,
|
// Conditions returns this rule's conditions and how they combine ("all"=AND,
|
||||||
// "any"=OR) — parses ConditionsJSON when present, falling back to the single legacy
|
// "any"=OR) — parses ConditionsJSON when present, falling back to the single legacy
|
||||||
// condition_field/op/value columns for rules created before multi-condition support
|
// condition_field/op/value columns for rules created before multi-condition support
|
||||||
|
|||||||
@@ -13,6 +13,27 @@ type Domain struct {
|
|||||||
// MFAExempt overrides [Auth] enforce_mailbox_mfa off for every mailbox under this
|
// MFAExempt overrides [Auth] enforce_mailbox_mfa off for every mailbox under this
|
||||||
// domain, regardless of that mailbox's own MFAExempt.
|
// domain, regardless of that mailbox's own MFAExempt.
|
||||||
MFAExempt bool
|
MFAExempt bool
|
||||||
|
// CatchallMailboxID is the opt-in fallback mailbox for a recipient that matches no
|
||||||
|
// mailbox/alias/sub-address on this domain — nil means an unresolved recipient
|
||||||
|
// still bounces normally. See mailstore.ResolveRecipient.
|
||||||
|
CatchallMailboxID *int64
|
||||||
|
// SendRateLimitPerHour caps outbound sends from this domain; nil means unlimited.
|
||||||
|
// See schema.go's send_rate_limit_per_hour column comment.
|
||||||
|
SendRateLimitPerHour *int
|
||||||
|
// MTASTSMode is "testing" (default) or "enforce" — see schema.go's mta_sts_mode
|
||||||
|
// column comment.
|
||||||
|
MTASTSMode string
|
||||||
|
// CalDAVEnabled/CardDAVEnabled are the domain-wide master switches for those two
|
||||||
|
// protocols — both off by default. A mailbox's own opt-in only takes effect when
|
||||||
|
// the matching switch here is also on. See schema.go's column comments.
|
||||||
|
CalDAVEnabled bool
|
||||||
|
CardDAVEnabled bool
|
||||||
|
// DKIMDNSAutomation is "manual" (default) or "automatic" — see schema.go's
|
||||||
|
// dkim_dns_automation column comment.
|
||||||
|
DKIMDNSAutomation string
|
||||||
|
// UseGlobalDKIM opts this domain into signing with the one shared DKIM key
|
||||||
|
// instead of its own — see schema.go's use_global_dkim column comment.
|
||||||
|
UseGlobalDKIM bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Sender struct {
|
type Sender struct {
|
||||||
|
|||||||
+11
-2
@@ -54,14 +54,16 @@ func (d *DB) GetSenderByEmail(email string) (*Sender, error) {
|
|||||||
return &s, nil
|
return &s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const domainColumns = `id, domain_name, is_active, created_at, verification_token, is_verified, verified_at, mfa_exempt`
|
const domainColumns = `id, domain_name, is_active, created_at, verification_token, is_verified, verified_at, mfa_exempt, catchall_mailbox_id, send_rate_limit_per_hour, mta_sts_mode, caldav_enabled, carddav_enabled, dkim_dns_automation, use_global_dkim`
|
||||||
|
|
||||||
// scanDomain scans a row selected with domainColumns, in that order.
|
// scanDomain scans a row selected with domainColumns, in that order.
|
||||||
func scanDomain(row *sql.Row) (*Domain, error) {
|
func scanDomain(row *sql.Row) (*Domain, error) {
|
||||||
var dom Domain
|
var dom Domain
|
||||||
var createdAt string
|
var createdAt string
|
||||||
var verifiedAt sql.NullString
|
var verifiedAt sql.NullString
|
||||||
if err := row.Scan(&dom.ID, &dom.DomainName, &dom.IsActive, &createdAt, &dom.VerificationToken, &dom.IsVerified, &verifiedAt, &dom.MFAExempt); err != nil {
|
var catchall sql.NullInt64
|
||||||
|
var rateLimit sql.NullInt64
|
||||||
|
if err := row.Scan(&dom.ID, &dom.DomainName, &dom.IsActive, &createdAt, &dom.VerificationToken, &dom.IsVerified, &verifiedAt, &dom.MFAExempt, &catchall, &rateLimit, &dom.MTASTSMode, &dom.CalDAVEnabled, &dom.CardDAVEnabled, &dom.DKIMDNSAutomation, &dom.UseGlobalDKIM); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -72,6 +74,13 @@ func scanDomain(row *sql.Row) (*Domain, error) {
|
|||||||
t, _ := parseTime(verifiedAt.String)
|
t, _ := parseTime(verifiedAt.String)
|
||||||
dom.VerifiedAt = &t
|
dom.VerifiedAt = &t
|
||||||
}
|
}
|
||||||
|
if catchall.Valid {
|
||||||
|
dom.CatchallMailboxID = &catchall.Int64
|
||||||
|
}
|
||||||
|
if rateLimit.Valid {
|
||||||
|
n := int(rateLimit.Int64)
|
||||||
|
dom.SendRateLimitPerHour = &n
|
||||||
|
}
|
||||||
return &dom, nil
|
return &dom, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+218
-6
@@ -24,7 +24,58 @@ CREATE TABLE IF NOT EXISTS esrv_domains (
|
|||||||
is_verified INTEGER NOT NULL DEFAULT 0,
|
is_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
verified_at DATETIME,
|
verified_at DATETIME,
|
||||||
default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120,
|
default_mailbox_quota_bytes INTEGER NOT NULL DEFAULT 5368709120,
|
||||||
mfa_exempt INTEGER NOT NULL DEFAULT 0
|
mfa_exempt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- Opt-in fallback mailbox for a recipient that matches no mailbox/alias/sub-address
|
||||||
|
-- on this domain — NULL (the default) means an unresolved recipient still bounces
|
||||||
|
-- normally. See mailstore.ResolveRecipient.
|
||||||
|
catchall_mailbox_id INTEGER REFERENCES esrv_mailboxes(id),
|
||||||
|
-- Outbound send-rate cap: NULL (default) means unlimited. Counts every SMTP DATA
|
||||||
|
-- transaction logged from this domain (esrv_email_logs) in a rolling hour — see
|
||||||
|
-- Session.domainSendRateLimited. A compromised account sending a burst should slow
|
||||||
|
-- down, not silently vanish undelivered mail; over-limit sends get a retryable
|
||||||
|
-- 450, not a hard reject.
|
||||||
|
send_rate_limit_per_hour INTEGER,
|
||||||
|
-- MTA-STS mode this domain's served policy (GET /.well-known/mta-sts.txt)
|
||||||
|
-- advertises to senders — 'testing' (default, safe: report-only, nothing this
|
||||||
|
-- server does changes based on it) or 'enforce' (senders that respect MTA-STS
|
||||||
|
-- should hard-fail rather than deliver over an unverified connection). Requires
|
||||||
|
-- the operator to actually publish the corresponding _mta-sts TXT record and
|
||||||
|
-- point mta-sts.<domain> DNS at this server — the admin UI documents both.
|
||||||
|
mta_sts_mode TEXT NOT NULL DEFAULT 'testing',
|
||||||
|
-- Per-domain CalDAV/CardDAV master switches — both off by default. A mailbox's own
|
||||||
|
-- caldav_enabled/carddav_enabled (esrv_mailboxes) only takes effect when the
|
||||||
|
-- matching switch here is also on; DAVBasicAuth checks both, so an admin opts a
|
||||||
|
-- domain in first, then each mailbox owner opts themselves in from webmail settings.
|
||||||
|
caldav_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
carddav_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- 'manual' (default) keeps today's copy-the-TXT-record-yourself flow; 'automatic'
|
||||||
|
-- pushes it via a DNS provider API instead, using this domain's own credentials in
|
||||||
|
-- esrv_domain_dns_credentials — see dkim.Manager.GenerateAndPublish.
|
||||||
|
dkim_dns_automation TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
-- Opt-in to sign this domain's outbound mail with the one shared/global DKIM key
|
||||||
|
-- (esrv_global_dkim_key) instead of this domain's own esrv_dkim_keys key material —
|
||||||
|
-- the domain's own selector is still what's used (see dkim.Manager.GetActiveDKIMKey),
|
||||||
|
-- so its own key row is kept, just not used for signing while this is on. Lets a
|
||||||
|
-- customer CNAME once and never touch DNS again on rotation — see
|
||||||
|
-- esrv_global_dkim_key's comment for the mechanism.
|
||||||
|
use_global_dkim INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One shared DKIM key, used by every domain with esrv_domains.use_global_dkim set —
|
||||||
|
-- published at "<selector>._domainkey.<[DKIM] global_dkim_hostname>" in the operator's
|
||||||
|
-- own zone (not any customer's). A customer CNAMEs their own
|
||||||
|
-- "<their selector>._domainkey.<their domain>" at that one record once; regenerating
|
||||||
|
-- this key (by hand or on [DKIM] global_dkim_rotation_days) then rotates DKIM for
|
||||||
|
-- every opted-in domain at once, with no further DNS work from any of them — this is
|
||||||
|
-- how Hornetsecurity's own CNAME delegation works. Only one is_active=1 row expected;
|
||||||
|
-- regenerating deactivates the previous one first, same convention as esrv_dkim_keys.
|
||||||
|
CREATE TABLE IF NOT EXISTS esrv_global_dkim_key (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
selector TEXT NOT NULL DEFAULT '',
|
||||||
|
private_key TEXT NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS esrv_senders (
|
CREATE TABLE IF NOT EXISTS esrv_senders (
|
||||||
@@ -131,6 +182,25 @@ CREATE TABLE IF NOT EXISTS esrv_dkim_keys (
|
|||||||
replaced_at DATETIME
|
replaced_at DATETIME
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Per-domain credentials for automatically publishing that domain's own DKIM TXT
|
||||||
|
-- record via a DNS provider API, instead of an admin manually copying it into their
|
||||||
|
-- DNS provider's UI — only takes effect when esrv_domains.dkim_dns_automation is
|
||||||
|
-- 'automatic'. Plaintext, matching how [LetsEncrypt]'s own DNS-01 provider credentials
|
||||||
|
-- are already stored in settings.ini for the same reason (this codebase's existing
|
||||||
|
-- norm for this class of secret). See internal/dnspublish for what each field means.
|
||||||
|
CREATE TABLE IF NOT EXISTS esrv_domain_dns_credentials (
|
||||||
|
domain_id INTEGER PRIMARY KEY REFERENCES esrv_domains(id),
|
||||||
|
provider TEXT NOT NULL DEFAULT '',
|
||||||
|
zone_name TEXT NOT NULL DEFAULT '',
|
||||||
|
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||||
|
route53_access_key_id TEXT NOT NULL DEFAULT '',
|
||||||
|
route53_secret_access_key TEXT NOT NULL DEFAULT '',
|
||||||
|
route53_region TEXT NOT NULL DEFAULT '',
|
||||||
|
digitalocean_api_token TEXT NOT NULL DEFAULT '',
|
||||||
|
gcloud_project TEXT NOT NULL DEFAULT '',
|
||||||
|
gcloud_service_account_json TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS esrv_custom_headers (
|
CREATE TABLE IF NOT EXISTS esrv_custom_headers (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
domain_id INTEGER NOT NULL REFERENCES esrv_domains(id),
|
||||||
@@ -220,7 +290,18 @@ CREATE TABLE IF NOT EXISTS esrv_mailboxes (
|
|||||||
-- esrv_mailbox_trusted_image_senders list; 'always' never blocks (not recommended,
|
-- esrv_mailbox_trusted_image_senders list; 'always' never blocks (not recommended,
|
||||||
-- offered anyway since it's the mailbox owner's own call). See
|
-- offered anyway since it's the mailbox owner's own call). See
|
||||||
-- webmail_mail.go's loadMessageForView / stripRemoteImages.
|
-- webmail_mail.go's loadMessageForView / stripRemoteImages.
|
||||||
remote_images_mode TEXT NOT NULL DEFAULT 'ask'
|
remote_images_mode TEXT NOT NULL DEFAULT 'ask',
|
||||||
|
-- Persistent mailbox-level forwarding — distinct from and independent of a filter
|
||||||
|
-- rule's own "forward" action (mailstore.ApplyRules): this applies unconditionally
|
||||||
|
-- to every message, checked early in deliverLocally, not matched against
|
||||||
|
-- from/subject/etc. NULL forward_to means forwarding is off.
|
||||||
|
forward_to TEXT,
|
||||||
|
forward_keep_copy INTEGER NOT NULL DEFAULT 1,
|
||||||
|
-- This mailbox's own opt-in for CalDAV/CardDAV sync — off by default, and only
|
||||||
|
-- actually reachable when the owning domain's matching esrv_domains switch is also
|
||||||
|
-- on (DAVBasicAuth checks both).
|
||||||
|
caldav_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
carddav_enabled INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Self-service webmail portal sessions — deliberately a parallel schema to
|
-- Self-service webmail portal sessions — deliberately a parallel schema to
|
||||||
@@ -231,7 +312,13 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_sessions (
|
|||||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||||
mfa_verified INTEGER NOT NULL DEFAULT 0,
|
mfa_verified INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
expires_at DATETIME NOT NULL
|
expires_at DATETIME NOT NULL,
|
||||||
|
-- Best-effort, for the self-service "Active sessions" list (webmail account
|
||||||
|
-- settings) to show something recognizable per session — never security-critical
|
||||||
|
-- (session validity is the token alone), so a missing/empty value here just
|
||||||
|
-- means a blanker list row, not a functional problem.
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
ip_address TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS esrv_mailbox_webauthn_credentials (
|
CREATE TABLE IF NOT EXISTS esrv_mailbox_webauthn_credentials (
|
||||||
@@ -287,6 +374,7 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_allowblock (
|
|||||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||||
list_type TEXT NOT NULL CHECK(list_type IN ('allow','block','junk')),
|
list_type TEXT NOT NULL CHECK(list_type IN ('allow','block','junk')),
|
||||||
pattern TEXT NOT NULL,
|
pattern TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'all',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE(mailbox_id, list_type, pattern)
|
UNIQUE(mailbox_id, list_type, pattern)
|
||||||
);
|
);
|
||||||
@@ -309,6 +397,43 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_contacts (
|
|||||||
UNIQUE(mailbox_id, email)
|
UNIQUE(mailbox_id, email)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- A mailbox owner's own calendar events — CalDAV (internal/webui/caldav.go) and the
|
||||||
|
-- webmail Calendar page (internal/webui/webmail_calendar.go) are two views onto the
|
||||||
|
-- same rows, same relationship as esrv_mailbox_contacts is to CardDAV. uid is the
|
||||||
|
-- stable CalDAV resource identity (like esrv_mailbox_contacts.uid); rrule stores the
|
||||||
|
-- raw RRULE value string (empty = non-recurring) — occurrence expansion for the
|
||||||
|
-- webmail month-view grid happens in Go via rrule-go, not SQL.
|
||||||
|
CREATE TABLE IF NOT EXISTS esrv_mailbox_calendar_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||||
|
uid TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
location TEXT NOT NULL DEFAULT '',
|
||||||
|
start_at DATETIME NOT NULL,
|
||||||
|
end_at DATETIME NOT NULL,
|
||||||
|
all_day INTEGER NOT NULL DEFAULT 0,
|
||||||
|
rrule TEXT NOT NULL DEFAULT '',
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(mailbox_id, uid)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_calendar_events_mailbox_start ON esrv_mailbox_calendar_events(mailbox_id, start_at);
|
||||||
|
|
||||||
|
-- At most one reminder per event in this pass (the webmail UI only ever offers a
|
||||||
|
-- single "remind me N minutes before") — its own table rather than a column so a
|
||||||
|
-- later multi-reminder UI won't need a schema change; the single-row invariant is
|
||||||
|
-- enforced in Go (SetEventReminder), not a constraint. No ON DELETE CASCADE here —
|
||||||
|
-- this DB never enables PRAGMA foreign_keys (same as every other REFERENCES in this
|
||||||
|
-- schema), so deleting an event explicitly deletes its reminder row too, in Go
|
||||||
|
-- (DeleteEvent/DeleteEventByUID), rather than relying on a cascade that wouldn't fire.
|
||||||
|
CREATE TABLE IF NOT EXISTS esrv_mailbox_calendar_reminders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_id INTEGER NOT NULL REFERENCES esrv_mailbox_calendar_events(id),
|
||||||
|
minutes_before INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
-- Simple first-match-wins filter rules, evaluated in priority order (lower first) at
|
-- Simple first-match-wins filter rules, evaluated in priority order (lower first) at
|
||||||
-- delivery time, before a message is encrypted and stored — so from/to/subject/body
|
-- delivery time, before a message is encrypted and stored — so from/to/subject/body
|
||||||
-- matching works against the real message, not just the plaintext cache columns below.
|
-- matching works against the real message, not just the plaintext cache columns below.
|
||||||
@@ -332,7 +457,7 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
|
|||||||
condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject','body','has_attachment','recipient_type')),
|
condition_field TEXT NOT NULL CHECK(condition_field IN ('from','to','subject','body','has_attachment','recipient_type')),
|
||||||
condition_op TEXT NOT NULL CHECK(condition_op IN ('contains','equals','starts_with')),
|
condition_op TEXT NOT NULL CHECK(condition_op IN ('contains','equals','starts_with')),
|
||||||
condition_value TEXT NOT NULL,
|
condition_value TEXT NOT NULL,
|
||||||
action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read','mark_as_spam','forward')),
|
action TEXT NOT NULL CHECK(action IN ('move_to_folder','delete','mark_read','mark_as_spam','forward','auto_reply')),
|
||||||
action_value TEXT NOT NULL DEFAULT '',
|
action_value TEXT NOT NULL DEFAULT '',
|
||||||
action_options_json TEXT NOT NULL DEFAULT '',
|
action_options_json TEXT NOT NULL DEFAULT '',
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -341,6 +466,20 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_filter_rules (
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Loop/storm prevention for the 'auto_reply' filter-rule action: one row per
|
||||||
|
-- (mailbox, sender) auto-reply actually sent, checked before sending another —
|
||||||
|
-- skipped if this mailbox already auto-replied to this sender within the last 24h
|
||||||
|
-- (fixed window, not admin-configurable, matching how most mainstream vacation
|
||||||
|
-- responders behave by default). Without this, two auto-responders emailing each
|
||||||
|
-- other would loop forever.
|
||||||
|
CREATE TABLE IF NOT EXISTS esrv_mailbox_autoreply_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||||
|
sender_addr TEXT NOT NULL,
|
||||||
|
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_autoreply_log_mailbox_sender ON esrv_mailbox_autoreply_log(mailbox_id, sender_addr, sent_at);
|
||||||
|
|
||||||
-- Senders a mailbox owner has explicitly said to always show remote images from
|
-- Senders a mailbox owner has explicitly said to always show remote images from
|
||||||
-- (esrv_mailboxes.remote_images_mode = 'trusted') — added either from the account
|
-- (esrv_mailboxes.remote_images_mode = 'trusted') — added either from the account
|
||||||
-- settings page or via the "always allow images from this sender" checkbox offered
|
-- settings page or via the "always allow images from this sender" checkbox offered
|
||||||
@@ -583,6 +722,28 @@ func migrateAddedColumns(db *sql.DB) {
|
|||||||
// plain columns and migrate fine here.
|
// plain columns and migrate fine here.
|
||||||
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN action_options_json TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE esrv_mailbox_filter_rules ADD COLUMN action_options_json TEXT NOT NULL DEFAULT ''`,
|
||||||
|
// Default 'all' here is deliberate backward compatibility: every pre-existing
|
||||||
|
// allow-list entry keeps its current full-bypass behavior after this migration.
|
||||||
|
// New entries get 'spam' as their default going forward — see webmailBlocklistAdd.
|
||||||
|
`ALTER TABLE esrv_mailbox_allowblock ADD COLUMN scope TEXT NOT NULL DEFAULT 'all'`,
|
||||||
|
`ALTER TABLE esrv_mailbox_contacts ADD COLUMN uid TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE esrv_mailbox_contacts ADD COLUMN given_name TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE esrv_mailbox_contacts ADD COLUMN family_name TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE esrv_mailbox_contacts ADD COLUMN org TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE esrv_mailbox_contacts ADD COLUMN updated_at DATETIME`,
|
||||||
|
`ALTER TABLE esrv_domains ADD COLUMN catchall_mailbox_id INTEGER REFERENCES esrv_mailboxes(id)`,
|
||||||
|
`ALTER TABLE esrv_domains ADD COLUMN send_rate_limit_per_hour INTEGER`,
|
||||||
|
`ALTER TABLE esrv_mailbox_sessions ADD COLUMN user_agent TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE esrv_mailbox_sessions ADD COLUMN ip_address TEXT NOT NULL DEFAULT ''`,
|
||||||
|
`ALTER TABLE esrv_domains ADD COLUMN mta_sts_mode TEXT NOT NULL DEFAULT 'testing'`,
|
||||||
|
`ALTER TABLE esrv_mailboxes ADD COLUMN forward_to TEXT`,
|
||||||
|
`ALTER TABLE esrv_mailboxes ADD COLUMN forward_keep_copy INTEGER NOT NULL DEFAULT 1`,
|
||||||
|
`ALTER TABLE esrv_domains ADD COLUMN caldav_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE esrv_domains ADD COLUMN carddav_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE esrv_mailboxes ADD COLUMN caldav_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE esrv_mailboxes ADD COLUMN carddav_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE esrv_domains ADD COLUMN dkim_dns_automation TEXT NOT NULL DEFAULT 'manual'`,
|
||||||
|
`ALTER TABLE esrv_domains ADD COLUMN use_global_dkim INTEGER NOT NULL DEFAULT 0`,
|
||||||
}
|
}
|
||||||
// The three old columns above were NOT NULL with no default, so simply adding
|
// The three old columns above were NOT NULL with no default, so simply adding
|
||||||
// key_pem left them behind still blocking every new insert (which only ever sets
|
// key_pem left them behind still blocking every new insert (which only ever sets
|
||||||
@@ -603,7 +764,9 @@ func migrateAddedColumns(db *sql.DB) {
|
|||||||
migrateSpamRenamedToJunk(db)
|
migrateSpamRenamedToJunk(db)
|
||||||
migrateFilterRulesMarkAsSpamCheck(db)
|
migrateFilterRulesMarkAsSpamCheck(db)
|
||||||
migrateFilterRulesAdvancedCheck(db)
|
migrateFilterRulesAdvancedCheck(db)
|
||||||
|
migrateFilterRulesAutoReplyCheck(db)
|
||||||
migrateAllowBlockJunkCheck(db)
|
migrateAllowBlockJunkCheck(db)
|
||||||
|
migrateContactUIDs(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateAllowBlockJunkCheck rebuilds esrv_mailbox_allowblock for any DB created
|
// migrateAllowBlockJunkCheck rebuilds esrv_mailbox_allowblock for any DB created
|
||||||
@@ -624,8 +787,8 @@ func migrateAllowBlockJunkCheck(db *sql.DB) {
|
|||||||
if _, err := db.Exec(schema); err != nil {
|
if _, err := db.Exec(schema); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
db.Exec(`INSERT INTO esrv_mailbox_allowblock (id, mailbox_id, list_type, pattern, created_at)
|
db.Exec(`INSERT INTO esrv_mailbox_allowblock (id, mailbox_id, list_type, pattern, scope, created_at)
|
||||||
SELECT id, mailbox_id, list_type, pattern, created_at FROM esrv_mailbox_allowblock_old`)
|
SELECT id, mailbox_id, list_type, pattern, scope, created_at FROM esrv_mailbox_allowblock_old`)
|
||||||
db.Exec(`DROP TABLE esrv_mailbox_allowblock_old`)
|
db.Exec(`DROP TABLE esrv_mailbox_allowblock_old`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,6 +849,33 @@ func migrateFilterRulesAdvancedCheck(db *sql.DB) {
|
|||||||
db.Exec(`DROP TABLE esrv_mailbox_filter_rules_old`)
|
db.Exec(`DROP TABLE esrv_mailbox_filter_rules_old`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateFilterRulesAutoReplyCheck rebuilds esrv_mailbox_filter_rules for any DB
|
||||||
|
// created before 'auto_reply' was added to the action CHECK constraint — same
|
||||||
|
// rename/recreate/copy/drop approach as migrateFilterRulesAdvancedCheck above,
|
||||||
|
// deliberately run after it for the same reason (a DB that predates every migration
|
||||||
|
// gets caught up by each in turn without any of them needing to know about the
|
||||||
|
// others' column set).
|
||||||
|
func migrateFilterRulesAutoReplyCheck(db *sql.DB) {
|
||||||
|
var tableSQL string
|
||||||
|
if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'esrv_mailbox_filter_rules'`).Scan(&tableSQL); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.Contains(tableSQL, "'auto_reply'") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`ALTER TABLE esrv_mailbox_filter_rules RENAME TO esrv_mailbox_filter_rules_old`); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Exec(`INSERT INTO esrv_mailbox_filter_rules
|
||||||
|
(id, mailbox_id, name, priority, condition_field, condition_op, condition_value, action, action_value, action_options_json, is_active, conditions_json, match_type, created_at)
|
||||||
|
SELECT id, mailbox_id, name, priority, condition_field, condition_op, condition_value, action, action_value, action_options_json, is_active, conditions_json, match_type, created_at
|
||||||
|
FROM esrv_mailbox_filter_rules_old`)
|
||||||
|
db.Exec(`DROP TABLE esrv_mailbox_filter_rules_old`)
|
||||||
|
}
|
||||||
|
|
||||||
// migrateSpamRenamedToJunk renames the standard "Spam" folder to "Junk" for mailboxes
|
// migrateSpamRenamedToJunk renames the standard "Spam" folder to "Junk" for mailboxes
|
||||||
// that already had messages/records under the old name — "Junk" is what most desktop
|
// that already had messages/records under the old name — "Junk" is what most desktop
|
||||||
// IMAP clients look for by name (see db.StandardMailboxFolders' doc comment). Always
|
// IMAP clients look for by name (see db.StandardMailboxFolders' doc comment). Always
|
||||||
@@ -702,6 +892,28 @@ func migrateSpamRenamedToJunk(db *sql.DB) {
|
|||||||
db.Exec(`UPDATE esrv_mailbox_folders SET parent_root = 'Junk' WHERE parent_root = 'Spam'`)
|
db.Exec(`UPDATE esrv_mailbox_folders SET parent_root = 'Junk' WHERE parent_root = 'Spam'`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateContactUIDs backfills a stable CardDAV resource UID (and updated_at) for any
|
||||||
|
// contact row created before the CardDAV feature existed — new rows get theirs at
|
||||||
|
// insert time (see crud_mailbox_contacts.go), so this only ever touches leftover rows
|
||||||
|
// with the ALTER-added column's zero value.
|
||||||
|
func migrateContactUIDs(db *sql.DB) {
|
||||||
|
rows, err := db.Query(`SELECT id FROM esrv_mailbox_contacts WHERE uid = ''`)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if rows.Scan(&id) == nil {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
for _, id := range ids {
|
||||||
|
db.Exec(`UPDATE esrv_mailbox_contacts SET uid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, generateContactUID(), id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DB wraps *sql.DB with the query helpers below.
|
// DB wraps *sql.DB with the query helpers below.
|
||||||
type DB struct {
|
type DB struct {
|
||||||
*sql.DB
|
*sql.DB
|
||||||
|
|||||||
+112
-2
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
msgdkim "github.com/emersion/go-msgauth/dkim"
|
msgdkim "github.com/emersion/go-msgauth/dkim"
|
||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/dnspublish"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FixedHeaders is the exact 8-header list DKIM signs over, in this fixed order,
|
// FixedHeaders is the exact 8-header list DKIM signs over, in this fixed order,
|
||||||
@@ -100,6 +101,100 @@ func (m *Manager) GenerateDKIMKeypair(domainName, selector string, forceNewKey b
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateAndPublish wraps GenerateDKIMKeypair with an optional auto-publish step: if
|
||||||
|
// the domain's dkim_dns_automation is "automatic" and DNS credentials are configured
|
||||||
|
// for it, pushes the resulting TXT record via dnspublish.SetTXTRecord using this
|
||||||
|
// domain's own credentials (esrv_domain_dns_credentials). Publish failure never blocks
|
||||||
|
// key generation — created/err reflect GenerateDKIMKeypair's own outcome regardless;
|
||||||
|
// publishErr is returned separately so the caller can show a "generated, but publish
|
||||||
|
// failed — update DNS manually" warning instead of a silent gap (same fail-open
|
||||||
|
// posture as virus scanning/MTA-STS elsewhere in this codebase).
|
||||||
|
func (m *Manager) GenerateAndPublish(domainName, selector string, forceNewKey bool) (created bool, publishErr error, err error) {
|
||||||
|
created, err = m.GenerateDKIMKeypair(domainName, selector, forceNewKey)
|
||||||
|
if err != nil || !created {
|
||||||
|
return created, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dom, err := m.DB.GetDomainByNameExact(domainName)
|
||||||
|
if err != nil || dom == nil || dom.DKIMDNSAutomation != "automatic" {
|
||||||
|
return created, nil, nil
|
||||||
|
}
|
||||||
|
creds, err := m.DB.GetDomainDNSCredentials(dom.ID)
|
||||||
|
if err != nil || creds == nil {
|
||||||
|
return created, nil, nil
|
||||||
|
}
|
||||||
|
rec, err := m.GetDKIMPublicKeyRecord(domainName)
|
||||||
|
if err != nil || rec == nil {
|
||||||
|
return created, err, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
publishErr = dnspublish.SetTXTRecord(dnspublish.Credentials{
|
||||||
|
Provider: creds.Provider, ZoneName: creds.ZoneName,
|
||||||
|
CloudflareAPIToken: creds.CloudflareAPIToken,
|
||||||
|
Route53AccessKeyID: creds.Route53AccessKeyID,
|
||||||
|
Route53SecretAccessKey: creds.Route53SecretAccessKey,
|
||||||
|
Route53Region: creds.Route53Region,
|
||||||
|
DigitalOceanAPIToken: creds.DigitalOceanAPIToken,
|
||||||
|
GCloudProject: creds.GCloudProject,
|
||||||
|
GCloudServiceAccountJSON: creds.GCloudServiceAccountJSON,
|
||||||
|
}, rec.Name, rec.Value)
|
||||||
|
return created, publishErr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveGlobalDKIMKey returns the current shared/global key, or nil if none has
|
||||||
|
// been generated yet — see schema.go's esrv_global_dkim_key comment.
|
||||||
|
func (m *Manager) GetActiveGlobalDKIMKey() (*db.DKIMKey, error) {
|
||||||
|
return m.DB.GetActiveGlobalDKIMKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlobalDKIMPublicKeyRecord mirrors GetDKIMPublicKeyRecord, but for the shared/global
|
||||||
|
// key at "<selector>._domainkey.<hostname>" instead of a domain's own record.
|
||||||
|
func (m *Manager) GlobalDKIMPublicKeyRecord(hostname string) (*DNSRecord, error) {
|
||||||
|
key, err := m.GetActiveGlobalDKIMKey()
|
||||||
|
if err != nil || key == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw := rawBase64FromPEM(key.PublicKey)
|
||||||
|
return &DNSRecord{
|
||||||
|
Name: fmt.Sprintf("%s._domainkey.%s", key.Selector, hostname),
|
||||||
|
Type: "TXT",
|
||||||
|
Value: fmt.Sprintf(`"v=DKIM1; k=rsa; p=%s"`, raw),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateGlobalDKIMKey (re)generates the one shared/global DKIM key — deactivates
|
||||||
|
// whatever was active first, same convention as GenerateDKIMKeypair — and, if hostname
|
||||||
|
// and creds are both given (the operator has configured [DKIM] global_dkim_hostname
|
||||||
|
// and a DNS provider), publishes its TXT record at
|
||||||
|
// "<selector>._domainkey.<hostname>" via dnspublish.SetTXTRecord. Publish failure
|
||||||
|
// fails open (the key is still regenerated; the caller gets the publish error back
|
||||||
|
// separately), same posture as GenerateAndPublish.
|
||||||
|
func (m *Manager) GenerateGlobalDKIMKey(hostname string, creds *dnspublish.Credentials) (publishErr error, err error) {
|
||||||
|
priv, err := rsa.GenerateKey(rand.Reader, m.KeySize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
privPEM, pubPEM, err := encodeKeyPair(priv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
selector := GenerateSelector()
|
||||||
|
if err := m.DB.DeactivateActiveGlobalDKIMKey(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := m.DB.InsertGlobalDKIMKey(selector, privPEM, pubPEM); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if hostname == "" || creds == nil || creds.Provider == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
name := selector + "._domainkey." + hostname
|
||||||
|
value := fmt.Sprintf(`"v=DKIM1; k=rsa; p=%s"`, rawBase64FromPEM(pubPEM))
|
||||||
|
publishErr = dnspublish.SetTXTRecord(*creds, name, value)
|
||||||
|
return publishErr, nil
|
||||||
|
}
|
||||||
|
|
||||||
func encodeKeyPair(priv *rsa.PrivateKey) (privPEM, pubPEM string, err error) {
|
func encodeKeyPair(priv *rsa.PrivateKey) (privPEM, pubPEM string, err error) {
|
||||||
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
|
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,13 +210,28 @@ func encodeKeyPair(priv *rsa.PrivateKey) (privPEM, pubPEM string, err error) {
|
|||||||
return privPEM, pubPEM, nil
|
return privPEM, pubPEM, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveDKIMKey mirrors DKIMManager.get_active_dkim_key.
|
// GetActiveDKIMKey mirrors DKIMManager.get_active_dkim_key. When the domain has opted
|
||||||
|
// into the shared/global DKIM key (UseGlobalDKIM), the returned key's PrivateKey/
|
||||||
|
// PublicKey are swapped for the active global key's — Selector stays the domain's own,
|
||||||
|
// since that's what both the outbound signature's s= tag and the customer's one-time
|
||||||
|
// CNAME source name need to keep matching. Falls back to the domain's own key material
|
||||||
|
// if no global key has been generated yet, rather than failing to sign at all.
|
||||||
func (m *Manager) GetActiveDKIMKey(domainName string) (*db.DKIMKey, error) {
|
func (m *Manager) GetActiveDKIMKey(domainName string) (*db.DKIMKey, error) {
|
||||||
dom, err := m.DB.GetDomainByName(domainName)
|
dom, err := m.DB.GetDomainByName(domainName)
|
||||||
if err != nil || dom == nil {
|
if err != nil || dom == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return m.DB.GetActiveDKIMKeyByDomainID(dom.ID)
|
key, err := m.DB.GetActiveDKIMKeyByDomainID(dom.ID)
|
||||||
|
if err != nil || key == nil || !dom.UseGlobalDKIM {
|
||||||
|
return key, err
|
||||||
|
}
|
||||||
|
global, err := m.DB.GetActiveGlobalDKIMKey()
|
||||||
|
if err != nil || global == nil {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
key.PrivateKey = global.PrivateKey
|
||||||
|
key.PublicKey = global.PublicKey
|
||||||
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNSRecord is the DNS TXT record for a domain's active DKIM key, mirroring
|
// DNSRecord is the DNS TXT record for a domain's active DKIM key, mirroring
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/dnspublish"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openTestDKIMDB(t *testing.T) *db.DB {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.CreateTemp("", "dkim-publish-test-*.db")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
t.Cleanup(func() { os.Remove(f.Name()) })
|
||||||
|
database, err := db.Open(f.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
return database
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateAndPublishManualModeNeverCallsDNSAPI confirms the default ("manual")
|
||||||
|
// mode behaves exactly like GenerateDKIMKeypair — no publish attempt at all — even
|
||||||
|
// with DNS credentials configured, since automation itself is off.
|
||||||
|
func TestGenerateAndPublishManualModeNeverCallsDNSAPI(t *testing.T) {
|
||||||
|
database := openTestDKIMDB(t)
|
||||||
|
domainID, err := database.CreateDomain("manual.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.SetDomainDNSCredentials(db.DomainDNSCredentials{
|
||||||
|
DomainID: domainID, Provider: "cloudflare", ZoneName: "manual.example", CloudflareAPIToken: "tok",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := New(database, 1024)
|
||||||
|
created, publishErr, err := mgr.GenerateAndPublish("manual.example", "", true)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("GenerateAndPublish: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
if publishErr != nil {
|
||||||
|
t.Fatalf("expected no publish attempt in manual mode, got publishErr=%v", publishErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateAndPublishAutomaticModeNoCredentialsSkipsSilently confirms "automatic"
|
||||||
|
// mode with no credentials row configured yet behaves like manual mode (skip, no
|
||||||
|
// error) rather than failing key generation.
|
||||||
|
func TestGenerateAndPublishAutomaticModeNoCredentialsSkipsSilently(t *testing.T) {
|
||||||
|
database := openTestDKIMDB(t)
|
||||||
|
domainID, err := database.CreateDomain("noauth.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.SetDomainDKIMDNSAutomation(domainID, "automatic"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := New(database, 1024)
|
||||||
|
created, publishErr, err := mgr.GenerateAndPublish("noauth.example", "", true)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("GenerateAndPublish: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
if publishErr != nil {
|
||||||
|
t.Fatalf("expected no publish attempt with no credentials configured, got publishErr=%v", publishErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateAndPublishAutomaticModePublishesRealRecord confirms automatic mode with
|
||||||
|
// real (Cloudflare) credentials actually publishes the domain's own DKIM TXT record —
|
||||||
|
// against a fake httptest.Server via dnspublish's exported test override, not a mock —
|
||||||
|
// and that key generation still succeeds regardless of the publish outcome.
|
||||||
|
func TestGenerateAndPublishAutomaticModePublishesRealRecord(t *testing.T) {
|
||||||
|
var gotValue string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/zones":
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[{"id":"zone1"}]}`))
|
||||||
|
case r.URL.Path == "/zones/zone1/dns_records" && r.Method == http.MethodGet:
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[]}`))
|
||||||
|
case r.URL.Path == "/zones/zone1/dns_records" && r.Method == http.MethodPost:
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
gotValue = string(body)
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":{"id":"rec1"}}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
orig := dnspublish.CloudflareAPIBase
|
||||||
|
dnspublish.CloudflareAPIBase = srv.URL
|
||||||
|
defer func() { dnspublish.CloudflareAPIBase = orig }()
|
||||||
|
|
||||||
|
database := openTestDKIMDB(t)
|
||||||
|
domainID, err := database.CreateDomain("auto.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.SetDomainDKIMDNSAutomation(domainID, "automatic"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.SetDomainDNSCredentials(db.DomainDNSCredentials{
|
||||||
|
DomainID: domainID, Provider: "cloudflare", ZoneName: "auto.example", CloudflareAPIToken: "tok",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := New(database, 1024)
|
||||||
|
created, publishErr, err := mgr.GenerateAndPublish("auto.example", "sel1", true)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("GenerateAndPublish: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
if publishErr != nil {
|
||||||
|
t.Fatalf("expected the publish to succeed, got %v", publishErr)
|
||||||
|
}
|
||||||
|
if gotValue == "" {
|
||||||
|
t.Fatal("expected the record body to have been captured")
|
||||||
|
}
|
||||||
|
if want := "sel1._domainkey.auto.example"; !strings.Contains(gotValue, want) {
|
||||||
|
t.Fatalf("expected the publish request body to contain %q, got %q", want, gotValue)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotValue, "v=DKIM1") {
|
||||||
|
t.Fatalf("expected the publish request body to contain the DKIM value, got %q", gotValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateAndPublishFailsOpenOnBadProvider confirms an invalid/unset provider in
|
||||||
|
// automatic mode surfaces publishErr but still leaves the key generated (fail-open).
|
||||||
|
func TestGenerateAndPublishFailsOpenOnBadProvider(t *testing.T) {
|
||||||
|
database := openTestDKIMDB(t)
|
||||||
|
domainID, err := database.CreateDomain("badprovider.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.SetDomainDKIMDNSAutomation(domainID, "automatic"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.SetDomainDNSCredentials(db.DomainDNSCredentials{
|
||||||
|
DomainID: domainID, Provider: "not-a-real-provider", ZoneName: "badprovider.example",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mgr := New(database, 1024)
|
||||||
|
created, publishErr, err := mgr.GenerateAndPublish("badprovider.example", "", true)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("expected key generation to still succeed, got created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
if publishErr == nil {
|
||||||
|
t.Fatal("expected a publish error for an unknown provider")
|
||||||
|
}
|
||||||
|
key, kerr := mgr.GetActiveDKIMKey("badprovider.example")
|
||||||
|
if kerr != nil || key == nil {
|
||||||
|
t.Fatalf("expected the key to exist despite the publish failure, got %v %v", key, kerr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/dnspublish"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGenerateGlobalDKIMKeyWithoutHostnameSkipsPublish confirms regenerating with no
|
||||||
|
// hostname/creds configured just rotates the key, no publish attempt.
|
||||||
|
func TestGenerateGlobalDKIMKeyWithoutHostnameSkipsPublish(t *testing.T) {
|
||||||
|
database := openTestDKIMDB(t)
|
||||||
|
mgr := New(database, 1024)
|
||||||
|
|
||||||
|
publishErr, err := mgr.GenerateGlobalDKIMKey("", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if publishErr != nil {
|
||||||
|
t.Fatalf("expected no publish attempt with no hostname/creds, got %v", publishErr)
|
||||||
|
}
|
||||||
|
key, err := mgr.GetActiveGlobalDKIMKey()
|
||||||
|
if err != nil || key == nil {
|
||||||
|
t.Fatalf("expected a global key to exist, got %v %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGenerateGlobalDKIMKeyPublishesAndRotates confirms a real publish happens against
|
||||||
|
// the configured hostname, and a second regenerate deactivates the first key.
|
||||||
|
func TestGenerateGlobalDKIMKeyPublishesAndRotates(t *testing.T) {
|
||||||
|
var bodies []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/zones":
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[{"id":"zone1"}]}`))
|
||||||
|
case r.URL.Path == "/zones/zone1/dns_records" && r.Method == http.MethodGet:
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[]}`))
|
||||||
|
case r.URL.Path == "/zones/zone1/dns_records" && r.Method == http.MethodPost:
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
bodies = append(bodies, string(body))
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":{"id":"rec1"}}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
orig := dnspublish.CloudflareAPIBase
|
||||||
|
dnspublish.CloudflareAPIBase = srv.URL
|
||||||
|
defer func() { dnspublish.CloudflareAPIBase = orig }()
|
||||||
|
|
||||||
|
database := openTestDKIMDB(t)
|
||||||
|
mgr := New(database, 1024)
|
||||||
|
creds := &dnspublish.Credentials{Provider: "cloudflare", ZoneName: "example.com", CloudflareAPIToken: "tok"}
|
||||||
|
|
||||||
|
publishErr, err := mgr.GenerateGlobalDKIMKey("dkim.example.com", creds)
|
||||||
|
if err != nil || publishErr != nil {
|
||||||
|
t.Fatalf("first generate: err=%v publishErr=%v", err, publishErr)
|
||||||
|
}
|
||||||
|
first, _ := mgr.GetActiveGlobalDKIMKey()
|
||||||
|
if first == nil {
|
||||||
|
t.Fatal("expected an active global key after first generate")
|
||||||
|
}
|
||||||
|
if len(bodies) != 1 || !strings.Contains(bodies[0], "_domainkey.dkim.example.com") {
|
||||||
|
t.Fatalf("expected a publish call naming the configured hostname, got %v", bodies)
|
||||||
|
}
|
||||||
|
|
||||||
|
publishErr, err = mgr.GenerateGlobalDKIMKey("dkim.example.com", creds)
|
||||||
|
if err != nil || publishErr != nil {
|
||||||
|
t.Fatalf("second generate: err=%v publishErr=%v", err, publishErr)
|
||||||
|
}
|
||||||
|
second, _ := mgr.GetActiveGlobalDKIMKey()
|
||||||
|
if second == nil || second.ID == first.ID {
|
||||||
|
t.Fatalf("expected a distinct, newly active key after rotation: first=%+v second=%+v", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetActiveDKIMKeySwapsToGlobalKeyMaterial confirms a domain with UseGlobalDKIM
|
||||||
|
// signs with the global key's material while keeping its own selector, and falls back
|
||||||
|
// to its own key when no global key exists yet.
|
||||||
|
func TestGetActiveDKIMKeySwapsToGlobalKeyMaterial(t *testing.T) {
|
||||||
|
database := openTestDKIMDB(t)
|
||||||
|
domainID, err := database.CreateDomain("useglobal.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mgr := New(database, 1024)
|
||||||
|
if ok, err := mgr.GenerateDKIMKeypair("useglobal.example", "own-sel", true); err != nil || !ok {
|
||||||
|
t.Fatalf("seed domain key: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
ownKey, _ := mgr.GetActiveDKIMKey("useglobal.example")
|
||||||
|
|
||||||
|
if err := database.SetDomainUseGlobalDKIM(domainID, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No global key generated yet — falls back to the domain's own material.
|
||||||
|
fallback, err := mgr.GetActiveDKIMKey("useglobal.example")
|
||||||
|
if err != nil || fallback == nil || fallback.PrivateKey != ownKey.PrivateKey || fallback.Selector != "own-sel" {
|
||||||
|
t.Fatalf("expected fallback to the domain's own key material, got %+v", fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := mgr.GenerateGlobalDKIMKey("", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
globalKey, _ := mgr.GetActiveGlobalDKIMKey()
|
||||||
|
|
||||||
|
swapped, err := mgr.GetActiveDKIMKey("useglobal.example")
|
||||||
|
if err != nil || swapped == nil {
|
||||||
|
t.Fatalf("GetActiveDKIMKey: %v %v", swapped, err)
|
||||||
|
}
|
||||||
|
if swapped.Selector != "own-sel" {
|
||||||
|
t.Fatalf("expected the domain's own selector preserved, got %q", swapped.Selector)
|
||||||
|
}
|
||||||
|
if swapped.PrivateKey != globalKey.PrivateKey || swapped.PublicKey != globalKey.PublicKey {
|
||||||
|
t.Fatalf("expected the global key's material substituted in, got %+v want private=%q public=%q", swapped, globalKey.PrivateKey, globalKey.PublicKey)
|
||||||
|
}
|
||||||
|
if swapped.PrivateKey == ownKey.PrivateKey {
|
||||||
|
t.Fatal("expected the domain's own private key to no longer be used once global mode is on")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package dnspublish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CloudflareAPIBase is overridden by tests to point at a fake httptest.Server instead
|
||||||
|
// of the real Cloudflare API.
|
||||||
|
var CloudflareAPIBase = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
|
type cloudflareError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cloudflareResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Errors []cloudflareError `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloudflareDo(req *http.Request, token string, out any) error {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTXTRecordCloudflare resolves creds.ZoneName to its Cloudflare zone ID, then
|
||||||
|
// creates a new TXT record or updates an existing one with the same name.
|
||||||
|
func setTXTRecordCloudflare(creds Credentials, recordFQDN, value string) error {
|
||||||
|
var zones struct {
|
||||||
|
cloudflareResponse
|
||||||
|
Result []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, CloudflareAPIBase+"/zones?name="+creds.ZoneName, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cloudflareDo(req, creds.CloudflareAPIToken, &zones); err != nil {
|
||||||
|
return fmt.Errorf("cloudflare: list zones: %w", err)
|
||||||
|
}
|
||||||
|
if !zones.Success || len(zones.Result) == 0 {
|
||||||
|
return fmt.Errorf("cloudflare: no zone found for %q (errors: %v)", creds.ZoneName, zones.Errors)
|
||||||
|
}
|
||||||
|
zoneID := zones.Result[0].ID
|
||||||
|
|
||||||
|
var existing struct {
|
||||||
|
cloudflareResponse
|
||||||
|
Result []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
req, err = http.NewRequest(http.MethodGet, CloudflareAPIBase+"/zones/"+zoneID+"/dns_records?type=TXT&name="+recordFQDN, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cloudflareDo(req, creds.CloudflareAPIToken, &existing); err != nil {
|
||||||
|
return fmt.Errorf("cloudflare: list existing records: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]any{
|
||||||
|
"type": "TXT", "name": recordFQDN, "content": value, "ttl": txtTTL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result cloudflareResponse
|
||||||
|
if existing.Success && len(existing.Result) > 0 {
|
||||||
|
req, err = http.NewRequest(http.MethodPut, CloudflareAPIBase+"/zones/"+zoneID+"/dns_records/"+existing.Result[0].ID, bytes.NewReader(body))
|
||||||
|
} else {
|
||||||
|
req, err = http.NewRequest(http.MethodPost, CloudflareAPIBase+"/zones/"+zoneID+"/dns_records", bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cloudflareDo(req, creds.CloudflareAPIToken, &result); err != nil {
|
||||||
|
return fmt.Errorf("cloudflare: upsert TXT record: %w", err)
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
return fmt.Errorf("cloudflare: upsert TXT record failed: %v", result.Errors)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package dnspublish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSetTXTRecordCloudflareCreatesWhenNoneExists confirms the zone-lookup + "no
|
||||||
|
// existing record" path issues a POST with the exact name/value expected.
|
||||||
|
func TestSetTXTRecordCloudflareCreatesWhenNoneExists(t *testing.T) {
|
||||||
|
var gotMethod, gotPath string
|
||||||
|
var gotBody map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/zones") && r.URL.RawQuery == "name=example.com":
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[{"id":"zone123"}]}`))
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/zones/zone123/dns_records":
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[]}`))
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/zones/zone123/dns_records":
|
||||||
|
gotMethod, gotPath = r.Method, r.URL.Path
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
json.Unmarshal(body, &gotBody)
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
|
||||||
|
t.Errorf("Authorization header = %q, want Bearer test-token", got)
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":{"id":"rec1"}}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.String())
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
orig := CloudflareAPIBase
|
||||||
|
CloudflareAPIBase = srv.URL
|
||||||
|
defer func() { CloudflareAPIBase = orig }()
|
||||||
|
|
||||||
|
err := SetTXTRecord(Credentials{Provider: "cloudflare", ZoneName: "example.com", CloudflareAPIToken: "test-token"},
|
||||||
|
"selector._domainkey.example.com", `"v=DKIM1; k=rsa; p=abc123"`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotMethod != http.MethodPost || gotPath != "/zones/zone123/dns_records" {
|
||||||
|
t.Fatalf("expected a POST to create the record, got %s %s", gotMethod, gotPath)
|
||||||
|
}
|
||||||
|
if gotBody["name"] != "selector._domainkey.example.com" || gotBody["content"] != `"v=DKIM1; k=rsa; p=abc123"` || gotBody["type"] != "TXT" {
|
||||||
|
t.Fatalf("unexpected request body: %+v", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetTXTRecordCloudflareUpdatesExistingRecord confirms an already-existing record
|
||||||
|
// with the same name is updated (PUT to its own id), not duplicated.
|
||||||
|
func TestSetTXTRecordCloudflareUpdatesExistingRecord(t *testing.T) {
|
||||||
|
var gotMethod, gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/zones") && r.URL.RawQuery == "name=example.com":
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[{"id":"zone123"}]}`))
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/zones/zone123/dns_records":
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[{"id":"existing-rec"}]}`))
|
||||||
|
case r.Method == http.MethodPut && r.URL.Path == "/zones/zone123/dns_records/existing-rec":
|
||||||
|
gotMethod, gotPath = r.Method, r.URL.Path
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":{"id":"existing-rec"}}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.String())
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
orig := CloudflareAPIBase
|
||||||
|
CloudflareAPIBase = srv.URL
|
||||||
|
defer func() { CloudflareAPIBase = orig }()
|
||||||
|
|
||||||
|
err := SetTXTRecord(Credentials{Provider: "cloudflare", ZoneName: "example.com", CloudflareAPIToken: "test-token"},
|
||||||
|
"selector._domainkey.example.com", `"v=DKIM1; k=rsa; p=xyz789"`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotMethod != http.MethodPut || gotPath != "/zones/zone123/dns_records/existing-rec" {
|
||||||
|
t.Fatalf("expected a PUT to update the existing record, got %s %s", gotMethod, gotPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetTXTRecordCloudflareNoZoneFound confirms a missing zone surfaces a clear error
|
||||||
|
// instead of silently failing or panicking.
|
||||||
|
func TestSetTXTRecordCloudflareNoZoneFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
orig := CloudflareAPIBase
|
||||||
|
CloudflareAPIBase = srv.URL
|
||||||
|
defer func() { CloudflareAPIBase = orig }()
|
||||||
|
|
||||||
|
err := SetTXTRecord(Credentials{Provider: "cloudflare", ZoneName: "nope.example", CloudflareAPIToken: "t"},
|
||||||
|
"selector._domainkey.nope.example", `"v=DKIM1"`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when no zone matches")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package dnspublish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DigitalOceanAPIBase is overridden by tests to point at a fake httptest.Server
|
||||||
|
// instead of the real DigitalOcean API.
|
||||||
|
var DigitalOceanAPIBase = "https://api.digitalocean.com/v2"
|
||||||
|
|
||||||
|
// digitaloceanRelativeName converts a full record name into one relative to the zone,
|
||||||
|
// which is what DigitalOcean's API expects (e.g. "selector._domainkey" for zone
|
||||||
|
// "example.com", record "selector._domainkey.example.com"; "@" for the zone apex
|
||||||
|
// itself). recordFQDN must be recordFQDN==zoneName or a subdomain of it.
|
||||||
|
func digitaloceanRelativeName(recordFQDN, zoneName string) (string, error) {
|
||||||
|
recordFQDN = strings.TrimSuffix(recordFQDN, ".")
|
||||||
|
zoneName = strings.TrimSuffix(zoneName, ".")
|
||||||
|
if recordFQDN == zoneName {
|
||||||
|
return "@", nil
|
||||||
|
}
|
||||||
|
suffix := "." + zoneName
|
||||||
|
if !strings.HasSuffix(recordFQDN, suffix) {
|
||||||
|
return "", fmt.Errorf("digitalocean: record %q is not under zone %q", recordFQDN, zoneName)
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(recordFQDN, suffix), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func digitaloceanDo(req *http.Request, token string, out any) (int, error) {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if out != nil {
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||||
|
return resp.StatusCode, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTXTRecordDigitalOcean(creds Credentials, recordFQDN, value string) error {
|
||||||
|
name, err := digitaloceanRelativeName(recordFQDN, creds.ZoneName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var list struct {
|
||||||
|
DomainRecords []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"domain_records"`
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, DigitalOceanAPIBase+"/domains/"+creds.ZoneName+"/records?type=TXT&name="+name, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status, err := digitaloceanDo(req, creds.DigitalOceanAPIToken, &list)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("digitalocean: list existing records: %w", err)
|
||||||
|
}
|
||||||
|
if status >= 300 {
|
||||||
|
return fmt.Errorf("digitalocean: list existing records: status %d", status)
|
||||||
|
}
|
||||||
|
var existingID int64
|
||||||
|
for _, r := range list.DomainRecords {
|
||||||
|
if r.Type == "TXT" && r.Name == name {
|
||||||
|
existingID = r.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(map[string]any{"type": "TXT", "name": name, "data": value, "ttl": txtTTL})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingID != 0 {
|
||||||
|
req, err = http.NewRequest(http.MethodPut, fmt.Sprintf("%s/domains/%s/records/%d", DigitalOceanAPIBase, creds.ZoneName, existingID), bytes.NewReader(body))
|
||||||
|
} else {
|
||||||
|
req, err = http.NewRequest(http.MethodPost, DigitalOceanAPIBase+"/domains/"+creds.ZoneName+"/records", bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status, err = digitaloceanDo(req, creds.DigitalOceanAPIToken, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("digitalocean: upsert TXT record: %w", err)
|
||||||
|
}
|
||||||
|
if status >= 300 {
|
||||||
|
return fmt.Errorf("digitalocean: upsert TXT record: status %d", status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package dnspublish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDigitaloceanRelativeName(t *testing.T) {
|
||||||
|
cases := []struct{ fqdn, zone, want string }{
|
||||||
|
{"selector._domainkey.example.com", "example.com", "selector._domainkey"},
|
||||||
|
{"example.com", "example.com", "@"},
|
||||||
|
{"selector._domainkey.example.com.", "example.com.", "selector._domainkey"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := digitaloceanRelativeName(c.fqdn, c.zone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s/%s: %v", c.fqdn, c.zone, err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("digitaloceanRelativeName(%q, %q) = %q, want %q", c.fqdn, c.zone, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := digitaloceanRelativeName("selector._domainkey.other.com", "example.com"); err == nil {
|
||||||
|
t.Error("expected an error for a record outside the zone")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetTXTRecordDigitalOceanCreatesWhenNoneExists(t *testing.T) {
|
||||||
|
var gotMethod, gotPath string
|
||||||
|
var gotBody map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/domains/example.com/records":
|
||||||
|
w.Write([]byte(`{"domain_records":[]}`))
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/domains/example.com/records":
|
||||||
|
gotMethod, gotPath = r.Method, r.URL.Path
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
json.Unmarshal(body, &gotBody)
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer do-token" {
|
||||||
|
t.Errorf("Authorization header = %q, want Bearer do-token", got)
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{"domain_record":{"id":1}}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.String())
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
orig := DigitalOceanAPIBase
|
||||||
|
DigitalOceanAPIBase = srv.URL
|
||||||
|
defer func() { DigitalOceanAPIBase = orig }()
|
||||||
|
|
||||||
|
err := SetTXTRecord(Credentials{Provider: "digitalocean", ZoneName: "example.com", DigitalOceanAPIToken: "do-token"},
|
||||||
|
"selector._domainkey.example.com", `"v=DKIM1; k=rsa; p=abc"`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotMethod != http.MethodPost || gotPath != "/domains/example.com/records" {
|
||||||
|
t.Fatalf("expected a POST to create the record, got %s %s", gotMethod, gotPath)
|
||||||
|
}
|
||||||
|
if gotBody["name"] != "selector._domainkey" || gotBody["data"] != `"v=DKIM1; k=rsa; p=abc"` {
|
||||||
|
t.Fatalf("unexpected request body: %+v", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetTXTRecordDigitalOceanUpdatesExisting(t *testing.T) {
|
||||||
|
var gotMethod, gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/domains/example.com/records":
|
||||||
|
w.Write([]byte(`{"domain_records":[{"id":42,"type":"TXT","name":"selector._domainkey"}]}`))
|
||||||
|
case r.Method == http.MethodPut && r.URL.Path == "/domains/example.com/records/42":
|
||||||
|
gotMethod, gotPath = r.Method, r.URL.Path
|
||||||
|
w.Write([]byte(`{"domain_record":{"id":42}}`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.String())
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
orig := DigitalOceanAPIBase
|
||||||
|
DigitalOceanAPIBase = srv.URL
|
||||||
|
defer func() { DigitalOceanAPIBase = orig }()
|
||||||
|
|
||||||
|
err := SetTXTRecord(Credentials{Provider: "digitalocean", ZoneName: "example.com", DigitalOceanAPIToken: "do-token"},
|
||||||
|
"selector._domainkey.example.com", `"v=DKIM1"`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotMethod != http.MethodPut || gotPath != "/domains/example.com/records/42" {
|
||||||
|
t.Fatalf("expected a PUT to update the existing record, got %s %s", gotMethod, gotPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Package dnspublish publishes an arbitrary-name/arbitrary-value DNS TXT record via one
|
||||||
|
// of four provider APIs (Cloudflare, Route53, DigitalOcean, Google Cloud DNS) — used to
|
||||||
|
// automate what would otherwise be an admin manually pasting a DKIM TXT record into
|
||||||
|
// their DNS provider's own UI. Deliberately separate from internal/acmecert's DNS-01
|
||||||
|
// provider wiring: go-acme/lego's challenge.Provider interface is hardcoded to
|
||||||
|
// "_acme-challenge.<domain>" naming and an ACME-specific value (confirmed by reading
|
||||||
|
// lego's dns01.GetChallengeInfo and a concrete provider), so it can't be reused for a
|
||||||
|
// record with an arbitrary name and value like a DKIM record.
|
||||||
|
package dnspublish
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Credentials selects a provider and holds every provider's own field set — fields
|
||||||
|
// unrelated to the selected Provider are simply ignored. Mirrors the flat-struct shape
|
||||||
|
// internal/config's [LetsEncrypt] section already uses for the same reason: these are
|
||||||
|
// admin-entered settings where only one provider is ever configured at a time.
|
||||||
|
type Credentials struct {
|
||||||
|
Provider string // "cloudflare", "route53", "digitalocean", "gcloud"
|
||||||
|
// ZoneName is the DNS zone/domain resource the provider hosts, e.g. "example.com"
|
||||||
|
// — used to resolve that provider's own zone/domain ID before publishing.
|
||||||
|
ZoneName string
|
||||||
|
|
||||||
|
CloudflareAPIToken string
|
||||||
|
|
||||||
|
Route53AccessKeyID string
|
||||||
|
Route53SecretAccessKey string
|
||||||
|
Route53Region string
|
||||||
|
|
||||||
|
DigitalOceanAPIToken string
|
||||||
|
|
||||||
|
GCloudProject string
|
||||||
|
// GCloudServiceAccountJSON is the service account key's raw JSON content (not a
|
||||||
|
// file path) — unlike [LetsEncrypt]'s global, file-path-based setting, this needs
|
||||||
|
// to work for per-domain, DB-stored credentials too.
|
||||||
|
GCloudServiceAccountJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
// txtTTL is a fixed, low TTL for every record this package writes — not configurable
|
||||||
|
// in this pass, so a rotation's new value propagates reasonably quickly regardless of
|
||||||
|
// what the zone's other records use.
|
||||||
|
const txtTTL = 300
|
||||||
|
|
||||||
|
// SetTXTRecord creates or updates (upserts) a TXT record at recordFQDN (e.g.
|
||||||
|
// "selector._domainkey.example.com") to value, in the zone named by creds.ZoneName, via
|
||||||
|
// whichever provider creds.Provider selects.
|
||||||
|
func SetTXTRecord(creds Credentials, recordFQDN, value string) error {
|
||||||
|
switch creds.Provider {
|
||||||
|
case "cloudflare":
|
||||||
|
return setTXTRecordCloudflare(creds, recordFQDN, value)
|
||||||
|
case "route53":
|
||||||
|
return setTXTRecordRoute53(creds, recordFQDN, value)
|
||||||
|
case "digitalocean":
|
||||||
|
return setTXTRecordDigitalOcean(creds, recordFQDN, value)
|
||||||
|
case "gcloud":
|
||||||
|
return setTXTRecordGCloud(creds, recordFQDN, value)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("dnspublish: unknown or unset DNS provider %q", creds.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package dnspublish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
dnsv1 "google.golang.org/api/dns/v1"
|
||||||
|
"google.golang.org/api/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GCloudAPIBase is overridden by tests to point the client at a fake httptest.Server
|
||||||
|
// instead of the real Cloud DNS API.
|
||||||
|
var GCloudAPIBase string
|
||||||
|
|
||||||
|
// setTXTRecordGCloud resolves creds.ZoneName to its Cloud DNS managed zone (by listing
|
||||||
|
// every managed zone in the project and matching DnsName — Cloud DNS has no
|
||||||
|
// lookup-by-DNS-name call), then applies a Change: Cloud DNS has no direct upsert, so
|
||||||
|
// an existing record set with the same name/type is deleted in the same atomic change
|
||||||
|
// that adds the new one.
|
||||||
|
func setTXTRecordGCloud(creds Credentials, recordFQDN, value string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
jwtCfg, err := google.JWTConfigFromJSON([]byte(creds.GCloudServiceAccountJSON), dnsv1.NdevClouddnsReadwriteScope)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gcloud: parse service account JSON: %w", err)
|
||||||
|
}
|
||||||
|
opts := []option.ClientOption{option.WithHTTPClient(jwtCfg.Client(ctx))}
|
||||||
|
if GCloudAPIBase != "" {
|
||||||
|
opts = append(opts, option.WithEndpoint(GCloudAPIBase))
|
||||||
|
}
|
||||||
|
svc, err := dnsv1.NewService(ctx, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gcloud: create DNS client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zoneName := creds.ZoneName
|
||||||
|
if !strings.HasSuffix(zoneName, ".") {
|
||||||
|
zoneName += "."
|
||||||
|
}
|
||||||
|
zones, err := svc.ManagedZones.List(creds.GCloudProject).Do()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gcloud: list managed zones: %w", err)
|
||||||
|
}
|
||||||
|
var zoneID string
|
||||||
|
for _, z := range zones.ManagedZones {
|
||||||
|
if z.DnsName == zoneName {
|
||||||
|
zoneID = z.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zoneID == "" {
|
||||||
|
return fmt.Errorf("gcloud: no managed zone found for %q", creds.ZoneName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fqdn := recordFQDN
|
||||||
|
if !strings.HasSuffix(fqdn, ".") {
|
||||||
|
fqdn += "."
|
||||||
|
}
|
||||||
|
existing, err := svc.ResourceRecordSets.List(creds.GCloudProject, zoneID).Name(fqdn).Type("TXT").Do()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gcloud: list existing record: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
change := &dnsv1.Change{
|
||||||
|
Additions: []*dnsv1.ResourceRecordSet{{
|
||||||
|
Name: fqdn, Type: "TXT", Ttl: txtTTL, Rrdatas: []string{value},
|
||||||
|
}},
|
||||||
|
Deletions: existing.Rrsets,
|
||||||
|
}
|
||||||
|
if _, err := svc.Changes.Create(creds.GCloudProject, zoneID, change).Do(); err != nil {
|
||||||
|
return fmt.Errorf("gcloud: apply change: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package dnspublish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeGCloudServiceAccountJSON builds a syntactically valid (but not
|
||||||
|
// Google-registered) service account key whose token_uri points at tokenURL — enough
|
||||||
|
// for golang.org/x/oauth2/google.JWTConfigFromJSON to parse and for its JWT-signing
|
||||||
|
// flow to run end-to-end against a fake local token endpoint, with no real network or
|
||||||
|
// real Google credentials involved.
|
||||||
|
func fakeGCloudServiceAccountJSON(t *testing.T, tokenURL string) string {
|
||||||
|
t.Helper()
|
||||||
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
der, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "test-project",
|
||||||
|
"private_key_id": "kid1",
|
||||||
|
"private_key": string(keyPEM),
|
||||||
|
"client_email": "test@test-project.iam.gserviceaccount.com",
|
||||||
|
"token_uri": tokenURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetTXTRecordGCloudCreatesWhenNoneExists(t *testing.T) {
|
||||||
|
var gotChangePath string
|
||||||
|
var gotChangeBody map[string]any
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"access_token":"fake-token","token_type":"Bearer","expires_in":3600}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/dns/v1/projects/test-project/managedZones", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`{"managedZones":[{"name":"my-zone","dnsName":"example.com."}]}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/dns/v1/projects/test-project/managedZones/my-zone/rrsets", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`{"rrsets":[]}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/dns/v1/projects/test-project/managedZones/my-zone/changes", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotChangePath = r.URL.Path
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
json.Unmarshal(body, &gotChangeBody)
|
||||||
|
w.Write([]byte(`{"id":"1","status":"pending"}`))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
origGCloud := GCloudAPIBase
|
||||||
|
GCloudAPIBase = srv.URL
|
||||||
|
defer func() { GCloudAPIBase = origGCloud }()
|
||||||
|
|
||||||
|
creds := Credentials{
|
||||||
|
Provider: "gcloud", ZoneName: "example.com", GCloudProject: "test-project",
|
||||||
|
GCloudServiceAccountJSON: fakeGCloudServiceAccountJSON(t, srv.URL+"/token"),
|
||||||
|
}
|
||||||
|
if err := SetTXTRecord(creds, "selector._domainkey.example.com", `"v=DKIM1; k=rsa; p=abc"`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotChangePath == "" {
|
||||||
|
t.Fatal("expected a Changes.Create request, got none")
|
||||||
|
}
|
||||||
|
additions, _ := gotChangeBody["additions"].([]any)
|
||||||
|
if len(additions) != 1 {
|
||||||
|
t.Fatalf("expected exactly one addition, got %v", gotChangeBody)
|
||||||
|
}
|
||||||
|
added := additions[0].(map[string]any)
|
||||||
|
if added["name"] != "selector._domainkey.example.com." || fmt.Sprint(added["rrdatas"]) != "[\"v=DKIM1; k=rsa; p=abc\"]" {
|
||||||
|
t.Fatalf("unexpected addition: %+v", added)
|
||||||
|
}
|
||||||
|
if deletions, ok := gotChangeBody["deletions"]; ok && deletions != nil {
|
||||||
|
if arr, ok := deletions.([]any); ok && len(arr) != 0 {
|
||||||
|
t.Fatalf("expected no deletions when no record existed, got %v", deletions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetTXTRecordGCloudNoZoneFound(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`{"access_token":"fake-token","token_type":"Bearer","expires_in":3600}`))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/dns/v1/projects/test-project/managedZones", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(`{"managedZones":[]}`))
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
orig := GCloudAPIBase
|
||||||
|
GCloudAPIBase = srv.URL
|
||||||
|
defer func() { GCloudAPIBase = orig }()
|
||||||
|
|
||||||
|
creds := Credentials{
|
||||||
|
Provider: "gcloud", ZoneName: "nope.example", GCloudProject: "test-project",
|
||||||
|
GCloudServiceAccountJSON: fakeGCloudServiceAccountJSON(t, srv.URL+"/token"),
|
||||||
|
}
|
||||||
|
err := SetTXTRecord(creds, "selector._domainkey.nope.example", `"v=DKIM1"`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when no managed zone matches")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no managed zone found") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package dnspublish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/route53"
|
||||||
|
r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Route53BaseEndpoint is overridden by tests to point the SDK client at a fake
|
||||||
|
// httptest.Server instead of the real Route53 endpoint.
|
||||||
|
var Route53BaseEndpoint string
|
||||||
|
|
||||||
|
// setTXTRecordRoute53 resolves creds.ZoneName to its hosted zone ID (via
|
||||||
|
// ListHostedZonesByName), then upserts the TXT record via ChangeResourceRecordSets.
|
||||||
|
// Uses the raw AWS SDK client directly (not lego's DNS-01 provider wrapper — that
|
||||||
|
// interface is ACME-challenge-shaped, see dnspublish.go's doc comment), so this needs
|
||||||
|
// no separate hosted-zone-ID setting the way the [LetsEncrypt] section does; the zone
|
||||||
|
// name alone is enough.
|
||||||
|
func setTXTRecordRoute53(creds Credentials, recordFQDN, value string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
client := route53.New(route53.Options{
|
||||||
|
Region: creds.Route53Region,
|
||||||
|
Credentials: credentials.NewStaticCredentialsProvider(creds.Route53AccessKeyID, creds.Route53SecretAccessKey, ""),
|
||||||
|
}, func(o *route53.Options) {
|
||||||
|
if Route53BaseEndpoint != "" {
|
||||||
|
o.BaseEndpoint = aws.String(Route53BaseEndpoint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
zoneName := creds.ZoneName
|
||||||
|
if !strings.HasSuffix(zoneName, ".") {
|
||||||
|
zoneName += "."
|
||||||
|
}
|
||||||
|
listOut, err := client.ListHostedZonesByName(ctx, &route53.ListHostedZonesByNameInput{DNSName: aws.String(zoneName)})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("route53: list hosted zones: %w", err)
|
||||||
|
}
|
||||||
|
var zoneID string
|
||||||
|
for _, z := range listOut.HostedZones {
|
||||||
|
if aws.ToString(z.Name) == zoneName {
|
||||||
|
zoneID = aws.ToString(z.Id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if zoneID == "" {
|
||||||
|
return fmt.Errorf("route53: no hosted zone found for %q", creds.ZoneName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fqdn := recordFQDN
|
||||||
|
if !strings.HasSuffix(fqdn, ".") {
|
||||||
|
fqdn += "."
|
||||||
|
}
|
||||||
|
_, err = client.ChangeResourceRecordSets(ctx, &route53.ChangeResourceRecordSetsInput{
|
||||||
|
HostedZoneId: aws.String(zoneID),
|
||||||
|
ChangeBatch: &r53types.ChangeBatch{
|
||||||
|
Changes: []r53types.Change{{
|
||||||
|
Action: r53types.ChangeActionUpsert,
|
||||||
|
ResourceRecordSet: &r53types.ResourceRecordSet{
|
||||||
|
Name: aws.String(fqdn),
|
||||||
|
Type: r53types.RRTypeTxt,
|
||||||
|
TTL: aws.Int64(txtTTL),
|
||||||
|
ResourceRecords: []r53types.ResourceRecord{{Value: aws.String(value)}},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("route53: upsert TXT record: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package dnspublish
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSetTXTRecordRoute53UpsertsAgainstResolvedZone confirms the zone name is resolved
|
||||||
|
// to its hosted zone ID (via ListHostedZonesByName) and the change is submitted as an
|
||||||
|
// UPSERT against that zone, with the expected record name/value — against a fake
|
||||||
|
// server speaking Route53's real REST-XML wire format, not a mock.
|
||||||
|
func TestSetTXTRecordRoute53UpsertsAgainstResolvedZone(t *testing.T) {
|
||||||
|
var changeBody string
|
||||||
|
var changePath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/2013-04-01/hostedzonesbyname"):
|
||||||
|
w.Header().Set("Content-Type", "text/xml")
|
||||||
|
w.Write([]byte(`<?xml version="1.0"?>
|
||||||
|
<ListHostedZonesByNameResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
|
||||||
|
<HostedZones>
|
||||||
|
<HostedZone>
|
||||||
|
<Id>/hostedzone/Z123EXAMPLE</Id>
|
||||||
|
<Name>example.com.</Name>
|
||||||
|
<CallerReference>ref1</CallerReference>
|
||||||
|
<Config><PrivateZone>false</PrivateZone></Config>
|
||||||
|
<ResourceRecordSetCount>2</ResourceRecordSetCount>
|
||||||
|
</HostedZone>
|
||||||
|
</HostedZones>
|
||||||
|
<IsTruncated>false</IsTruncated>
|
||||||
|
<MaxItems>100</MaxItems>
|
||||||
|
</ListHostedZonesByNameResponse>`))
|
||||||
|
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/rrset"):
|
||||||
|
changePath = r.URL.Path
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
changeBody = string(body)
|
||||||
|
w.Header().Set("Content-Type", "text/xml")
|
||||||
|
w.Write([]byte(`<?xml version="1.0"?>
|
||||||
|
<ChangeResourceRecordSetsResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
|
||||||
|
<ChangeInfo>
|
||||||
|
<Id>/change/C123</Id>
|
||||||
|
<Status>PENDING</Status>
|
||||||
|
<SubmittedAt>2026-01-01T00:00:00Z</SubmittedAt>
|
||||||
|
</ChangeInfo>
|
||||||
|
</ChangeResourceRecordSetsResponse>`))
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.String())
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
orig := Route53BaseEndpoint
|
||||||
|
Route53BaseEndpoint = srv.URL
|
||||||
|
defer func() { Route53BaseEndpoint = orig }()
|
||||||
|
|
||||||
|
err := SetTXTRecord(Credentials{
|
||||||
|
Provider: "route53", ZoneName: "example.com",
|
||||||
|
Route53AccessKeyID: "AKIAFAKE", Route53SecretAccessKey: "secret", Route53Region: "us-east-1",
|
||||||
|
}, "selector._domainkey.example.com", `"v=DKIM1; k=rsa; p=abc123"`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(changePath, "Z123EXAMPLE") {
|
||||||
|
t.Fatalf("expected the change request to target the resolved zone Z123EXAMPLE, path was %s", changePath)
|
||||||
|
}
|
||||||
|
if !strings.Contains(changeBody, "UPSERT") {
|
||||||
|
t.Errorf("expected an UPSERT action in the change request body: %s", changeBody)
|
||||||
|
}
|
||||||
|
if !strings.Contains(changeBody, "selector._domainkey.example.com") {
|
||||||
|
t.Errorf("expected the record name in the change request body: %s", changeBody)
|
||||||
|
}
|
||||||
|
if !strings.Contains(changeBody, "v=DKIM1") {
|
||||||
|
t.Errorf("expected the record value in the change request body: %s", changeBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetTXTRecordRoute53NoZoneFound confirms a zone name mismatch surfaces a clear
|
||||||
|
// error rather than silently operating on the wrong zone.
|
||||||
|
func TestSetTXTRecordRoute53NoZoneFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/xml")
|
||||||
|
w.Write([]byte(`<?xml version="1.0"?>
|
||||||
|
<ListHostedZonesByNameResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
|
||||||
|
<HostedZones></HostedZones>
|
||||||
|
<IsTruncated>false</IsTruncated>
|
||||||
|
<MaxItems>100</MaxItems>
|
||||||
|
</ListHostedZonesByNameResponse>`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
orig := Route53BaseEndpoint
|
||||||
|
Route53BaseEndpoint = srv.URL
|
||||||
|
defer func() { Route53BaseEndpoint = orig }()
|
||||||
|
|
||||||
|
err := SetTXTRecord(Credentials{
|
||||||
|
Provider: "route53", ZoneName: "nope.example",
|
||||||
|
Route53AccessKeyID: "AKIAFAKE", Route53SecretAccessKey: "secret", Route53Region: "us-east-1",
|
||||||
|
}, "selector._domainkey.nope.example", `"v=DKIM1"`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when no hosted zone matches")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ package imapserver
|
|||||||
import (
|
import (
|
||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
"mailgoserver/internal/mailstore"
|
"mailgoserver/internal/mailstore"
|
||||||
|
"mailgoserver/internal/notify"
|
||||||
"mailgoserver/internal/toolbox"
|
"mailgoserver/internal/toolbox"
|
||||||
|
|
||||||
"gopkg.in/ini.v1"
|
"gopkg.in/ini.v1"
|
||||||
@@ -21,6 +22,8 @@ type Backend struct {
|
|||||||
Mailstore *mailstore.Store
|
Mailstore *mailstore.Store
|
||||||
Logger *toolbox.Logger
|
Logger *toolbox.Logger
|
||||||
Cfg *ini.File
|
Cfg *ini.File
|
||||||
|
// Notify is nil-safe (see notify.Bus.Publish) — optional wiring for IDLE push.
|
||||||
|
Notify *notify.Bus
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Backend) NewSession(peerIP string) *Session {
|
func (b *Backend) NewSession(peerIP string) *Session {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/emersion/go-imap/v2"
|
"github.com/emersion/go-imap/v2"
|
||||||
"github.com/emersion/go-imap/v2/imapclient"
|
"github.com/emersion/go-imap/v2/imapclient"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
"mailgoserver/internal/imapserver"
|
"mailgoserver/internal/imapserver"
|
||||||
"mailgoserver/internal/mailstore"
|
"mailgoserver/internal/mailstore"
|
||||||
|
"mailgoserver/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestMailboxWithAppPassword seeds a domain + mailbox + app password (returning the
|
// newTestMailboxWithAppPassword seeds a domain + mailbox + app password (returning the
|
||||||
@@ -296,3 +298,188 @@ func TestIMAPStoreSeenFlag(t *testing.T) {
|
|||||||
t.Fatalf("expected \\Seen in returned flags, got %v", msgs[0].Flags)
|
t.Fatalf("expected \\Seen in returned flags, got %v", msgs[0].Flags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIMAPAppendStoresMessageWithClientDate confirms APPEND (a client uploading an
|
||||||
|
// existing message — Drafts/Sent upload, migrating old mail in) stores the message,
|
||||||
|
// preserves the client-supplied flags, and — critically — stamps INTERNALDATE with the
|
||||||
|
// client's own supplied date rather than "now" (StoreMessageWithDate's whole reason for
|
||||||
|
// existing: a migrated 2020 email should not show up as delivered today).
|
||||||
|
func TestIMAPAppendStoresMessageWithClientDate(t *testing.T) {
|
||||||
|
client, email, appPassword, mailboxID := newTestMailboxWithAppPassword(t)
|
||||||
|
_ = mailboxID
|
||||||
|
|
||||||
|
if err := client.Login(email, appPassword).Wait(); err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := []byte("From: Old Sender <old@example.com>\r\nSubject: migrated\r\n\r\nOld body.")
|
||||||
|
oldDate := time.Date(2020, 3, 15, 9, 0, 0, 0, time.UTC)
|
||||||
|
appendCmd := client.Append("INBOX", int64(len(raw)), &imap.AppendOptions{
|
||||||
|
Flags: []imap.Flag{imap.FlagSeen},
|
||||||
|
Time: oldDate,
|
||||||
|
})
|
||||||
|
if _, err := appendCmd.Write(raw); err != nil {
|
||||||
|
t.Fatalf("append write: %v", err)
|
||||||
|
}
|
||||||
|
if err := appendCmd.Close(); err != nil {
|
||||||
|
t.Fatalf("append close: %v", err)
|
||||||
|
}
|
||||||
|
appendData, err := appendCmd.Wait()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("append: %v", err)
|
||||||
|
}
|
||||||
|
if appendData.UID == 0 {
|
||||||
|
t.Fatal("expected a non-zero UID from APPEND")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.Select("INBOX", nil).Wait(); err != nil {
|
||||||
|
t.Fatalf("select: %v", err)
|
||||||
|
}
|
||||||
|
msgs, err := client.Fetch(imap.SeqSetNum(2), &imap.FetchOptions{
|
||||||
|
Envelope: true,
|
||||||
|
Flags: true,
|
||||||
|
InternalDate: true,
|
||||||
|
BodySection: []*imap.FetchItemBodySection{{}},
|
||||||
|
}).Collect()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch appended message: %v", err)
|
||||||
|
}
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("got %d messages at seq 2, want 1", len(msgs))
|
||||||
|
}
|
||||||
|
msg := msgs[0]
|
||||||
|
if msg.Envelope == nil || msg.Envelope.Subject != "migrated" {
|
||||||
|
t.Fatalf("envelope = %+v, want subject %q", msg.Envelope, "migrated")
|
||||||
|
}
|
||||||
|
if !msg.InternalDate.Equal(oldDate) {
|
||||||
|
t.Fatalf("InternalDate = %v, want the client-supplied %v (not now)", msg.InternalDate, oldDate)
|
||||||
|
}
|
||||||
|
foundSeen := false
|
||||||
|
for _, f := range msg.Flags {
|
||||||
|
if f == imap.FlagSeen {
|
||||||
|
foundSeen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundSeen {
|
||||||
|
t.Fatalf("expected \\Seen flag from APPEND's options.Flags, got %v", msg.Flags)
|
||||||
|
}
|
||||||
|
if len(msg.BodySection) != 1 || string(msg.BodySection[0].Bytes) != string(raw) {
|
||||||
|
t.Fatalf("body = %q, want %q", msg.BodySection[0].Bytes, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIMAPAppendRejectsUnknownFolder confirms APPEND to a folder that doesn't exist
|
||||||
|
// for this mailbox fails rather than silently creating one — this server doesn't
|
||||||
|
// support CREATE (see Session.Create), so APPEND shouldn't quietly do the same thing
|
||||||
|
// through a side door.
|
||||||
|
func TestIMAPAppendRejectsUnknownFolder(t *testing.T) {
|
||||||
|
client, email, appPassword, _ := newTestMailboxWithAppPassword(t)
|
||||||
|
if err := client.Login(email, appPassword).Wait(); err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := []byte("Subject: x\r\n\r\nbody")
|
||||||
|
appendCmd := client.Append("NoSuchFolder", int64(len(raw)), nil)
|
||||||
|
appendCmd.Write(raw)
|
||||||
|
appendCmd.Close()
|
||||||
|
if _, err := appendCmd.Wait(); err == nil {
|
||||||
|
t.Fatal("expected APPEND to a nonexistent folder to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIMAPIdlePushesOnNewMessage confirms an IDLE-ing client gets an unsolicited
|
||||||
|
// EXISTS update the moment a message lands in its selected folder — the whole point of
|
||||||
|
// internal/notify's bus — rather than only finding out on its next NOOP/re-SELECT.
|
||||||
|
// Deliberately doesn't go through SMTP delivery (that's covered by
|
||||||
|
// TestSMTP*/session_test.go elsewhere) — stores directly via mailstore and publishes
|
||||||
|
// exactly like smtpserver.deliverLocally does, to isolate this test to the IDLE/notify
|
||||||
|
// wiring itself.
|
||||||
|
func TestIMAPIdlePushesOnNewMessage(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
database, err := db.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { database.Close() })
|
||||||
|
domainID, err := database.CreateDomain("example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store := mailstore.New(database, mailstore.GenerateDEK(), t.TempDir())
|
||||||
|
dek := mailstore.GenerateDEK()
|
||||||
|
wrapped, nonce, err := store.WrapDEK(dek)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
portalHash, _ := db.HashPassword("portal-password-unused")
|
||||||
|
mailboxID, err := database.CreateMailbox("inbox@example.com", portalHash, domainID, 5*1024*1024*1024, wrapped, nonce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
appPassword := db.GenerateAppPassword(25)
|
||||||
|
appHash, _ := db.HashPassword(appPassword)
|
||||||
|
if _, err := database.CreateAppPassword(mailboxID, "test client", appHash, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bus := notify.NewBus()
|
||||||
|
backend := &imapserver.Backend{DB: database, Mailstore: store, Notify: bus}
|
||||||
|
srv := imapserver.NewPlainServer(backend)
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
go srv.Serve(ln)
|
||||||
|
t.Cleanup(func() { srv.Close() })
|
||||||
|
|
||||||
|
conn, err := net.Dial("tcp", ln.Addr().String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
numMessagesCh := make(chan uint32, 4)
|
||||||
|
client := imapclient.New(conn, &imapclient.Options{
|
||||||
|
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
|
||||||
|
Mailbox: func(data *imapclient.UnilateralDataMailbox) {
|
||||||
|
if data.NumMessages != nil {
|
||||||
|
numMessagesCh <- *data.NumMessages
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { client.Close() })
|
||||||
|
|
||||||
|
if err := client.Login("inbox@example.com", appPassword).Wait(); err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := client.Select("INBOX", nil).Wait(); err != nil {
|
||||||
|
t.Fatalf("select: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
idleCmd, err := client.Idle()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("idle: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short wait for the server's Idle handler to actually reach its Subscribe
|
||||||
|
// call before publishing — otherwise this notification could fire before anyone
|
||||||
|
// is listening and be missed (Publish is fire-and-forget, not queued for latecomers).
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := store.StoreMessage(mailboxID, "INBOX", []byte("Subject: new\r\n\r\nbody"), "<new@example.com>", "sender@example.com", "new"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bus.Publish(mailboxID, "INBOX")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case n := <-numMessagesCh:
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("pushed NumMessages = %d, want 1", n)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for the IDLE push")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := idleCmd.Close(); err != nil {
|
||||||
|
t.Fatalf("idle close: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+111
-11
@@ -1,16 +1,20 @@
|
|||||||
package imapserver
|
package imapserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/emersion/go-imap/v2"
|
"github.com/emersion/go-imap/v2"
|
||||||
goimapserver "github.com/emersion/go-imap/v2/imapserver"
|
goimapserver "github.com/emersion/go-imap/v2/imapserver"
|
||||||
|
|
||||||
"mailgoserver/internal/abuseguard"
|
"mailgoserver/internal/abuseguard"
|
||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/mailstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
const inboxName = "INBOX"
|
const inboxName = "INBOX"
|
||||||
@@ -20,14 +24,19 @@ var _ goimapserver.Session = (*Session)(nil)
|
|||||||
// Session implements goimapserver.Session against one mailbox's messages via
|
// Session implements goimapserver.Session against one mailbox's messages via
|
||||||
// mailstore. Sequence numbers are recomputed fresh from the DB on every command
|
// mailstore. Sequence numbers are recomputed fresh from the DB on every command
|
||||||
// rather than cached/tracked across concurrent updates.
|
// rather than cached/tracked across concurrent updates.
|
||||||
// ponytail: no MailboxTracker/IDLE push support — Idle just blocks until the client
|
// ponytail: push (Idle/Poll below) only ever reports a changed NumMessages (new mail
|
||||||
// sends DONE, so a connected client still gets new mail via NOOP/periodic re-SELECT,
|
// arriving/being APPENDed) — it doesn't use go-imap/v2's MailboxTracker machinery, so
|
||||||
// just not an instant push. Add a tracker if that matters.
|
// there's no per-session EXPUNGE/flag-change push to OTHER concurrently-connected
|
||||||
|
// sessions on the same mailbox; those still resync via NOOP/re-SELECT like before.
|
||||||
|
// New mail is the dominant, valuable case (a second client toggling flags on the same
|
||||||
|
// mailbox at the same moment is rare); add real MailboxTracker-based sync if that gap
|
||||||
|
// matters.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
backend *Backend
|
backend *Backend
|
||||||
peerIP string
|
peerIP string
|
||||||
mailbox *db.Mailbox // set once Login succeeds
|
mailbox *db.Mailbox // set once Login succeeds
|
||||||
selectedFolder string // set by Select; defaults to INBOX if empty
|
selectedFolder string // set by Select; defaults to INBOX if empty
|
||||||
|
lastKnownNumMessages uint32 // set by Select, compared against in Idle/Poll
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) Close() error { return nil }
|
func (s *Session) Close() error { return nil }
|
||||||
@@ -128,6 +137,7 @@ func (s *Session) Select(mailbox string, options *imap.SelectOptions) (*imap.Sel
|
|||||||
sort.Slice(flags, func(i, j int) bool { return flags[i] < flags[j] })
|
sort.Slice(flags, func(i, j int) bool { return flags[i] < flags[j] })
|
||||||
permanent := append(append([]imap.Flag{}, flags...), imap.FlagWildcard)
|
permanent := append(append([]imap.Flag{}, flags...), imap.FlagWildcard)
|
||||||
|
|
||||||
|
s.lastKnownNumMessages = uint32(len(msgs))
|
||||||
return &imap.SelectData{
|
return &imap.SelectData{
|
||||||
Flags: flags,
|
Flags: flags,
|
||||||
PermanentFlags: permanent,
|
PermanentFlags: permanent,
|
||||||
@@ -269,15 +279,105 @@ func (s *Session) Status(mailbox string, options *imap.StatusOptions) (*imap.Sta
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append stores a client-uploaded message (draft/sent upload, drag-move from another
|
||||||
|
// account, migrating old mail in) via the same mailstore path SMTP delivery already
|
||||||
|
// uses. Unlike a message that just arrived over SMTP, an APPENDed message's
|
||||||
|
// INTERNALDATE should reflect whatever the client asked for (a migrated message's
|
||||||
|
// original date, not "now") — StoreMessageWithDate exists specifically for this, see
|
||||||
|
// its doc comment.
|
||||||
func (s *Session) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
|
func (s *Session) Append(mailbox string, r imap.LiteralReader, options *imap.AppendOptions) (*imap.AppendData, error) {
|
||||||
return nil, errors.New("APPEND is not supported yet")
|
if err := s.requireAuth(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
folder, ok, err := s.folderExists(mailbox)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, notFoundErr()
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
internalDate := time.Now()
|
||||||
|
if options != nil && !options.Time.IsZero() {
|
||||||
|
internalDate = options.Time
|
||||||
|
}
|
||||||
|
from, subject, messageID := "", "", ""
|
||||||
|
if msg, err := mail.ReadMessage(bytes.NewReader(raw)); err == nil {
|
||||||
|
from = msg.Header.Get("From")
|
||||||
|
subject = msg.Header.Get("Subject")
|
||||||
|
messageID = msg.Header.Get("Message-Id")
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, err := s.backend.Mailstore.StoreMessageWithDate(s.mailbox.ID, folder, raw, messageID, from, subject, internalDate)
|
||||||
|
if err != nil {
|
||||||
|
if err == mailstore.ErrQuotaExceeded {
|
||||||
|
return nil, &imap.Error{Type: imap.StatusResponseTypeNo, Code: imap.ResponseCodeOverQuota, Text: "Mailbox quota exceeded"}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if options != nil && len(options.Flags) > 0 {
|
||||||
|
flagStrs := make([]string, len(options.Flags))
|
||||||
|
for i, f := range options.Flags {
|
||||||
|
flagStrs[i] = string(f)
|
||||||
|
}
|
||||||
|
if err := s.backend.DB.SetMessageFlags(s.mailbox.ID, uid, strings.Join(flagStrs, " ")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.backend.Notify.Publish(s.mailbox.ID, folder)
|
||||||
|
return &imap.AppendData{UID: imap.UID(uid), UIDValidity: uint32(s.mailbox.ID)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) Poll(w *goimapserver.UpdateWriter, allowExpunge bool) error { return nil }
|
// checkNumMessages re-reads the selected folder's current message count and, if it
|
||||||
|
// changed since the last time this session reported it (Select, or a previous
|
||||||
|
// Poll/Idle tick), writes an updated EXISTS count and remembers the new value.
|
||||||
|
// Shared by Poll and Idle — both ultimately just want "does the client's view of
|
||||||
|
// NumMessages need updating."
|
||||||
|
func (s *Session) checkNumMessages(w *goimapserver.UpdateWriter) error {
|
||||||
|
if s.mailbox == nil || s.selectedFolder == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msgs, err := s.messages()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n := uint32(len(msgs))
|
||||||
|
if n == s.lastKnownNumMessages {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.lastKnownNumMessages = n
|
||||||
|
return w.WriteNumMessages(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Poll(w *goimapserver.UpdateWriter, allowExpunge bool) error {
|
||||||
|
return s.checkNumMessages(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle blocks, pushing an updated EXISTS count to the client whenever a message lands
|
||||||
|
// in the currently selected folder (see internal/notify) — until the client sends
|
||||||
|
// DONE (stop closes). A session with nothing selected (or no Notify bus wired up, e.g.
|
||||||
|
// in tests that don't need push) just blocks on stop, same as before this existed.
|
||||||
func (s *Session) Idle(w *goimapserver.UpdateWriter, stop <-chan struct{}) error {
|
func (s *Session) Idle(w *goimapserver.UpdateWriter, stop <-chan struct{}) error {
|
||||||
<-stop
|
if s.mailbox == nil || s.selectedFolder == "" || s.backend.Notify == nil {
|
||||||
return nil
|
<-stop
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ch, unsubscribe := s.backend.Notify.Subscribe(s.mailbox.ID, s.selectedFolder)
|
||||||
|
defer unsubscribe()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return nil
|
||||||
|
case <-ch:
|
||||||
|
if err := s.checkNumMessages(w); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expunge permanently removes every \Deleted-flagged message matched by uids (or all
|
// Expunge permanently removes every \Deleted-flagged message matched by uids (or all
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package mailstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScanVirus streams raw through a clamd instance at addr (e.g. "127.0.0.1:3310") via
|
||||||
|
// clamd's INSTREAM protocol — plain TCP, no client library needed, matching how
|
||||||
|
// CheckRspamd already talks to an external scanner directly. Only called when
|
||||||
|
// [Mailstore] virus_scan_enabled=true (see session.go's Data()). Returns
|
||||||
|
// (true, signature name, nil) on a positive match, (false, "", nil) when clean; a
|
||||||
|
// non-nil err means the scan itself failed (unreachable/timed out/protocol error) —
|
||||||
|
// this package has no opinion on fail-open vs fail-closed, that's the caller's call.
|
||||||
|
func ScanVirus(addr string, raw []byte) (infected bool, signature string, err error) {
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := conn.Write([]byte("zINSTREAM\x00")); err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkSize = 8192
|
||||||
|
var lenBuf [4]byte
|
||||||
|
for i := 0; i < len(raw); i += chunkSize {
|
||||||
|
end := i + chunkSize
|
||||||
|
if end > len(raw) {
|
||||||
|
end = len(raw)
|
||||||
|
}
|
||||||
|
chunk := raw[i:end]
|
||||||
|
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(chunk)))
|
||||||
|
if _, err := conn.Write(lenBuf[:]); err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(chunk); err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := conn.Write([]byte{0, 0, 0, 0}); err != nil { // zero-length chunk: end of stream
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// clamd's reply is one NUL-terminated line, e.g. "stream: OK\0",
|
||||||
|
// "stream: Eicar-Test-Signature FOUND\0", or "stream: <msg> ERROR\0".
|
||||||
|
reply, err := bufio.NewReader(conn).ReadString('\x00')
|
||||||
|
if err != nil && reply == "" {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
reply = strings.TrimRight(reply, "\x00\r\n")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(reply, "ERROR"):
|
||||||
|
return false, "", errors.New("clamd: " + reply)
|
||||||
|
case strings.HasSuffix(reply, "FOUND"):
|
||||||
|
name := strings.TrimSuffix(strings.TrimPrefix(reply, "stream: "), " FOUND")
|
||||||
|
return true, name, nil
|
||||||
|
default:
|
||||||
|
return false, "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package mailstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeClamd stands in for a real clamd instance: reads the INSTREAM protocol exactly
|
||||||
|
// as a real one would (command, length-prefixed chunks, zero-length terminator),
|
||||||
|
// reassembles the full payload so the test can assert on exactly what ScanVirus sent,
|
||||||
|
// then writes back the given canned reply.
|
||||||
|
func fakeClamd(t *testing.T, reply string) (addr string, gotPayload chan []byte) {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { ln.Close() })
|
||||||
|
gotPayload = make(chan []byte, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
cmd := make([]byte, len("zINSTREAM\x00"))
|
||||||
|
if _, err := io.ReadFull(conn, cmd); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload []byte
|
||||||
|
for {
|
||||||
|
var lenBuf [4]byte
|
||||||
|
if _, err := io.ReadFull(conn, lenBuf[:]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := binary.BigEndian.Uint32(lenBuf[:])
|
||||||
|
if n == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
chunk := make([]byte, n)
|
||||||
|
if _, err := io.ReadFull(conn, chunk); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload = append(payload, chunk...)
|
||||||
|
}
|
||||||
|
gotPayload <- payload
|
||||||
|
conn.Write([]byte(reply + "\x00"))
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ln.Addr().String(), gotPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanVirusCleanReply(t *testing.T) {
|
||||||
|
addr, gotPayload := fakeClamd(t, "stream: OK")
|
||||||
|
infected, sig, err := ScanVirus(addr, []byte("hello world"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if infected || sig != "" {
|
||||||
|
t.Fatalf("infected=%v sig=%q, want clean", infected, sig)
|
||||||
|
}
|
||||||
|
if got := <-gotPayload; string(got) != "hello world" {
|
||||||
|
t.Fatalf("clamd received %q, want %q — the length-prefixed chunk protocol is broken", got, "hello world")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanVirusInfectedReply(t *testing.T) {
|
||||||
|
addr, _ := fakeClamd(t, "stream: Eicar-Test-Signature FOUND")
|
||||||
|
infected, sig, err := ScanVirus(addr, []byte("X5O!P%@AP"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !infected || sig != "Eicar-Test-Signature" {
|
||||||
|
t.Fatalf("infected=%v sig=%q, want infected with the parsed signature name", infected, sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanVirusErrorReply(t *testing.T) {
|
||||||
|
addr, _ := fakeClamd(t, "stream: Access denied ERROR")
|
||||||
|
if _, _, err := ScanVirus(addr, []byte("x")); err == nil {
|
||||||
|
t.Fatal("expected an error for a clamd-reported ERROR reply")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanVirusUnreachable(t *testing.T) {
|
||||||
|
if _, _, err := ScanVirus("127.0.0.1:1", []byte("x")); err == nil {
|
||||||
|
t.Fatal("expected an error dialing an unreachable clamd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanVirusChunksLargePayload(t *testing.T) {
|
||||||
|
addr, gotPayload := fakeClamd(t, "stream: OK")
|
||||||
|
large := make([]byte, 20000) // spans multiple 8192-byte chunks
|
||||||
|
for i := range large {
|
||||||
|
large[i] = byte(i % 251)
|
||||||
|
}
|
||||||
|
if _, _, err := ScanVirus(addr, large); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := <-gotPayload
|
||||||
|
if len(got) != len(large) {
|
||||||
|
t.Fatalf("clamd received %d bytes, want %d — multi-chunk reassembly is broken", len(got), len(large))
|
||||||
|
}
|
||||||
|
for i := range large {
|
||||||
|
if got[i] != large[i] {
|
||||||
|
t.Fatalf("byte %d mismatch: got %d want %d", i, got[i], large[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package mailstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/publicsuffix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DMARCPolicy is a parsed _dmarc TXT record (RFC 7489 §6.3) — only the tags this
|
||||||
|
// server actually acts on (p/sp/pct); tags like rua/ruf (aggregate/failure reporting
|
||||||
|
// addresses) are parsed by nothing here since outbound DMARC reporting is out of scope
|
||||||
|
// for this pass (see tests/todo.md).
|
||||||
|
type DMARCPolicy struct {
|
||||||
|
P string // required: "none" | "quarantine" | "reject"
|
||||||
|
SP string // subdomain policy; defaults to P if absent
|
||||||
|
Pct int // 0-100, defaults to 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupDMARCPolicy fetches and parses domain's _dmarc TXT record. A nil policy with a
|
||||||
|
// nil error means "no DMARC record published" — the normal case for most domains, not
|
||||||
|
// a failure worth logging; a lookup/resolver error is treated the same way (fail open,
|
||||||
|
// matching CheckSPF's "no record: neutral, not a penalty" posture elsewhere in this
|
||||||
|
// file) since a transient DNS hiccup must never itself quarantine/reject mail.
|
||||||
|
func LookupDMARCPolicy(domain string) *DMARCPolicy {
|
||||||
|
if domain == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
txts, err := net.DefaultResolver.LookupTXT(context.Background(), "_dmarc."+domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, t := range txts {
|
||||||
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(t)), "v=dmarc1") {
|
||||||
|
return parseDMARCRecord(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDMARCRecord(record string) *DMARCPolicy {
|
||||||
|
pol := &DMARCPolicy{P: "none", Pct: 100}
|
||||||
|
for _, tag := range strings.Split(record, ";") {
|
||||||
|
kv := strings.SplitN(strings.TrimSpace(tag), "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val := strings.ToLower(strings.TrimSpace(kv[0])), strings.ToLower(strings.TrimSpace(kv[1]))
|
||||||
|
switch key {
|
||||||
|
case "p":
|
||||||
|
pol.P = val
|
||||||
|
case "sp":
|
||||||
|
pol.SP = val
|
||||||
|
case "pct":
|
||||||
|
if n, err := strconv.Atoi(val); err == nil && n >= 0 && n <= 100 {
|
||||||
|
pol.Pct = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pol.SP == "" {
|
||||||
|
pol.SP = pol.P
|
||||||
|
}
|
||||||
|
return pol
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectivePolicy returns the policy that applies to a message whose From: domain is
|
||||||
|
// fromDomain, given the organizational domain the record was published under — sp
|
||||||
|
// applies for a strict subdomain of the organizational domain, p otherwise (RFC 7489
|
||||||
|
// §6.6.3).
|
||||||
|
func (pol *DMARCPolicy) EffectivePolicy(fromDomain, orgDomain string) string {
|
||||||
|
if !strings.EqualFold(fromDomain, orgDomain) {
|
||||||
|
return pol.SP
|
||||||
|
}
|
||||||
|
return pol.P
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrganizationalDomain returns domain's registrable organizational domain (e.g.
|
||||||
|
// "mail.example.co.uk" -> "example.co.uk"), via the public suffix list — DMARC
|
||||||
|
// alignment is defined in terms of this, and a naive "last two labels" heuristic would
|
||||||
|
// be wrong for exactly the multi-part-TLD domains (.co.uk, .com.au, etc.) alignment
|
||||||
|
// most needs to get right. Falls back to the lowercased input unchanged if it can't be
|
||||||
|
// parsed (e.g. a bare TLD or malformed input) — same fail-open posture as the rest of
|
||||||
|
// this file's DNS-dependent checks.
|
||||||
|
func OrganizationalDomain(domain string) string {
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
org, err := publicsuffix.EffectiveTLDPlusOne(domain)
|
||||||
|
if err != nil {
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
return org
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package mailstore
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseDMARCRecord(t *testing.T) {
|
||||||
|
pol := parseDMARCRecord("v=DMARC1; p=reject; sp=quarantine; pct=50")
|
||||||
|
if pol.P != "reject" || pol.SP != "quarantine" || pol.Pct != 50 {
|
||||||
|
t.Fatalf("got %+v", pol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDMARCRecordDefaultsSPToP(t *testing.T) {
|
||||||
|
pol := parseDMARCRecord("v=DMARC1; p=quarantine")
|
||||||
|
if pol.SP != "quarantine" {
|
||||||
|
t.Fatalf("sp should default to p, got %q", pol.SP)
|
||||||
|
}
|
||||||
|
if pol.Pct != 100 {
|
||||||
|
t.Fatalf("pct should default to 100, got %d", pol.Pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDMARCRecordIgnoresOutOfRangePct(t *testing.T) {
|
||||||
|
pol := parseDMARCRecord("v=DMARC1; p=reject; pct=150")
|
||||||
|
if pol.Pct != 100 {
|
||||||
|
t.Fatalf("out-of-range pct should be ignored, keeping default 100, got %d", pol.Pct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEffectivePolicyUsesPForOrgDomain(t *testing.T) {
|
||||||
|
pol := &DMARCPolicy{P: "reject", SP: "quarantine"}
|
||||||
|
if got := pol.EffectivePolicy("example.com", "example.com"); got != "reject" {
|
||||||
|
t.Fatalf("got %q, want p applied for the organizational domain itself", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEffectivePolicyUsesSPForSubdomain(t *testing.T) {
|
||||||
|
pol := &DMARCPolicy{P: "reject", SP: "quarantine"}
|
||||||
|
if got := pol.EffectivePolicy("mail.example.com", "example.com"); got != "quarantine" {
|
||||||
|
t.Fatalf("got %q, want sp applied for a strict subdomain", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizationalDomainStripsSubdomain(t *testing.T) {
|
||||||
|
if got := OrganizationalDomain("mail.example.com"); got != "example.com" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrganizationalDomainHandlesMultiPartTLD(t *testing.T) {
|
||||||
|
if got := OrganizationalDomain("mail.example.co.uk"); got != "example.co.uk" {
|
||||||
|
t.Fatalf("got %q, want the public-suffix-aware organizational domain", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupDMARCPolicyEmptyDomain(t *testing.T) {
|
||||||
|
if pol := LookupDMARCPolicy(""); pol != nil {
|
||||||
|
t.Fatalf("expected nil policy for empty domain, got %+v", pol)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,66 @@
|
|||||||
package mailstore
|
package mailstore
|
||||||
|
|
||||||
import "mailgoserver/internal/db"
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
// ResolveRecipient looks up a local mailbox for addr — its primary email first, then
|
"mailgoserver/internal/db"
|
||||||
// any active alias — so mail sent to an alias lands in the owning mailbox's INBOX.
|
)
|
||||||
|
|
||||||
|
// ResolveRecipient looks up a local mailbox for addr: its primary email first, then
|
||||||
|
// any active alias, then a "+tag" sub-address stripped down to its base mailbox, then
|
||||||
|
// finally the recipient's domain's opt-in catch-all mailbox if one is configured — so
|
||||||
|
// mail sent to an alias, "user+tag@domain", or (if enabled) any other address on the
|
||||||
|
// domain still lands somewhere sensible instead of bouncing.
|
||||||
func (s *Store) ResolveRecipient(addr string) (*db.Mailbox, error) {
|
func (s *Store) ResolveRecipient(addr string) (*db.Mailbox, error) {
|
||||||
mbox, err := s.DB.GetMailboxByEmail(addr)
|
mbox, err := s.DB.GetMailboxByEmail(addr)
|
||||||
if err != nil || mbox != nil {
|
if err != nil || mbox != nil {
|
||||||
return mbox, err
|
return mbox, err
|
||||||
}
|
}
|
||||||
alias, err := s.DB.GetAliasByEmail(addr)
|
if alias, err := s.DB.GetAliasByEmail(addr); err != nil {
|
||||||
if err != nil || alias == nil {
|
return nil, err
|
||||||
|
} else if alias != nil {
|
||||||
|
return s.DB.GetMailboxByID(alias.MailboxID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sub-addressing only applies to a mailbox's own primary address (not aliases) —
|
||||||
|
// checked above first, so an address that's genuinely registered with a literal
|
||||||
|
// "+" in its local-part still wins over this fallback.
|
||||||
|
if base, ok := stripSubaddressTag(addr); ok {
|
||||||
|
if mbox, err := s.DB.GetMailboxByEmail(base); err != nil || mbox != nil {
|
||||||
|
return mbox, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := domainOfEmail(addr)
|
||||||
|
if domain == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
dom, err := s.DB.GetDomainByName(domain)
|
||||||
|
if err != nil || dom == nil || dom.CatchallMailboxID == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.DB.GetMailboxByID(alias.MailboxID)
|
return s.DB.GetMailboxByID(*dom.CatchallMailboxID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainOfEmail(addr string) string {
|
||||||
|
i := strings.LastIndex(addr, "@")
|
||||||
|
if i < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return addr[i+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripSubaddressTag turns "user+tag@domain" into "user@domain", ok=true — or
|
||||||
|
// ok=false if addr has no "+" in its local-part to strip.
|
||||||
|
func stripSubaddressTag(addr string) (base string, ok bool) {
|
||||||
|
at := strings.LastIndex(addr, "@")
|
||||||
|
if at < 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
local, domain := addr[:at], addr[at:]
|
||||||
|
plus := strings.Index(local, "+")
|
||||||
|
if plus < 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return local[:plus] + domain, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package mailstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveRecipientExactMatch(t *testing.T) {
|
||||||
|
s, mailboxID := newTestMailbox(t, 1<<30)
|
||||||
|
mbox, err := s.ResolveRecipient("user@example.com")
|
||||||
|
if err != nil || mbox == nil || mbox.ID != mailboxID {
|
||||||
|
t.Fatalf("got %+v, err=%v", mbox, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRecipientSubaddressTag(t *testing.T) {
|
||||||
|
s, mailboxID := newTestMailbox(t, 1<<30)
|
||||||
|
mbox, err := s.ResolveRecipient("user+newsletter@example.com")
|
||||||
|
if err != nil || mbox == nil || mbox.ID != mailboxID {
|
||||||
|
t.Fatalf("got %+v, err=%v, want the base mailbox for a +tag sub-address", mbox, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRecipientSubaddressDoesNotShadowRealMailbox(t *testing.T) {
|
||||||
|
s, _ := newTestMailbox(t, 1<<30)
|
||||||
|
dom, err := s.DB.GetDomainByName("example.com")
|
||||||
|
if err != nil || dom == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, err := db.HashPassword("irrelevant-portal-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dek := GenerateDEK()
|
||||||
|
wrapped, nonce, err := s.WrapDEK(dek)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A literal, genuinely-registered "user+newsletter@example.com" mailbox must win
|
||||||
|
// over the sub-address-stripping fallback, not get shadowed by "user@example.com".
|
||||||
|
taggedMailboxID, err := s.DB.CreateMailbox("user+newsletter@example.com", hash, dom.ID, 1<<30, wrapped, nonce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mbox, err := s.ResolveRecipient("user+newsletter@example.com")
|
||||||
|
if err != nil || mbox == nil || mbox.ID != taggedMailboxID {
|
||||||
|
t.Fatalf("got %+v, err=%v, want the literal registered mailbox, not the stripped-tag fallback", mbox, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRecipientCatchall(t *testing.T) {
|
||||||
|
s, mailboxID := newTestMailbox(t, 1<<30)
|
||||||
|
dom, err := s.DB.GetDomainByName("example.com")
|
||||||
|
if err != nil || dom == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mbox, err := s.ResolveRecipient("nobody@example.com"); err != nil || mbox != nil {
|
||||||
|
t.Fatalf("expected no catch-all configured to leave an unknown recipient unresolved, got %+v, err=%v", mbox, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DB.SetDomainCatchall(dom.ID, &mailboxID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mbox, err := s.ResolveRecipient("nobody@example.com")
|
||||||
|
if err != nil || mbox == nil || mbox.ID != mailboxID {
|
||||||
|
t.Fatalf("got %+v, err=%v, want the catch-all mailbox once configured", mbox, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DB.SetDomainCatchall(dom.ID, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mbox, err := s.ResolveRecipient("nobody@example.com"); err != nil || mbox != nil {
|
||||||
|
t.Fatalf("expected clearing the catch-all to leave an unknown recipient unresolved again, got %+v, err=%v", mbox, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package mailstore
|
package mailstore
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
@@ -18,6 +19,21 @@ type FilterAction struct {
|
|||||||
// the recipient's own local copy is still stored too.
|
// the recipient's own local copy is still stored too.
|
||||||
ForwardTo string
|
ForwardTo string
|
||||||
KeepCopy bool
|
KeepCopy bool
|
||||||
|
// AutoReply: true means also send a vacation-responder reply — actually sent by
|
||||||
|
// smtpserver's deliverLocally, same division of labor as ForwardTo above. Doesn't
|
||||||
|
// affect where/whether this message itself is stored (that's decided by whatever
|
||||||
|
// other fields a rule also sets, same as MarkRead).
|
||||||
|
AutoReply bool
|
||||||
|
AutoReplySubject string // ActionValue; empty means the caller picks a default
|
||||||
|
AutoReplyBody string // from ActionOptionsJSON, see autoReplyOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoReplyOptions is the 'auto_reply' action's own ActionOptionsJSON shape — a
|
||||||
|
// separate struct from RuleActionOptions since that one's fields (KeepCopy) are
|
||||||
|
// specific to the 'forward' action; ActionOptionsJSON's meaning depends entirely on
|
||||||
|
// which action a given rule row actually has.
|
||||||
|
type autoReplyOptions struct {
|
||||||
|
Body string `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyRules evaluates a mailbox's filter rules in priority order and returns the
|
// ApplyRules evaluates a mailbox's filter rules in priority order and returns the
|
||||||
@@ -53,6 +69,10 @@ func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAc
|
|||||||
return FilterAction{MarkRead: true}, nil
|
return FilterAction{MarkRead: true}, nil
|
||||||
case "forward":
|
case "forward":
|
||||||
return FilterAction{ForwardTo: r.ActionValue, KeepCopy: r.ActionOptions().KeepCopy}, nil
|
return FilterAction{ForwardTo: r.ActionValue, KeepCopy: r.ActionOptions().KeepCopy}, nil
|
||||||
|
case "auto_reply":
|
||||||
|
var opts autoReplyOptions
|
||||||
|
json.Unmarshal([]byte(r.ActionOptionsJSON), &opts) // best-effort: empty body on parse failure
|
||||||
|
return FilterAction{AutoReply: true, AutoReplySubject: r.ActionValue, AutoReplyBody: opts.Body}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return FilterAction{}, nil
|
return FilterAction{}, nil
|
||||||
|
|||||||
@@ -58,8 +58,23 @@ var ErrQuotaExceeded = errors.New("mailstore: mailbox quota exceeded")
|
|||||||
// StoreMessage encrypts raw with the mailbox's own data encryption key and persists
|
// StoreMessage encrypts raw with the mailbox's own data encryption key and persists
|
||||||
// it to disk, then indexes it in esrv_mailbox_messages and updates the mailbox's
|
// it to disk, then indexes it in esrv_mailbox_messages and updates the mailbox's
|
||||||
// cached used_bytes. from/subject are cached in the DB in plain text by design (see
|
// cached used_bytes. from/subject are cached in the DB in plain text by design (see
|
||||||
// schema.go) so IMAP LIST/basic SEARCH don't need to decrypt every message.
|
// schema.go) so IMAP LIST/basic SEARCH don't need to decrypt every message. The
|
||||||
|
// message's IMAP INTERNALDATE is stamped as now — see StoreMessageWithDate for a
|
||||||
|
// caller-supplied date instead.
|
||||||
func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, messageIDHeader, from, subject string) (uid int64, err error) {
|
func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, messageIDHeader, from, subject string) (uid int64, err error) {
|
||||||
|
return s.storeMessage(mailboxID, folder, raw, messageIDHeader, from, subject, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreMessageWithDate behaves exactly like StoreMessage but stamps INTERNALDATE with
|
||||||
|
// internalDate instead of the current time — for IMAP APPEND (a client uploading
|
||||||
|
// already-existing mail wants its original date preserved, not "now") and for
|
||||||
|
// restoring a per-domain export (see internal/backup), neither of which is normal
|
||||||
|
// SMTP/webmail delivery of a message that just arrived.
|
||||||
|
func (s *Store) StoreMessageWithDate(mailboxID int64, folder string, raw []byte, messageIDHeader, from, subject string, internalDate time.Time) (uid int64, err error) {
|
||||||
|
return s.storeMessage(mailboxID, folder, raw, messageIDHeader, from, subject, internalDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) storeMessage(mailboxID int64, folder string, raw []byte, messageIDHeader, from, subject string, internalDate time.Time) (uid int64, err error) {
|
||||||
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
mbox, err := s.DB.GetMailboxByID(mailboxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -80,8 +95,11 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
// The on-disk sharding directory is always bucketed by the real current time (when
|
||||||
dir := filepath.Join(s.BasePath, sanitizePathSegment(mbox.Email), folder, now.Format("2006-02-Jan"))
|
// the file is actually being written), not internalDate — a bulk import/APPEND of
|
||||||
|
// old mail shouldn't retroactively create (or collide into) old dated directories;
|
||||||
|
// INTERNALDATE is purely a DB column, unrelated to where the ciphertext blob lives.
|
||||||
|
dir := filepath.Join(s.BasePath, sanitizePathSegment(mbox.Email), folder, time.Now().Format("2006-02-Jan"))
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -92,7 +110,7 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", now, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject, previewSnippet(raw))
|
uid, err = s.DB.InsertMessage(mailboxID, folder, messageIDHeader, "", internalDate, int64(len(raw)), storagePath, nonce, from, extractHeaderValue(raw, "To"), subject, previewSnippet(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(storagePath)
|
os.Remove(storagePath)
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Package notify is a minimal in-process pub-sub for "a message landed in this
|
||||||
|
// mailbox folder" events — the single relay point SMTP local delivery and IMAP APPEND
|
||||||
|
// call into, and both IMAP IDLE push (internal/imapserver) and webmail SSE push
|
||||||
|
// (internal/webui) subscribe to. Deliberately just a wakeup signal with no message
|
||||||
|
// content: every subscriber already has its own authoritative way to re-fetch state
|
||||||
|
// (a DB query), so there's no duplicate-source-of-truth risk in keeping the event
|
||||||
|
// itself empty.
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type key struct {
|
||||||
|
mailboxID int64
|
||||||
|
folder string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bus fans a Publish out to every current Subscribe-r of the same (mailboxID,
|
||||||
|
// folder). Safe for concurrent use.
|
||||||
|
type Bus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
subs map[key][]chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBus() *Bus {
|
||||||
|
return &Bus{subs: make(map[key][]chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe returns a channel that receives a value each time Publish(mailboxID,
|
||||||
|
// folder) is called, and an unsubscribe func the caller must call when done listening
|
||||||
|
// (typically via defer). The channel is buffered 1 and sent to non-blockingly — a
|
||||||
|
// subscriber that's slow to drain it can miss a notification, which is fine, since the
|
||||||
|
// next poll/idle cycle still reflects real DB state regardless of whether it was
|
||||||
|
// notified about this specific change. A nil Bus is a valid receiver (see Publish):
|
||||||
|
// the returned channel simply never fires, and unsubscribe is a no-op.
|
||||||
|
func (b *Bus) Subscribe(mailboxID int64, folder string) (ch <-chan struct{}, unsubscribe func()) {
|
||||||
|
if b == nil {
|
||||||
|
return make(chan struct{}), func() {}
|
||||||
|
}
|
||||||
|
k := key{mailboxID, folder}
|
||||||
|
c := make(chan struct{}, 1)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.subs[k] = append(b.subs[k], c)
|
||||||
|
b.mu.Unlock()
|
||||||
|
return c, func() {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
list := b.subs[k]
|
||||||
|
for i, existing := range list {
|
||||||
|
if existing == c {
|
||||||
|
b.subs[k] = append(list[:i], list[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(b.subs[k]) == 0 {
|
||||||
|
delete(b.subs, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish wakes every current subscriber of (mailboxID, folder). A nil Bus is a valid
|
||||||
|
// no-op receiver — callers that hold an optionally-nil *Bus (e.g. in a test backend
|
||||||
|
// that never wired one up) can call Publish unconditionally instead of nil-checking
|
||||||
|
// at every call site.
|
||||||
|
func (b *Bus) Publish(mailboxID int64, folder string) {
|
||||||
|
if b == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
subs := append([]chan struct{}{}, b.subs[key{mailboxID, folder}]...)
|
||||||
|
b.mu.Unlock()
|
||||||
|
for _, c := range subs {
|
||||||
|
select {
|
||||||
|
case c <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSubscribePublishDelivers(t *testing.T) {
|
||||||
|
b := NewBus()
|
||||||
|
ch, unsub := b.Subscribe(1, "INBOX")
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
b.Publish(1, "INBOX")
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
default:
|
||||||
|
t.Fatal("expected a notification on the subscribed channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishDoesNotCrossFoldersOrMailboxes(t *testing.T) {
|
||||||
|
b := NewBus()
|
||||||
|
ch, unsub := b.Subscribe(1, "INBOX")
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
b.Publish(1, "Junk")
|
||||||
|
b.Publish(2, "INBOX")
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
t.Fatal("did not expect a notification for a different folder/mailbox")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsubscribeStopsDelivery(t *testing.T) {
|
||||||
|
b := NewBus()
|
||||||
|
ch, unsub := b.Subscribe(1, "INBOX")
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
b.Publish(1, "INBOX")
|
||||||
|
select {
|
||||||
|
case _, ok := <-ch:
|
||||||
|
if ok {
|
||||||
|
t.Fatal("did not expect a notification after unsubscribe")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNilBusPublishIsNoop(t *testing.T) {
|
||||||
|
var b *Bus
|
||||||
|
b.Publish(1, "INBOX") // must not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishIsNonBlockingOnFullChannel(t *testing.T) {
|
||||||
|
b := NewBus()
|
||||||
|
_, unsub := b.Subscribe(1, "INBOX")
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
b.Publish(1, "INBOX")
|
||||||
|
b.Publish(1, "INBOX") // channel already holds 1 buffered value — must not block
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Publish blocked on a full subscriber channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mtaSTSEnforced checks whether domain (a receiving mail domain, not an MX hostname —
|
||||||
|
// MTA-STS policies are published per-domain, per RFC 8461) publishes an MTA-STS
|
||||||
|
// policy in "enforce" mode via its well-known HTTPS endpoint. trySend uses this to
|
||||||
|
// decide whether an unverified-fallback retry is off the table: a domain that opted
|
||||||
|
// into strict enforcement should get a real failure on a bad cert, not a silent
|
||||||
|
// downgrade.
|
||||||
|
//
|
||||||
|
// Fails closed toward "no enforced policy": any lookup/fetch/parse failure returns
|
||||||
|
// false, never an error — a domain that doesn't publish MTA-STS at all is the
|
||||||
|
// overwhelmingly common case today and must never block delivery over that alone.
|
||||||
|
func mtaSTSEnforced(domain string) bool {
|
||||||
|
if domain == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Get("https://mta-sts." + domain + "/.well-known/mta-sts.txt")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return parseMTASTSModeIsEnforce(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMTASTSModeIsEnforce reads an MTA-STS policy body and reports whether its
|
||||||
|
// "mode:" field is "enforce" — split out from mtaSTSEnforced so the parsing logic is
|
||||||
|
// testable without a real HTTPS round trip.
|
||||||
|
func parseMTASTSModeIsEnforce(body io.Reader) bool {
|
||||||
|
scanner := bufio.NewScanner(body)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if rest, ok := strings.CutPrefix(line, "mode:"); ok {
|
||||||
|
return strings.TrimSpace(rest) == "enforce"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMTASTSModeIsEnforce(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"enforce", "version: STSv1\nmode: enforce\nmx: mail.example.com\nmax_age: 604800\n", true},
|
||||||
|
{"testing", "version: STSv1\nmode: testing\nmx: mail.example.com\n", false},
|
||||||
|
{"no mode line", "version: STSv1\nmx: mail.example.com\n", false},
|
||||||
|
{"empty body", "", false},
|
||||||
|
{"extra whitespace", "version: STSv1\n mode: enforce \n", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := parseMTASTSModeIsEnforce(strings.NewReader(c.body)); got != c.want {
|
||||||
|
t.Errorf("got %v, want %v for body %q", got, c.want, c.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMTASTSEnforcedEmptyDomain(t *testing.T) {
|
||||||
|
if mtaSTSEnforced("") {
|
||||||
|
t.Fatal("expected an empty domain to never be treated as enforced")
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-7
@@ -4,6 +4,8 @@ package relay
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
@@ -50,6 +52,29 @@ type Relay struct {
|
|||||||
// before that, so this is assigned afterward rather than threaded through the
|
// before that, so this is assigned afterward rather than threaded through the
|
||||||
// constructor. Nil-safe: SendBounce falls back to relaying out when unset.
|
// constructor. Nil-safe: SendBounce falls back to relaying out when unset.
|
||||||
Mailstore *mailstore.Store
|
Mailstore *mailstore.Store
|
||||||
|
|
||||||
|
// port overrides mxPort for tests only (a real MX is always 25) — lets a test spin
|
||||||
|
// up a local TLS-capable SMTP stand-in on an ephemeral port and point trySend at it
|
||||||
|
// directly, unreachable via net.LookupMX. Zero (the normal, non-test case) means
|
||||||
|
// "use mxPort".
|
||||||
|
port int
|
||||||
|
// rootCAs overrides the system trust store for tests only — lets a test present a
|
||||||
|
// cert signed by a throwaway test CA and exercise the *real* verified-first-try
|
||||||
|
// path (not just the InsecureSkipVerify fallback) without needing a
|
||||||
|
// system-trusted cert. nil (the normal, non-test case) means "use the system pool".
|
||||||
|
rootCAs *x509.CertPool
|
||||||
|
// mtaSTSCheck overrides mtaSTSEnforced for tests only — lets a test simulate a
|
||||||
|
// domain publishing (or not publishing) an MTA-STS enforce policy without a real
|
||||||
|
// outbound HTTPS call. nil (the normal, non-test case) means "use the real
|
||||||
|
// mtaSTSEnforced, which does a live HTTPS lookup."
|
||||||
|
mtaSTSCheck func(domain string) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Relay) targetPort() int {
|
||||||
|
if r.port != 0 {
|
||||||
|
return r.port
|
||||||
|
}
|
||||||
|
return mxPort
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds a Relay from settings.ini. Unlike email_relay.py (which reads
|
// New builds a Relay from settings.ini. Unlike email_relay.py (which reads
|
||||||
@@ -169,7 +194,7 @@ func (r *Relay) deliverToDomain(domain, mailFrom string, rcpts []string, content
|
|||||||
var lastErr error
|
var lastErr error
|
||||||
for _, mx := range mxRecords {
|
for _, mx := range mxRecords {
|
||||||
host := strings.TrimSuffix(mx.Host, ".")
|
host := strings.TrimSuffix(mx.Host, ".")
|
||||||
resp, err := r.trySend(host, mailFrom, rcpts, content)
|
resp, err := r.trySend(host, domain, mailFrom, rcpts, content)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return "success", resp, "", ""
|
return "success", resp, "", ""
|
||||||
}
|
}
|
||||||
@@ -179,8 +204,45 @@ func (r *Relay) deliverToDomain(domain, mailFrom string, rcpts []string, content
|
|||||||
return "failed", "", "RELAY", fmt.Sprintf("%v", lastErr)
|
return "failed", "", "RELAY", fmt.Sprintf("%v", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Relay) trySend(host, mailFrom string, rcpts []string, content string) (string, error) {
|
// errStartTLSVerifyFailed wraps a STARTTLS failure that happened during real
|
||||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(mxPort)), r.Timeout)
|
// certificate verification specifically (as opposed to e.g. the server rejecting the
|
||||||
|
// STARTTLS command itself) — trySend uses this to decide whether a fresh,
|
||||||
|
// unverified-fallback attempt is worth making.
|
||||||
|
var errStartTLSVerifyFailed = errors.New("starttls certificate verification failed")
|
||||||
|
|
||||||
|
// trySend delivers once over a fresh connection, verifying the receiving MTA's
|
||||||
|
// certificate. If that specifically fails at the STARTTLS handshake, it retries once
|
||||||
|
// more on a brand-new connection with verification skipped — a failed TLS handshake
|
||||||
|
// leaves the original connection unusable, so this can't be a retry on the same conn.
|
||||||
|
// Falling back (rather than hard-failing) matches real-world opportunistic-STARTTLS
|
||||||
|
// behavior: plenty of legitimate small mail servers present certs that don't chain
|
||||||
|
// cleanly, and bouncing over that with no compensating control would hurt
|
||||||
|
// deliverability for no real security gain. The fallback still logs clearly, so a
|
||||||
|
// domain that's actually being MITM'd leaves a trail — unless domain itself publishes
|
||||||
|
// an MTA-STS policy in "enforce" mode (see mtasts.go), in which case it explicitly
|
||||||
|
// opted into strict behavior and gets a real failure instead of the silent downgrade.
|
||||||
|
func (r *Relay) trySend(host, domain, mailFrom string, rcpts []string, content string) (string, error) {
|
||||||
|
resp, err := r.trySendOnce(host, mailFrom, rcpts, content, false)
|
||||||
|
if err != nil && errors.Is(err, errStartTLSVerifyFailed) {
|
||||||
|
checkEnforced := mtaSTSEnforced
|
||||||
|
if r.mtaSTSCheck != nil {
|
||||||
|
checkEnforced = r.mtaSTSCheck
|
||||||
|
}
|
||||||
|
if checkEnforced(domain) {
|
||||||
|
r.Logger.Warning("STARTTLS certificate verification failed for %s, and %s publishes an MTA-STS enforce policy — not falling back to an unverified connection: %v", host, domain, err)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
r.Logger.Warning("STARTTLS certificate verification failed for %s, retrying delivery over an unverified (still encrypted) connection: %v", host, err)
|
||||||
|
resp, err = r.trySendOnce(host, mailFrom, rcpts, content, true)
|
||||||
|
if err == nil {
|
||||||
|
r.Logger.Warning("Delivered to %s over an unverified TLS connection after verified STARTTLS failed", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Relay) trySendOnce(host, mailFrom string, rcpts []string, content string, skipVerify bool) (string, error) {
|
||||||
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(r.targetPort())), r.Timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -199,12 +261,15 @@ func (r *Relay) trySend(host, mailFrom string, rcpts []string, content string) (
|
|||||||
|
|
||||||
// Opportunistic STARTTLS: upgrade if offered, send in plaintext otherwise —
|
// Opportunistic STARTTLS: upgrade if offered, send in plaintext otherwise —
|
||||||
// mirrors relay_email_async's "if starttls in extensions" check with no hard
|
// mirrors relay_email_async's "if starttls in extensions" check with no hard
|
||||||
// requirement, and no strict certificate verification since arbitrary receiving
|
// requirement. Certificate verification is real (ServerName set, no blanket
|
||||||
// MTAs commonly present certs that don't chain cleanly (matches the Python code,
|
// InsecureSkipVerify) unless this is the unverified-fallback attempt — see
|
||||||
// which never configures certificate verification for this opportunistic hop).
|
// trySend above for why/when that happens.
|
||||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||||
tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: true}
|
tlsConfig := &tls.Config{ServerName: host, InsecureSkipVerify: skipVerify, RootCAs: r.rootCAs}
|
||||||
if err := c.StartTLS(tlsConfig); err != nil {
|
if err := c.StartTLS(tlsConfig); err != nil {
|
||||||
|
if !skipVerify {
|
||||||
|
return "", fmt.Errorf("%w: %v", errStartTLSVerifyFailed, err)
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package relay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
|
||||||
|
"mailgoserver/internal/toolbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// acceptAllBackend is a minimal go-smtp Backend/Session that accepts every command —
|
||||||
|
// a stand-in "receiving MTA" for trySend tests, not testing go-smtp itself.
|
||||||
|
type acceptAllBackend struct{ received chan string }
|
||||||
|
|
||||||
|
func (b *acceptAllBackend) NewSession(*smtp.Conn) (smtp.Session, error) {
|
||||||
|
return &acceptAllSession{received: b.received}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type acceptAllSession struct{ received chan string }
|
||||||
|
|
||||||
|
func (s *acceptAllSession) Mail(from string, opts *smtp.MailOptions) error { return nil }
|
||||||
|
func (s *acceptAllSession) Rcpt(to string, opts *smtp.RcptOptions) error { return nil }
|
||||||
|
func (s *acceptAllSession) Data(r io.Reader) error {
|
||||||
|
b, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.received != nil {
|
||||||
|
s.received <- string(b)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *acceptAllSession) Reset() {}
|
||||||
|
func (s *acceptAllSession) Logout() error { return nil }
|
||||||
|
|
||||||
|
// countingListener counts every accepted TCP connection — used to distinguish "one
|
||||||
|
// connection, verified on the first try" from "two connections, the first abandoned
|
||||||
|
// after a failed TLS handshake and a second one succeeding via the unverified
|
||||||
|
// fallback" without needing to inspect trySend's internals or log output directly.
|
||||||
|
type countingListener struct {
|
||||||
|
net.Listener
|
||||||
|
accepted *atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingListener) Accept() (net.Conn, error) {
|
||||||
|
conn, err := c.Listener.Accept()
|
||||||
|
if err == nil {
|
||||||
|
c.accepted.Add(1)
|
||||||
|
}
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// genTestCert generates a throwaway self-signed CA and a leaf certificate for
|
||||||
|
// "127.0.0.1" signed by it — enough to exercise real x509 verification (via
|
||||||
|
// Relay.rootCAs) without needing a system-trusted cert in a test environment.
|
||||||
|
func genTestCert(t *testing.T) (leafCert tls.Certificate, caPool *x509.CertPool) {
|
||||||
|
t.Helper()
|
||||||
|
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
caTemplate := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "test CA"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
IsCA: true,
|
||||||
|
KeyUsage: x509.KeyUsageCertSign,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
caCert, err := x509.ParseCertificate(caDER)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
leafTemplate := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(2),
|
||||||
|
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
}
|
||||||
|
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, caCert, &leafKey.PublicKey, caKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
pool.AddCert(caCert)
|
||||||
|
cert := tls.Certificate{Certificate: [][]byte{leafDER}, PrivateKey: leafKey}
|
||||||
|
return cert, pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTestMTA starts a real go-smtp server (STARTTLS-capable) presenting cert on
|
||||||
|
// 127.0.0.1, returning its port, a channel receiving each DATA payload, and a counter
|
||||||
|
// of accepted TCP connections.
|
||||||
|
func startTestMTA(t *testing.T, cert tls.Certificate) (port int, received chan string, accepted *atomic.Int32) {
|
||||||
|
t.Helper()
|
||||||
|
received = make(chan string, 2)
|
||||||
|
accepted = &atomic.Int32{}
|
||||||
|
be := &acceptAllBackend{received: received}
|
||||||
|
s := smtp.NewServer(be)
|
||||||
|
s.Domain = "mx.example.com"
|
||||||
|
s.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||||
|
s.AllowInsecureAuth = true
|
||||||
|
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cl := &countingListener{Listener: l, accepted: accepted}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
go s.Serve(cl)
|
||||||
|
|
||||||
|
_, portStr, _ := net.SplitHostPort(l.Addr().String())
|
||||||
|
port, err = strconv.Atoi(portStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return port, received, accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrySendVerifiesRealCertOnFirstTry(t *testing.T) {
|
||||||
|
cert, caPool := genTestCert(t)
|
||||||
|
port, received, accepted := startTestMTA(t, cert)
|
||||||
|
|
||||||
|
r := &Relay{Hostname: "sender.example.com", Timeout: 5 * time.Second, Logger: toolbox.GetLogger("relay_test"), port: port, rootCAs: caPool}
|
||||||
|
|
||||||
|
resp, err := r.trySend("127.0.0.1", "test.invalid", "from@example.com", []string{"to@example.com"}, "Subject: hi\r\n\r\nbody")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected delivery to succeed with a CA-trusted cert, got: %v", err)
|
||||||
|
}
|
||||||
|
if resp == "" {
|
||||||
|
t.Fatal("expected a non-empty server response")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-received:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected the test MTA to receive the message")
|
||||||
|
}
|
||||||
|
// Exactly one connection: verification succeeded on the very first try, no
|
||||||
|
// unverified-fallback retry (which would show up as a second accepted connection).
|
||||||
|
if got := accepted.Load(); got != 1 {
|
||||||
|
t.Errorf("expected exactly 1 accepted connection (verified first try), got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrySendFallsBackToUnverifiedOnUntrustedCert(t *testing.T) {
|
||||||
|
cert, _ := genTestCert(t) // leaf cert generated, but its CA is deliberately not trusted below
|
||||||
|
port, received, accepted := startTestMTA(t, cert)
|
||||||
|
|
||||||
|
// An empty pool (not nil) so this can't accidentally pass by falling through to a
|
||||||
|
// real system-trusted cert.
|
||||||
|
r := &Relay{Hostname: "sender.example.com", Timeout: 5 * time.Second, Logger: toolbox.GetLogger("relay_test"), port: port, rootCAs: x509.NewCertPool()}
|
||||||
|
|
||||||
|
resp, err := r.trySend("127.0.0.1", "test.invalid", "from@example.com", []string{"to@example.com"}, "Subject: hi\r\n\r\nbody")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected delivery to still succeed via the unverified fallback, got: %v", err)
|
||||||
|
}
|
||||||
|
if resp == "" {
|
||||||
|
t.Fatal("expected a non-empty server response")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-received:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("expected the test MTA to receive the message despite the untrusted cert")
|
||||||
|
}
|
||||||
|
// Two connections: the first is abandoned after a failed verified STARTTLS
|
||||||
|
// handshake, the second succeeds via the InsecureSkipVerify fallback.
|
||||||
|
if got := accepted.Load(); got != 2 {
|
||||||
|
t.Errorf("expected exactly 2 accepted connections (verified attempt + unverified fallback), got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTrySendSkipsFallbackForMTASTSEnforcedDomain confirms a domain whose policy
|
||||||
|
// resolves as MTA-STS-enforced never gets the unverified-fallback retry — it should
|
||||||
|
// fail outright on the untrusted cert, matching what a domain that opted into strict
|
||||||
|
// enforcement asked for.
|
||||||
|
func TestTrySendSkipsFallbackForMTASTSEnforcedDomain(t *testing.T) {
|
||||||
|
cert, _ := genTestCert(t) // untrusted below, same as the fallback test
|
||||||
|
port, _, accepted := startTestMTA(t, cert)
|
||||||
|
|
||||||
|
r := &Relay{
|
||||||
|
Hostname: "sender.example.com", Timeout: 5 * time.Second, Logger: toolbox.GetLogger("relay_test"),
|
||||||
|
port: port, rootCAs: x509.NewCertPool(),
|
||||||
|
mtaSTSCheck: func(domain string) bool { return domain == "strict.example" },
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.trySend("127.0.0.1", "strict.example", "from@example.com", []string{"to@example.com"}, "Subject: hi\r\n\r\nbody")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected delivery to fail outright for an MTA-STS-enforced domain with an untrusted cert, not fall back")
|
||||||
|
}
|
||||||
|
// Exactly one connection: the verified attempt, abandoned — no second
|
||||||
|
// (unverified-fallback) connection should ever have been made.
|
||||||
|
if got := accepted.Load(); got != 1 {
|
||||||
|
t.Errorf("expected exactly 1 accepted connection (no fallback attempt), got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package smtpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDomainSendRateLimited is a white-box unit test of the core rate-limit decision
|
||||||
|
// (no real SMTP transaction, no network) — see TestSendRateLimitRejectsOverLimitMail
|
||||||
|
// below for the live end-to-end wiring check.
|
||||||
|
func TestDomainSendRateLimited(t *testing.T) {
|
||||||
|
backend := newTestBackend(t)
|
||||||
|
domainID, err := backend.DB.CreateDomain("ratelimited.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
limit := 2
|
||||||
|
if err := backend.DB.SetDomainSendRateLimit(domainID, &limit); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := &Session{backend: backend}
|
||||||
|
|
||||||
|
if limited, err := s.domainSendRateLimited("ratelimited.example"); err != nil || limited {
|
||||||
|
t.Fatalf("expected not limited with zero sends so far, limited=%v err=%v", limited, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if _, err := backend.DB.InsertEmailLog(db.EmailLog{
|
||||||
|
MessageID: "m" + string(rune('a'+i)) + "@ratelimited.example", Timestamp: time.Now(),
|
||||||
|
MailFrom: "sender@ratelimited.example", EmailHeaders: "h", Status: "relayed",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limited, err := s.domainSendRateLimited("ratelimited.example"); err != nil || !limited {
|
||||||
|
t.Fatalf("expected limited after hitting the cap of 2, limited=%v err=%v", limited, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unconfigured domain (no limit set) must never limit, regardless of volume.
|
||||||
|
if limited, err := s.domainSendRateLimited("example.com"); err != nil || limited {
|
||||||
|
t.Fatalf("expected no limit for a domain with send_rate_limit_per_hour unset, limited=%v err=%v", limited, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unrecognized domain must never limit either (not this server's problem to
|
||||||
|
// cap, and GetDomainByName returning nil must fail open, not error).
|
||||||
|
if limited, err := s.domainSendRateLimited("nowhere.invalid"); err != nil || limited {
|
||||||
|
t.Fatalf("expected no limit for an unrecognized domain, limited=%v err=%v", limited, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSendRateLimitRejectsOverLimitMail confirms the real wiring: an authorized
|
||||||
|
// sender's relay recipient gets a 450 with a rate-limit reason recorded once the
|
||||||
|
// domain's cap is hit, via a real SMTP transaction end to end.
|
||||||
|
func TestSendRateLimitRejectsOverLimitMail(t *testing.T) {
|
||||||
|
backend, _ := newTestBackendWithMailbox(t)
|
||||||
|
domains, err := backend.DB.ListDomains()
|
||||||
|
if err != nil || len(domains) == 0 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
limit := 0
|
||||||
|
if err := backend.DB.SetDomainSendRateLimit(domains[0].ID, &limit); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
c, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Mail("test@example.com"); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("someone@elsewhere.example"); err != nil {
|
||||||
|
t.Fatalf("RCPT: %v", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Write([]byte("Subject: hi\r\n\r\nhi"))
|
||||||
|
// The final response is the same generic 550 either way (see Data()'s fixed
|
||||||
|
// message for a total failure) — what actually distinguishes "rate limited" from
|
||||||
|
// "a real relay failure" is the recorded per-recipient reason, checked below.
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
var errMsg string
|
||||||
|
if err := backend.DB.QueryRow(`SELECT error_message FROM esrv_email_recipient_logs ORDER BY id DESC LIMIT 1`).Scan(&errMsg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if errMsg == "" || !strings.Contains(errMsg, "rate limit") {
|
||||||
|
t.Fatalf("expected the recipient log's error to mention the rate limit, got %q", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,6 +163,40 @@ func ensureRequiredHeaders(content, messageID string, envelopeRcptTos []string,
|
|||||||
return strings.Join(out, "\r\n") + "\r\n\r\n" + body
|
return strings.Join(out, "\r\n") + "\r\n\r\n" + body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prependSubjectTag returns content with tag prepended to its Subject header value,
|
||||||
|
// e.g. "Subject: hello" -> "Subject: ***Failed DKIM*** hello" — used to flag inbound
|
||||||
|
// mail that fails DKIM/SPF/spam checks (see deliverLocally in session.go). By the time
|
||||||
|
// this runs, ensureRequiredHeaders has already guaranteed a single, unfolded Subject
|
||||||
|
// line (existingHeaders flattens any folded original into one line), so this only ever
|
||||||
|
// needs to patch one line — content's body and every other header are untouched. A
|
||||||
|
// pre-existing RFC 2047 encoded-word subject is left encoded as-is after the new
|
||||||
|
// plain-ASCII tag: encoded-words and plain text may coexist in one header value,
|
||||||
|
// whitespace-separated, so no decode/re-encode is needed. No-op if content has no
|
||||||
|
// Subject header at all (shouldn't happen post-ensureRequiredHeaders, but safe either way).
|
||||||
|
func prependSubjectTag(content, tag string) string {
|
||||||
|
headerBlock, body := splitHeadersBody(content)
|
||||||
|
lines := strings.Split(headerBlock, "\r\n")
|
||||||
|
found := false
|
||||||
|
for i, line := range lines {
|
||||||
|
if len(line) >= 8 && strings.EqualFold(line[:8], "subject:") {
|
||||||
|
newVal := "***" + tag + "***"
|
||||||
|
if orig := strings.TrimSpace(line[8:]); orig != "" {
|
||||||
|
newVal += " " + orig
|
||||||
|
}
|
||||||
|
lines[i] = "Subject: " + newVal
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
// ensureRequiredHeaders (which always runs earlier in the pipeline) unconditionally
|
||||||
|
// emits a "\r\n\r\n"-separated header/body boundary, so content reaching here always
|
||||||
|
// has one — no need to detect/preserve a bare "\n\n" alternative.
|
||||||
|
return strings.Join(lines, "\r\n") + "\r\n\r\n" + body
|
||||||
|
}
|
||||||
|
|
||||||
// getContentType mirrors smtp_handler.get_content_type: prefer the part's own type,
|
// getContentType mirrors smtp_handler.get_content_type: prefer the part's own type,
|
||||||
// fall back to extension sniffing, then a small fixed extension map.
|
// fall back to extension sniffing, then a small fixed extension map.
|
||||||
func getContentType(partContentType, filename string) string {
|
func getContentType(partContentType, filename string) string {
|
||||||
|
|||||||
@@ -91,3 +91,47 @@ func TestExtractMessageIDKeepsMatchingHostname(t *testing.T) {
|
|||||||
t.Errorf("id = %q, want unchanged", id)
|
t.Errorf("id = %q, want unchanged", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrependSubjectTagSimple(t *testing.T) {
|
||||||
|
raw := "Message-ID: <a@b>\r\nSubject: hello\r\n\r\nbody text"
|
||||||
|
out := prependSubjectTag(raw, "Failed DKIM")
|
||||||
|
want := "Message-ID: <a@b>\r\nSubject: ***Failed DKIM*** hello\r\n\r\nbody text"
|
||||||
|
if out != want {
|
||||||
|
t.Errorf("got %q, want %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrependSubjectTagCombinedReasons(t *testing.T) {
|
||||||
|
raw := "Subject: hi\r\n\r\nbody"
|
||||||
|
out := prependSubjectTag(raw, "Failed SPF, Failed DKIM")
|
||||||
|
want := "Subject: ***Failed SPF, Failed DKIM*** hi\r\n\r\nbody"
|
||||||
|
if out != want {
|
||||||
|
t.Errorf("got %q, want %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrependSubjectTagEncodedWordSubjectLeftEncoded(t *testing.T) {
|
||||||
|
raw := "Subject: =?UTF-8?B?SGVsbG8=?=\r\n\r\nbody"
|
||||||
|
out := prependSubjectTag(raw, "SPAM")
|
||||||
|
want := "Subject: ***SPAM*** =?UTF-8?B?SGVsbG8=?=\r\n\r\nbody"
|
||||||
|
if out != want {
|
||||||
|
t.Errorf("got %q, want %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrependSubjectTagEmptySubject(t *testing.T) {
|
||||||
|
raw := "Subject: \r\n\r\nbody"
|
||||||
|
out := prependSubjectTag(raw, "SPAM")
|
||||||
|
want := "Subject: ***SPAM***\r\n\r\nbody"
|
||||||
|
if out != want {
|
||||||
|
t.Errorf("got %q, want %q", out, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrependSubjectTagNoSubjectHeaderIsNoOp(t *testing.T) {
|
||||||
|
raw := "Message-ID: <a@b>\r\n\r\nbody text"
|
||||||
|
out := prependSubjectTag(raw, "SPAM")
|
||||||
|
if out != raw {
|
||||||
|
t.Errorf("got %q, want unchanged %q", out, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package smtpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/smtp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sendFrom(t *testing.T, addr, from, to, subject string) error {
|
||||||
|
t.Helper()
|
||||||
|
c, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Mail(from); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt(to); err != nil {
|
||||||
|
t.Fatalf("RCPT: %v", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Write([]byte("Subject: " + subject + "\r\n\r\nhi"))
|
||||||
|
return w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoReplyRuleFiresOnceThenSuppressedFor24h confirms an 'auto_reply' filter rule
|
||||||
|
// sends a reply on the first matching message from a sender, still stores the
|
||||||
|
// original message normally in INBOX (auto-reply is additive, not a replacement
|
||||||
|
// action), and suppresses a second reply to the same sender within the loop-prevention
|
||||||
|
// window — without needing to wait a real 24h, by directly manipulating the DB layer
|
||||||
|
// HasRecentAutoReply/RecordAutoReply already tested at unit-test speed.
|
||||||
|
func TestAutoReplyRuleFiresOnceThenSuppressedFor24h(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
conditions := []db.RuleCondition{{Field: "subject", Op: "contains", Value: "vacation"}}
|
||||||
|
if _, err := backend.DB.CreateRuleMulti(mailboxID, 0, conditions, "all", "", "auto_reply", "Out of office", `{"body":"I am away."}`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendFrom(t, addr, "sender@example.org", "inbox@example.com", "vacation question"); err != nil {
|
||||||
|
t.Fatalf("first send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The message itself must still be stored normally — auto_reply doesn't divert it.
|
||||||
|
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil || len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected the original message still stored in INBOX, got %d (err=%v)", len(msgs), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop-prevention must now be recorded for this sender.
|
||||||
|
recent, err := backend.DB.HasRecentAutoReply(mailboxID, "sender@example.org")
|
||||||
|
if err != nil || !recent {
|
||||||
|
t.Fatalf("expected an auto-reply to be recorded, HasRecentAutoReply = %v, err=%v", recent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second matching message from the same sender within the window must not
|
||||||
|
// record a second auto-reply (RecordAutoReply only called once).
|
||||||
|
if err := sendFrom(t, addr, "sender@example.org", "inbox@example.com", "vacation again"); err != nil {
|
||||||
|
t.Fatalf("second send: %v", err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := backend.DB.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_autoreply_log WHERE mailbox_id = ? AND sender_addr = ?`, mailboxID, "sender@example.org").Scan(&count); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 auto-reply log row after two messages within the window, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoReplyNeverFiresForNullSender confirms sendAutoReply itself refuses a
|
||||||
|
// null-sender message — the classic bounce-loop bug this codebase already avoids for
|
||||||
|
// SendBounce. A white-box unit test, not a live SMTP round trip: Session.Mail()
|
||||||
|
// already hard-rejects MAIL FROM:<> for every sender category before DATA is ever
|
||||||
|
// reached (a separate, pre-existing gap — this server currently can't accept a
|
||||||
|
// genuine bounce/DSN from another real MTA at all — out of scope for this feature),
|
||||||
|
// so sendAutoReply's own guard is a defense-in-depth check that isn't reachable
|
||||||
|
// through the live protocol today; this test exercises it directly instead.
|
||||||
|
func TestAutoReplyNeverFiresForNullSender(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
mbox, err := backend.DB.GetMailboxByID(mailboxID)
|
||||||
|
if err != nil || mbox == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := &Session{backend: backend, mailFrom: ""}
|
||||||
|
s.sendAutoReply(mbox, "Out of office", "away", "")
|
||||||
|
|
||||||
|
var count int
|
||||||
|
if err := backend.DB.QueryRow(`SELECT COUNT(*) FROM esrv_mailbox_autoreply_log WHERE mailbox_id = ?`, mailboxID).Scan(&count); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("expected no auto-reply recorded for a null-sender session, got %d log row(s)", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,18 @@ func newTestBackendWithMailbox(t *testing.T) (*Backend, int64) {
|
|||||||
// tests exercise local-delivery wiring, not spam-scoring accuracy (see
|
// tests exercise local-delivery wiring, not spam-scoring accuracy (see
|
||||||
// internal/mailstore's own tests for that), so disable rejection entirely here.
|
// internal/mailstore's own tests for that), so disable rejection entirely here.
|
||||||
backend.Cfg.Section("Mailstore").Key("spam_reject_score").SetValue("1000000")
|
backend.Cfg.Section("Mailstore").Key("spam_reject_score").SetValue("1000000")
|
||||||
|
// Same reasoning as above: these tests' plain unsigned messages have no real DKIM
|
||||||
|
// signature and no real SPF-published sender domain, so independent DKIM/SPF
|
||||||
|
// enforcement (session.go's deliverLocally) would otherwise quarantine them to
|
||||||
|
// Junk regardless of the spam-score override — tests specifically about DKIM/SPF
|
||||||
|
// enforcement (mailbox_spam_test.go) re-enable these explicitly per test. DMARC is
|
||||||
|
// disabled for the same reason and one more: example.com (the sender domain every
|
||||||
|
// test in this package uses) publishes a real "p=reject" DMARC record, which would
|
||||||
|
// hard-reject every unsigned test message outright rather than merely quarantine
|
||||||
|
// it — tests specifically about DMARC re-enable this explicitly per test.
|
||||||
|
backend.Cfg.Section("Mailstore").Key("enforce_dkim").SetValue("false")
|
||||||
|
backend.Cfg.Section("Mailstore").Key("enforce_spf").SetValue("false")
|
||||||
|
backend.Cfg.Section("Mailstore").Key("enforce_dmarc").SetValue("false")
|
||||||
|
|
||||||
dek := mailstore.GenerateDEK()
|
dek := mailstore.GenerateDEK()
|
||||||
wrapped, nonce, err := store.WrapDEK(dek)
|
wrapped, nonce, err := store.WrapDEK(dek)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package smtpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDMARCFailureHardRejects confirms a message whose From: domain publishes a real
|
||||||
|
// "p=reject" DMARC policy (example.com does — the same domain every test in this
|
||||||
|
// package sends from) is hard-rejected at SMTP time when it's neither DKIM- nor
|
||||||
|
// SPF-aligned, since these unsigned test messages are never DKIM-signed and 127.0.0.1
|
||||||
|
// never passes example.com's SPF.
|
||||||
|
func TestDMARCFailureHardRejects(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
backend.Cfg.Section("Mailstore").Key("enforce_dmarc").SetValue("true")
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "hello"); err == nil {
|
||||||
|
t.Fatal("expected delivery to be hard-rejected for a DMARC p=reject failure")
|
||||||
|
}
|
||||||
|
inboxMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
spamMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "Junk")
|
||||||
|
if len(inboxMsgs) != 0 || len(spamMsgs) != 0 {
|
||||||
|
t.Fatalf("expected nothing stored anywhere for a hard reject, got INBOX=%d Junk=%d", len(inboxMsgs), len(spamMsgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAllowScopeDMARCSuppressesOnlyDMARC confirms a "dmarc"-scoped whitelist entry
|
||||||
|
// suppresses just the DMARC check for that sender, letting an otherwise-unflagged
|
||||||
|
// message land in INBOX untagged even though example.com's real policy is p=reject.
|
||||||
|
func TestAllowScopeDMARCSuppressesOnlyDMARC(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
backend.Cfg.Section("Mailstore").Key("enforce_dmarc").SetValue("true")
|
||||||
|
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "allow", "test@example.com", "dmarc"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "hello"); err != nil {
|
||||||
|
t.Fatalf("expected delivery accepted (DMARC suppressed by scope), got: %v", err)
|
||||||
|
}
|
||||||
|
msg, _ := firstMessage(t, backend, mailboxID, "INBOX")
|
||||||
|
if msg.CachedSubject != "hello" {
|
||||||
|
t.Fatalf("cached subject = %q, want untagged (DMARC suppressed by scope, nothing else flags this message)", msg.CachedSubject)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package smtpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/smtp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPersistentMailboxForwardKeepCopyFalseDropsLocalCopy confirms a mailbox-level
|
||||||
|
// forward (webmail account settings — distinct from a filter rule's own "forward"
|
||||||
|
// action, see mailbox_rules_test.go's equivalents) with keep_copy=false forwards and
|
||||||
|
// skips local storage entirely.
|
||||||
|
func TestPersistentMailboxForwardKeepCopyFalseDropsLocalCopy(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
if err := backend.DB.SetMailboxForwarding(mailboxID, "elsewhere@forward-test.invalid", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
c, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Mail("test@example.com"); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||||
|
t.Fatalf("RCPT: %v", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Write([]byte("Subject: hello\r\n\r\nhi"))
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("expected DATA to still report success even though forwarding drops the local copy, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(msgs) != 0 {
|
||||||
|
t.Fatalf("expected mailbox forwarding with keep_copy=false to skip local storage, found %d messages", len(msgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPersistentMailboxForwardKeepCopyTrueStillStoresLocally confirms the default
|
||||||
|
// (keep_copy=true) still stores the mailbox owner's own copy alongside forwarding.
|
||||||
|
func TestPersistentMailboxForwardKeepCopyTrueStillStoresLocally(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
if err := backend.DB.SetMailboxForwarding(mailboxID, "elsewhere@forward-test.invalid", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
c, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Mail("test@example.com"); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||||
|
t.Fatalf("RCPT: %v", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Write([]byte("Subject: hello\r\n\r\nhi"))
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("DATA: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected mailbox forwarding with keep_copy=true to still store locally, found %d messages", len(msgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoForwardingConfiguredIsANoop confirms an ordinary mailbox with no forward_to
|
||||||
|
// set behaves exactly as before this feature existed.
|
||||||
|
func TestNoForwardingConfiguredIsANoop(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
c, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Mail("test@example.com"); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("inbox@example.com"); err != nil {
|
||||||
|
t.Fatalf("RCPT: %v", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Write([]byte("Subject: hello\r\n\r\nhi"))
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("DATA: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil || len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 locally stored message with no forwarding configured, got %d (err=%v)", len(msgs), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
func TestBlockedSenderRejectedAtRcpt(t *testing.T) {
|
func TestBlockedSenderRejectedAtRcpt(t *testing.T) {
|
||||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "block", "test@example.com"); err != nil {
|
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "block", "test@example.com", "all"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
addr := startTestServer(t, backend)
|
addr := startTestServer(t, backend)
|
||||||
@@ -77,7 +77,7 @@ func TestAllowListBypassesSpamQuarantine(t *testing.T) {
|
|||||||
t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err)
|
t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "allow", "test@example.com"); err != nil {
|
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "allow", "test@example.com", "all"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := send(t); err != nil {
|
if err := send(t); err != nil {
|
||||||
@@ -96,7 +96,7 @@ func TestAllowListBypassesSpamQuarantine(t *testing.T) {
|
|||||||
// but decided unconditionally rather than computed.
|
// but decided unconditionally rather than computed.
|
||||||
func TestJunkListQuarantinesWithoutRejecting(t *testing.T) {
|
func TestJunkListQuarantinesWithoutRejecting(t *testing.T) {
|
||||||
backend, mailboxID := newTestBackendWithMailbox(t)
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "junk", "test@example.com"); err != nil {
|
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "junk", "test@example.com", "all"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
addr := startTestServer(t, backend)
|
addr := startTestServer(t, backend)
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeRspamd stands in for a real rspamd instance, always returning the fixed
|
// fakeRspamd stands in for a real rspamd instance, always returning the fixed
|
||||||
@@ -83,3 +86,105 @@ func TestRspamdScoreThresholdQuarantinesInsteadOfRejecting(t *testing.T) {
|
|||||||
t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err)
|
t.Fatalf("expected 1 quarantined message in Spam, got %d (err=%v)", len(spamMsgs), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// firstMessage fetches the single message expected in folder, failing the test if
|
||||||
|
// there isn't exactly one — a small shared helper for the tagging tests below, which
|
||||||
|
// all need to inspect both the cached subject and the raw stored content.
|
||||||
|
func firstMessage(t *testing.T, backend *Backend, mailboxID int64, folder string) (db.MailboxMessage, []byte) {
|
||||||
|
t.Helper()
|
||||||
|
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, folder)
|
||||||
|
if err != nil || len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 message in %s, got %d (err=%v)", folder, len(msgs), err)
|
||||||
|
}
|
||||||
|
raw, err := backend.Mailstore.FetchMessage(mailboxID, msgs[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return msgs[0], raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDKIMFailureQuarantinesAndTagsSubject confirms a message with no valid DKIM
|
||||||
|
// signature — the default for these unsigned test messages, since the test domain has
|
||||||
|
// no DKIM key configured — is independently quarantined to Junk with a "***Failed
|
||||||
|
// DKIM***" subject tag, both in the cached subject (list view) and the actual stored
|
||||||
|
// header (any IMAP client), even though the heuristic spam score alone wouldn't have
|
||||||
|
// crossed the reject threshold.
|
||||||
|
func TestDKIMFailureQuarantinesAndTagsSubject(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
backend.Cfg.Section("Mailstore").Key("enforce_dkim").SetValue("true")
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "hello"); err != nil {
|
||||||
|
t.Fatalf("expected delivery accepted (quarantined), got: %v", err)
|
||||||
|
}
|
||||||
|
msg, raw := firstMessage(t, backend, mailboxID, "Junk")
|
||||||
|
if msg.CachedSubject != "***Failed DKIM*** hello" {
|
||||||
|
t.Fatalf("cached subject = %q, want tagged", msg.CachedSubject)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), "Subject: ***Failed DKIM*** hello") {
|
||||||
|
t.Fatalf("stored raw content missing tagged Subject header: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAllowScopeDKIMSuppressesOnlyDKIM confirms a "dkim"-scoped whitelist entry
|
||||||
|
// suppresses the DKIM check specifically for that sender, without suppressing the
|
||||||
|
// independent spam-score check — a message that both fails DKIM and hits a spam
|
||||||
|
// keyword still lands in Junk tagged only "***SPAM***", not the full-bypass INBOX
|
||||||
|
// outcome a scope="all" entry would produce.
|
||||||
|
func TestAllowScopeDKIMSuppressesOnlyDKIM(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
backend.Cfg.Section("Mailstore").Key("enforce_dkim").SetValue("true")
|
||||||
|
backend.Cfg.Section("Mailstore").Key("spam_reject_score").SetValue("1") // any keyword hit is enough
|
||||||
|
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "allow", "test@example.com", "dkim"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "casino night"); err != nil {
|
||||||
|
t.Fatalf("expected delivery accepted (quarantined), got: %v", err)
|
||||||
|
}
|
||||||
|
msg, _ := firstMessage(t, backend, mailboxID, "Junk")
|
||||||
|
if msg.CachedSubject != "***SPAM*** casino night" {
|
||||||
|
t.Fatalf("cached subject = %q, want only the SPAM tag (DKIM suppressed by scope)", msg.CachedSubject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAllowScopeAllBypassesDKIMToo confirms a full "all"-scoped whitelist entry still
|
||||||
|
// bypasses DKIM enforcement entirely (the pre-existing full-bypass behavior every
|
||||||
|
// pre-migration "allow" row keeps, per the scope column's backward-compatible default).
|
||||||
|
func TestAllowScopeAllBypassesDKIMToo(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
backend.Cfg.Section("Mailstore").Key("enforce_dkim").SetValue("true")
|
||||||
|
if _, err := backend.DB.AddAllowBlockEntry(mailboxID, "allow", "test@example.com", "all"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "hello"); err != nil {
|
||||||
|
t.Fatalf("expected delivery accepted, got: %v", err)
|
||||||
|
}
|
||||||
|
msg, _ := firstMessage(t, backend, mailboxID, "INBOX")
|
||||||
|
if msg.CachedSubject != "hello" {
|
||||||
|
t.Fatalf("cached subject = %q, want untagged (fully bypassed)", msg.CachedSubject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDKIMGatedMessageSkipsFilterRules confirms security-driven Junk routing keeps its
|
||||||
|
// existing precedence over the mailbox owner's own filter rules: a rule that would
|
||||||
|
// otherwise move this sender's mail to a custom folder is never applied once the
|
||||||
|
// message is DKIM-gated to Junk.
|
||||||
|
func TestDKIMGatedMessageSkipsFilterRules(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
backend.Cfg.Section("Mailstore").Key("enforce_dkim").SetValue("true")
|
||||||
|
if _, err := backend.DB.CreateRule(mailboxID, 0, "from", "contains", "test@example.com", "move_to_folder", "Important"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "hello"); err != nil {
|
||||||
|
t.Fatalf("expected delivery accepted (quarantined), got: %v", err)
|
||||||
|
}
|
||||||
|
// firstMessage fails the test if the rule's target folder ("Important") got the
|
||||||
|
// message instead of Junk.
|
||||||
|
firstMessage(t, backend, mailboxID, "Junk")
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ func ResolveBanner(cfg *ini.File, heloHostname string) string {
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyLimits sets go-smtp's own message-size/recipient-count caps from
|
||||||
|
// [Mailstore]'s max_message_bytes/max_recipients (both previously unset, meaning
|
||||||
|
// unlimited — library defaults). 25MB/100 are generous-but-bounded defaults: enough
|
||||||
|
// for normal mail with attachments, not enough for someone to tie up a connection
|
||||||
|
// streaming an unbounded body or listing thousands of recipients in one envelope.
|
||||||
|
func applyLimits(s *smtp.Server, cfg *ini.File) {
|
||||||
|
sec := cfg.Section("Mailstore")
|
||||||
|
s.MaxMessageBytes = sec.Key("max_message_bytes").MustInt64(25 * 1024 * 1024)
|
||||||
|
s.MaxRecipients = sec.Key("max_recipients").MustInt(100)
|
||||||
|
}
|
||||||
|
|
||||||
// NewPlainServer mirrors server_runner.py's PlainController: no TLS context at all, so
|
// NewPlainServer mirrors server_runner.py's PlainController: no TLS context at all, so
|
||||||
// STARTTLS is never offered, and AUTH is advertised and usable in plaintext
|
// STARTTLS is never offered, and AUTH is advertised and usable in plaintext
|
||||||
// (auth_require_tls=False).
|
// (auth_require_tls=False).
|
||||||
@@ -38,6 +49,7 @@ func NewPlainServer(backend *Backend, addr, banner string) *smtp.Server {
|
|||||||
s.AllowInsecureAuth = true
|
s.AllowInsecureAuth = true
|
||||||
s.ReadTimeout = 5 * time.Minute
|
s.ReadTimeout = 5 * time.Minute
|
||||||
s.WriteTimeout = 5 * time.Minute
|
s.WriteTimeout = 5 * time.Minute
|
||||||
|
applyLimits(s, backend.Cfg)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,5 +66,6 @@ func NewTLSServer(backend *Backend, addr, banner string, tlsConfig *tls.Config)
|
|||||||
s.AllowInsecureAuth = true
|
s.AllowInsecureAuth = true
|
||||||
s.ReadTimeout = 5 * time.Minute
|
s.ReadTimeout = 5 * time.Minute
|
||||||
s.WriteTimeout = 5 * time.Minute
|
s.WriteTimeout = 5 * time.Minute
|
||||||
|
applyLimits(s, backend.Cfg)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,3 +215,67 @@ func TestSenderCannotSpoofOtherAddress(t *testing.T) {
|
|||||||
t.Fatalf("expected 550 response, got: %v", err)
|
t.Fatalf("expected 550 response, got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMaxRecipientsRejectsExcessRecipients confirms the configured [Mailstore]
|
||||||
|
// max_recipients cap (applyLimits in server.go) is actually enforced by the running
|
||||||
|
// server, not just set and ignored.
|
||||||
|
func TestMaxRecipientsRejectsExcessRecipients(t *testing.T) {
|
||||||
|
backend := newTestBackend(t)
|
||||||
|
backend.Cfg.Section("Mailstore").Key("max_recipients").SetValue("2")
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
c, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Mail("test@example.com"); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("one@elsewhere.example"); err != nil {
|
||||||
|
t.Fatalf("expected recipient 1 (at the limit) to succeed, got: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("two@elsewhere.example"); err != nil {
|
||||||
|
t.Fatalf("expected recipient 2 (at the limit) to succeed, got: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("three@elsewhere.example"); err == nil {
|
||||||
|
t.Fatal("expected recipient 3 (over the configured limit of 2) to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMaxMessageBytesRejectsOversizedMessage confirms the configured [Mailstore]
|
||||||
|
// max_message_bytes cap is actually enforced.
|
||||||
|
func TestMaxMessageBytesRejectsOversizedMessage(t *testing.T) {
|
||||||
|
backend := newTestBackend(t)
|
||||||
|
backend.Cfg.Section("Mailstore").Key("max_message_bytes").SetValue("64")
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
c, err := smtp.Dial(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", "test@example.com", "testpass123", "127.0.0.1")); err != nil {
|
||||||
|
t.Fatalf("auth: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Mail("test@example.com"); err != nil {
|
||||||
|
t.Fatalf("MAIL FROM: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Rcpt("someone@elsewhere.example"); err != nil {
|
||||||
|
t.Fatalf("RCPT: %v", err)
|
||||||
|
}
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DATA: %v", err)
|
||||||
|
}
|
||||||
|
// Comfortably over the 64-byte cap set above.
|
||||||
|
if _, err := w.Write([]byte("Subject: hi\r\n\r\n" + strings.Repeat("x", 500))); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err == nil {
|
||||||
|
t.Fatal("expected the oversized message to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+226
-18
@@ -17,6 +17,7 @@ import (
|
|||||||
"mailgoserver/internal/dkim"
|
"mailgoserver/internal/dkim"
|
||||||
"mailgoserver/internal/mailstore"
|
"mailgoserver/internal/mailstore"
|
||||||
"mailgoserver/internal/mailview"
|
"mailgoserver/internal/mailview"
|
||||||
|
"mailgoserver/internal/notify"
|
||||||
"mailgoserver/internal/relay"
|
"mailgoserver/internal/relay"
|
||||||
"mailgoserver/internal/toolbox"
|
"mailgoserver/internal/toolbox"
|
||||||
)
|
)
|
||||||
@@ -33,6 +34,11 @@ type Backend struct {
|
|||||||
Logger *toolbox.Logger
|
Logger *toolbox.Logger
|
||||||
HeloHostname string
|
HeloHostname string
|
||||||
AttachmentsBasePath string
|
AttachmentsBasePath string
|
||||||
|
// Notify publishes "a message landed" events for IMAP IDLE push
|
||||||
|
// (internal/imapserver) and webmail SSE push (internal/webui) — nil-safe
|
||||||
|
// (notify.Bus.Publish no-ops on a nil receiver), so it's optional wiring, not a
|
||||||
|
// required dependency for tests that don't care about push.
|
||||||
|
Notify *notify.Bus
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
func (b *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
||||||
@@ -246,6 +252,23 @@ func (s *Session) Data(r io.Reader) error {
|
|||||||
dkimSigned = signedContent != rebuilt
|
dkimSigned = signedContent != rebuilt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Virus scanning runs once per message (unlike rspamd's per-recipient check
|
||||||
|
// below in deliverLocally) — a virus is present or not regardless of who it's
|
||||||
|
// addressed to, so one scan covers both the relay and local-delivery paths that
|
||||||
|
// split further down. Hard-rejects the whole transaction on a positive match,
|
||||||
|
// mirroring rspamd's own "reject" action precedent; fails OPEN on a scanner
|
||||||
|
// error/unreachable clamd (never blocks mail on a scanner outage, same posture
|
||||||
|
// CheckRspamd already has) and is off entirely unless explicitly enabled.
|
||||||
|
if s.backend.Cfg.Section("Mailstore").Key("virus_scan_enabled").MustBool(false) {
|
||||||
|
addr := s.backend.Cfg.Section("Mailstore").Key("clamd_address").MustString("127.0.0.1:3310")
|
||||||
|
if infected, signature, err := mailstore.ScanVirus(addr, []byte(signedContent)); err != nil {
|
||||||
|
s.backend.Logger.Error("virus scan unreachable/errored, delivering normally: %v", err)
|
||||||
|
} else if infected {
|
||||||
|
s.backend.Logger.Warning("rejected infected message from %s (%s)", s.mailFrom, signature)
|
||||||
|
return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.NoEnhancedCode, Message: "Message rejected: virus detected (" + signature + ")"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rebuiltHeaders := existingHeaders(rebuilt)
|
rebuiltHeaders := existingHeaders(rebuilt)
|
||||||
toHeader := rebuiltHeaders["to"]
|
toHeader := rebuiltHeaders["to"]
|
||||||
ccHeader := rebuiltHeaders["cc"]
|
ccHeader := rebuiltHeaders["cc"]
|
||||||
@@ -328,7 +351,16 @@ func (s *Session) Data(r io.Reader) error {
|
|||||||
|
|
||||||
var results []relay.Result
|
var results []relay.Result
|
||||||
if len(relayRcpts) > 0 {
|
if len(relayRcpts) > 0 {
|
||||||
results = s.backend.Relay.RelayEmailAsync(s.mailFrom, relayRcpts, signedContent, relayTypes)
|
if limited, err := s.domainSendRateLimited(senderDomain); err != nil {
|
||||||
|
s.backend.Logger.Error("send-rate-limit check for domain %s: %v", senderDomain, err)
|
||||||
|
results = s.backend.Relay.RelayEmailAsync(s.mailFrom, relayRcpts, signedContent, relayTypes)
|
||||||
|
} else if limited {
|
||||||
|
for i, rcpt := range relayRcpts {
|
||||||
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: relayTypes[i], Status: "failed", ErrorCode: "450", ErrorMessage: "Sending rate limit exceeded for domain " + senderDomain + ", try again later"})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results = s.backend.Relay.RelayEmailAsync(s.mailFrom, relayRcpts, signedContent, relayTypes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(localRcpts) > 0 {
|
if len(localRcpts) > 0 {
|
||||||
results = append(results, s.deliverLocally(localRcpts, localTypes, signedContent, messageID, subject, fromHeader)...)
|
results = append(results, s.deliverLocally(localRcpts, localTypes, signedContent, messageID, subject, fromHeader)...)
|
||||||
@@ -445,17 +477,55 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enforceDKIM := s.backend.Cfg.Section("Mailstore").Key("enforce_dkim").MustBool(true)
|
||||||
|
enforceSPF := s.backend.Cfg.Section("Mailstore").Key("enforce_spf").MustBool(true)
|
||||||
|
enforceDMARC := s.backend.Cfg.Section("Mailstore").Key("enforce_dmarc").MustBool(true)
|
||||||
|
|
||||||
|
// DMARC ties DKIM/SPF together via alignment to the visible From: header's domain
|
||||||
|
// — a materially different check from dkimPass/spfPass above (which align to the
|
||||||
|
// SMTP envelope's MAIL FROM domain, senderDomain — the two commonly match but
|
||||||
|
// DMARC specifically cares about the header, since that's what the recipient
|
||||||
|
// actually sees and what phishing spoofs). Computed once here, applied per
|
||||||
|
// recipient below (same shape as dkimPass/spfPass/heuristicScore) so the
|
||||||
|
// per-mailbox whitelist scope can still suppress it independently.
|
||||||
|
fromHeaderAddrs := parseAddressList(fromDisplay)
|
||||||
|
fromHeaderDomain := ""
|
||||||
|
if len(fromHeaderAddrs) > 0 {
|
||||||
|
fromHeaderDomain = domainOfAddr(fromHeaderAddrs[0])
|
||||||
|
}
|
||||||
|
dmarcFailPolicy := "" // "", "quarantine", or "reject" — "" means DMARC didn't fail (or wasn't evaluated)
|
||||||
|
if enforceDMARC && fromHeaderDomain != "" {
|
||||||
|
if pol := mailstore.LookupDMARCPolicy(fromHeaderDomain); pol != nil {
|
||||||
|
orgDomain := mailstore.OrganizationalDomain(fromHeaderDomain)
|
||||||
|
dkimAligned := dkim.VerifyInbound(signedContent, fromHeaderDomain)
|
||||||
|
spfAligned := spfPass && mailstore.OrganizationalDomain(senderDomain) == orgDomain
|
||||||
|
if !dkimAligned && !spfAligned {
|
||||||
|
// ponytail: pct= sampling (gradual DMARC rollout) isn't applied — the
|
||||||
|
// full effective policy always enforces regardless of pct, which is
|
||||||
|
// strictly more cautious than what a pct<100 domain owner asked for,
|
||||||
|
// never less. Add real sampling if a pct<100 domain's mail needs to
|
||||||
|
// land in INBOX during a deliberate rollout.
|
||||||
|
dmarcFailPolicy = pol.EffectivePolicy(fromHeaderDomain, orgDomain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
results := make([]relay.Result, 0, len(rcpts))
|
results := make([]relay.Result, 0, len(rcpts))
|
||||||
for i, rcpt := range rcpts {
|
for i, rcpt := range rcpts {
|
||||||
mbox := s.localMailboxes[strings.ToLower(rcpt)]
|
mbox := s.localMailboxes[strings.ToLower(rcpt)]
|
||||||
folder := "INBOX"
|
folder := "INBOX"
|
||||||
markRead := false
|
markRead := false
|
||||||
|
|
||||||
// An explicit per-mailbox allow-list entry bypasses spam scoring entirely —
|
|
||||||
// the built-in heuristic and optional rspamd check both run regardless of each
|
|
||||||
// other (additive, not either/or), but neither runs at all once allow-listed.
|
|
||||||
spamGated := false
|
spamGated := false
|
||||||
if allowed, _ := s.backend.DB.IsAllowed(mbox.ID, s.mailFrom); !allowed {
|
var tags []string
|
||||||
|
|
||||||
|
// An explicit per-mailbox "allow" entry scoped to "all" bypasses every check
|
||||||
|
// below entirely — the pre-existing full-bypass behavior, still available via
|
||||||
|
// the scope picker (webmail_blocklist.html). A narrower scope (spf/dkim/spam)
|
||||||
|
// only suppresses that one check; the others below still apply independently.
|
||||||
|
scope, hasAllow, _ := s.backend.DB.AllowScope(mbox.ID, s.mailFrom)
|
||||||
|
if hasAllow && scope == "all" {
|
||||||
|
// unchanged existing behavior: full bypass, no scoring, no tagging
|
||||||
|
} else if junked, _ := s.backend.DB.IsJunked(mbox.ID, s.mailFrom); junked {
|
||||||
// The mailbox owner's own Blocklist (webmail Settings, or the message-view
|
// The mailbox owner's own Blocklist (webmail Settings, or the message-view
|
||||||
// "Mark as Junk" action — internal/webui's webmailMarkAsJunk) also bypasses
|
// "Mark as Junk" action — internal/webui's webmailMarkAsJunk) also bypasses
|
||||||
// scoring entirely, straight to Junk: the user already told us how to
|
// scoring entirely, straight to Junk: the user already told us how to
|
||||||
@@ -464,12 +534,33 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
|||||||
// just hidden), unlike admin's separate hard-reject block list
|
// just hidden), unlike admin's separate hard-reject block list
|
||||||
// (IsBlocked, checked at RCPT time — see Rcpt()) — those are different
|
// (IsBlocked, checked at RCPT time — see Rcpt()) — those are different
|
||||||
// tools for different jobs, not two ways to do the same thing.
|
// tools for different jobs, not two ways to do the same thing.
|
||||||
if junked, _ := s.backend.DB.IsJunked(mbox.ID, s.mailFrom); junked {
|
folder = "Junk"
|
||||||
folder = "Junk"
|
spamGated = true
|
||||||
spamGated = true
|
} else {
|
||||||
} else {
|
suppressDKIM := hasAllow && scope == "dkim"
|
||||||
|
suppressSPF := hasAllow && scope == "spf"
|
||||||
|
suppressSpam := hasAllow && scope == "spam"
|
||||||
|
suppressDMARC := hasAllow && scope == "dmarc"
|
||||||
|
|
||||||
|
if enforceDKIM && !dkimPass && !suppressDKIM {
|
||||||
|
tags = append(tags, "Failed DKIM")
|
||||||
|
}
|
||||||
|
if enforceSPF && !spfPass && !suppressSPF {
|
||||||
|
tags = append(tags, "Failed SPF")
|
||||||
|
}
|
||||||
|
|
||||||
|
hardReject := false
|
||||||
|
if !suppressDMARC {
|
||||||
|
switch dmarcFailPolicy {
|
||||||
|
case "reject":
|
||||||
|
tags = append(tags, "Failed DMARC")
|
||||||
|
hardReject = true
|
||||||
|
case "quarantine":
|
||||||
|
tags = append(tags, "Failed DMARC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !suppressSpam {
|
||||||
quarantine := heuristicScore >= rejectScore
|
quarantine := heuristicScore >= rejectScore
|
||||||
hardReject := false
|
|
||||||
if rspamdEnabled {
|
if rspamdEnabled {
|
||||||
if score, rAction, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpt); err == nil {
|
if score, rAction, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpt); err == nil {
|
||||||
// rspamd's own "reject" action is a considered policy decision
|
// rspamd's own "reject" action is a considered policy decision
|
||||||
@@ -487,21 +578,62 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
|||||||
// rspamd unreachable/erroring must not block mail — errors are swallowed,
|
// rspamd unreachable/erroring must not block mail — errors are swallowed,
|
||||||
// the built-in heuristic above is still the baseline gate either way.
|
// the built-in heuristic above is still the baseline gate either way.
|
||||||
}
|
}
|
||||||
if hardReject {
|
|
||||||
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "550", ErrorMessage: "Message rejected as spam"})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if quarantine {
|
if quarantine {
|
||||||
folder = "Junk"
|
tags = append(tags, "SPAM")
|
||||||
spamGated = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if hardReject {
|
||||||
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "failed", ErrorCode: "550", ErrorMessage: "Message rejected as spam"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(tags) > 0 {
|
||||||
|
folder = "Junk"
|
||||||
|
spamGated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tagged message's per-recipient copy gets its Subject prepended with why it
|
||||||
|
// was flagged (e.g. "***Failed SPF, Failed DKIM***") — both in the stored raw
|
||||||
|
// content (so any IMAP client sees it too, not just this webmail UI) and in the
|
||||||
|
// cached subject used for list views. This does invalidate that copy's own DKIM
|
||||||
|
// signature (rewritten after signing) — harmless here since spamGated is always
|
||||||
|
// true whenever tags are non-empty, and that already skips ApplyRules below
|
||||||
|
// (including its ForwardTo action), so a tagged copy is never relayed/forwarded
|
||||||
|
// anywhere; it's only ever locally stored and read via IMAP/webmail, neither of
|
||||||
|
// which re-verifies DKIM on read.
|
||||||
|
recipientContent := signedContent
|
||||||
|
recipientSubject := subject
|
||||||
|
if len(tags) > 0 {
|
||||||
|
tag := strings.Join(tags, ", ")
|
||||||
|
recipientContent = prependSubjectTag(signedContent, tag)
|
||||||
|
recipientSubject = "***" + tag + "***"
|
||||||
|
if subject != "" {
|
||||||
|
recipientSubject += " " + subject
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter rules organize legitimate mail the recipient already trusts arriving
|
// Filter rules organize legitimate mail the recipient already trusts arriving
|
||||||
// in their INBOX — a quarantined message skips them entirely and always lands
|
// in their INBOX — a quarantined message skips them entirely and always lands
|
||||||
// in Junk, rather than a rule accidentally routing spam back into view.
|
// in Junk, rather than a rule accidentally routing spam back into view.
|
||||||
if !spamGated {
|
if !spamGated {
|
||||||
|
// Persistent mailbox-level forwarding (webmail account settings) — distinct
|
||||||
|
// from and independent of a filter rule's own "forward" action below; both
|
||||||
|
// can fire on the same message if a mailbox has both configured (a real but
|
||||||
|
// accepted edge case, not engineered around).
|
||||||
|
if mbox.ForwardTo != nil && *mbox.ForwardTo != "" {
|
||||||
|
forwardTo, mailboxEmail, keepCopy := *mbox.ForwardTo, mbox.Email, mbox.ForwardKeepCopy
|
||||||
|
go func() {
|
||||||
|
res := s.backend.Relay.RelayEmailAsync(mailboxEmail, []string{forwardTo}, signedContent, []string{"to"})
|
||||||
|
if len(res) > 0 && res[0].Status != "success" {
|
||||||
|
s.backend.Logger.Error("mailbox forwarding: delivery to %s failed: %s", forwardTo, res[0].ErrorMessage)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if !keepCopy {
|
||||||
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Forwarded to " + forwardTo + ", not kept locally"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{
|
action, err := s.backend.Mailstore.ApplyRules(mbox.ID, map[string]string{
|
||||||
"from": s.mailFrom, "to": rcpt, "subject": subject,
|
"from": s.mailFrom, "to": rcpt, "subject": subject,
|
||||||
"body": bodyText, "has_attachment": hasAttachment, "recipient_type": types[i],
|
"body": bodyText, "has_attachment": hasAttachment, "recipient_type": types[i],
|
||||||
@@ -534,13 +666,16 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
|||||||
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"})
|
results = append(results, relay.Result{Recipient: rcpt, RecipientType: types[i], Status: "success", ServerResponse: "Discarded by filter rule"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if action.AutoReply {
|
||||||
|
s.sendAutoReply(mbox, action.AutoReplySubject, action.AutoReplyBody, messageID)
|
||||||
|
}
|
||||||
if action.Folder != "" {
|
if action.Folder != "" {
|
||||||
folder = action.Folder
|
folder = action.Folder
|
||||||
}
|
}
|
||||||
markRead = action.MarkRead
|
markRead = action.MarkRead
|
||||||
}
|
}
|
||||||
|
|
||||||
uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(signedContent), messageID, fromDisplay, subject)
|
uid, err := s.backend.Mailstore.StoreMessage(mbox.ID, folder, []byte(recipientContent), messageID, fromDisplay, recipientSubject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errCode, errMsg := "450", err.Error()
|
errCode, errMsg := "450", err.Error()
|
||||||
if err == mailstore.ErrQuotaExceeded {
|
if err == mailstore.ErrQuotaExceeded {
|
||||||
@@ -554,6 +689,7 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
|||||||
s.backend.Logger.Error("mark_read rule failed to set flag for message %d: %v", uid, err)
|
s.backend.Logger.Error("mark_read rule failed to set flag for message %d: %v", uid, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
s.backend.Notify.Publish(mbox.ID, folder)
|
||||||
serverResponse := "Delivered to local mailbox"
|
serverResponse := "Delivered to local mailbox"
|
||||||
if spamGated {
|
if spamGated {
|
||||||
serverResponse = "Quarantined to Junk folder"
|
serverResponse = "Quarantined to Junk folder"
|
||||||
@@ -563,6 +699,78 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendAutoReply fires a vacation-responder reply to the current message's sender,
|
||||||
|
// fire-and-forget (same reasoning as the forward action: a slow/unreachable target
|
||||||
|
// must never delay the SMTP response), after two loop/storm-prevention checks: never
|
||||||
|
// reply to a null-sender message (a bounce/DSN — replying to one is the classic
|
||||||
|
// bounce-loop bug, same rule SendBounce itself already follows), and never reply to
|
||||||
|
// the same sender more than once per rolling 24h (esrv_mailbox_autoreply_log) — two
|
||||||
|
// auto-responders emailing each other would otherwise loop forever.
|
||||||
|
func (s *Session) sendAutoReply(mbox *db.Mailbox, subject, body, inReplyTo string) {
|
||||||
|
if s.mailFrom == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if recent, err := s.backend.DB.HasRecentAutoReply(mbox.ID, s.mailFrom); err != nil || recent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if subject == "" {
|
||||||
|
subject = "Automatic reply"
|
||||||
|
}
|
||||||
|
mailboxEmail, replyTo := mbox.Email, s.mailFrom
|
||||||
|
raw := buildAutoReplyMessage(s.backend.HeloHostname, mailboxEmail, replyTo, subject, body, inReplyTo)
|
||||||
|
go func() {
|
||||||
|
res := s.backend.Relay.RelayEmailAsync(mailboxEmail, []string{replyTo}, raw, []string{"to"})
|
||||||
|
if len(res) > 0 && res[0].Status != "success" {
|
||||||
|
s.backend.Logger.Error("auto-reply to %s failed: %s", replyTo, res[0].ErrorMessage)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := s.backend.DB.RecordAutoReply(mbox.ID, replyTo); err != nil {
|
||||||
|
s.backend.Logger.Error("record auto-reply to %s: %v", replyTo, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAutoReplyMessage renders a simple vacation-responder reply — plain text/plain,
|
||||||
|
// marked Auto-Submitted (RFC 3834) so it isn't itself replied to by another
|
||||||
|
// auto-responder on the receiving end, mirroring relay.buildBounceMessage's shape.
|
||||||
|
func buildAutoReplyMessage(hostname, from, to, subject, body, inReplyTo string) string {
|
||||||
|
headers := []string{
|
||||||
|
"Message-ID: <" + toolbox.GenerateMessageID(hostname) + ">",
|
||||||
|
"Date: " + time.Now().Format(time.RFC1123Z),
|
||||||
|
"From: " + from,
|
||||||
|
"To: " + to,
|
||||||
|
"Subject: " + subject,
|
||||||
|
"Auto-Submitted: auto-replied",
|
||||||
|
`Content-Type: text/plain; charset="UTF-8"`,
|
||||||
|
"Content-Transfer-Encoding: 8bit",
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
}
|
||||||
|
if inReplyTo != "" {
|
||||||
|
headers = append(headers, "In-Reply-To: <"+inReplyTo+">", "References: <"+inReplyTo+">")
|
||||||
|
}
|
||||||
|
return strings.Join(headers, "\r\n") + "\r\n\r\n" + body + "\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// domainSendRateLimited reports whether domain has hit its own admin-configured
|
||||||
|
// outbound send-rate cap (esrv_domains.send_rate_limit_per_hour) within the last
|
||||||
|
// rolling hour. Unconfigured (nil limit) or an unrecognized/empty domain never limits.
|
||||||
|
func (s *Session) domainSendRateLimited(domain string) (bool, error) {
|
||||||
|
if domain == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
dom, err := s.backend.DB.GetDomainByName(domain)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if dom == nil || dom.SendRateLimitPerHour == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
count, err := s.backend.DB.CountRecentSendsForDomain(domain, time.Now().Add(-time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count >= *dom.SendRateLimitPerHour, nil
|
||||||
|
}
|
||||||
|
|
||||||
func containsStr(list []string, s string) bool {
|
func containsStr(list []string, s string) bool {
|
||||||
for _, v := range list {
|
for _, v := range list {
|
||||||
if v == s {
|
if v == s {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package smtpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeClamd stands in for a real clamd instance for these integration tests — reads
|
||||||
|
// the INSTREAM protocol far enough to reach end-of-stream, then replies canned.
|
||||||
|
func fakeClamd(t *testing.T, reply string) string {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { ln.Close() })
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer conn.Close()
|
||||||
|
cmd := make([]byte, len("zINSTREAM\x00"))
|
||||||
|
if _, err := io.ReadFull(conn, cmd); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
var lenBuf [4]byte
|
||||||
|
if _, err := io.ReadFull(conn, lenBuf[:]); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := binary.BigEndian.Uint32(lenBuf[:])
|
||||||
|
if n == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if _, err := io.CopyN(io.Discard, conn, int64(n)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.Write([]byte(reply + "\x00"))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ln.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVirusScanRejectsInfectedMessage confirms an infected message is hard-rejected
|
||||||
|
// at DATA time when virus_scan_enabled is on, before either relay or local delivery.
|
||||||
|
func TestVirusScanRejectsInfectedMessage(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
clamdAddr := fakeClamd(t, "stream: Eicar-Test-Signature FOUND")
|
||||||
|
backend.Cfg.Section("Mailstore").Key("virus_scan_enabled").SetValue("true")
|
||||||
|
backend.Cfg.Section("Mailstore").Key("clamd_address").SetValue(clamdAddr)
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "infected"); err == nil {
|
||||||
|
t.Fatal("expected DATA to be hard-rejected for an infected message")
|
||||||
|
}
|
||||||
|
msgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
spamMsgs, _ := backend.DB.ListMessagesInFolder(mailboxID, "Junk")
|
||||||
|
if len(msgs) != 0 || len(spamMsgs) != 0 {
|
||||||
|
t.Fatalf("expected nothing stored anywhere for a virus-rejected message, got INBOX=%d Junk=%d", len(msgs), len(spamMsgs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVirusScanAllowsCleanMessage confirms a clean message is delivered normally when
|
||||||
|
// scanning is on.
|
||||||
|
func TestVirusScanAllowsCleanMessage(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
clamdAddr := fakeClamd(t, "stream: OK")
|
||||||
|
backend.Cfg.Section("Mailstore").Key("virus_scan_enabled").SetValue("true")
|
||||||
|
backend.Cfg.Section("Mailstore").Key("clamd_address").SetValue(clamdAddr)
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "clean"); err != nil {
|
||||||
|
t.Fatalf("expected a clean message to be accepted, got: %v", err)
|
||||||
|
}
|
||||||
|
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil || len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 message stored, got %d (err=%v)", len(msgs), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVirusScanFailsOpenWhenClamdUnreachable confirms an unreachable/erroring scanner
|
||||||
|
// never blocks mail — same resilience posture CheckRspamd already has.
|
||||||
|
func TestVirusScanFailsOpenWhenClamdUnreachable(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
backend.Cfg.Section("Mailstore").Key("virus_scan_enabled").SetValue("true")
|
||||||
|
backend.Cfg.Section("Mailstore").Key("clamd_address").SetValue("127.0.0.1:1") // nothing listens here
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "hello"); err != nil {
|
||||||
|
t.Fatalf("expected delivery to proceed despite an unreachable scanner, got: %v", err)
|
||||||
|
}
|
||||||
|
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil || len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 message stored despite the scanner being unreachable, got %d (err=%v)", len(msgs), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVirusScanDisabledByDefault confirms scanning is off unless explicitly enabled —
|
||||||
|
// a message that would be flagged FOUND by a configured-but-disabled scanner still
|
||||||
|
// gets delivered, since the scan never runs at all.
|
||||||
|
func TestVirusScanDisabledByDefault(t *testing.T) {
|
||||||
|
backend, mailboxID := newTestBackendWithMailbox(t)
|
||||||
|
clamdAddr := fakeClamd(t, "stream: Eicar-Test-Signature FOUND")
|
||||||
|
backend.Cfg.Section("Mailstore").Key("clamd_address").SetValue(clamdAddr)
|
||||||
|
// virus_scan_enabled deliberately left unset (default false).
|
||||||
|
addr := startTestServer(t, backend)
|
||||||
|
|
||||||
|
if err := sendTestMessage(t, addr, "hello"); err != nil {
|
||||||
|
t.Fatalf("expected delivery with scanning disabled, got: %v", err)
|
||||||
|
}
|
||||||
|
msgs, err := backend.DB.ListMessagesInFolder(mailboxID, "INBOX")
|
||||||
|
if err != nil || len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 message stored with scanning disabled, got %d (err=%v)", len(msgs), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/backup"
|
||||||
|
"mailgoserver/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// backupDir resolves the configured [Backup] destination folder to an absolute path,
|
||||||
|
// same convention as testAttachmentsPath in settings.go.
|
||||||
|
func (a *App) backupDir() string {
|
||||||
|
dir := a.Cfg.Section("Backup").Key("dir").MustString("server_data/backups")
|
||||||
|
if !filepath.IsAbs(dir) {
|
||||||
|
dir = filepath.Join(a.Root, dir)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) backupsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scheduled, err := backup.ListScheduled(a.backupDir())
|
||||||
|
if err != nil {
|
||||||
|
a.Logger.Error("list scheduled backups: %v", err)
|
||||||
|
}
|
||||||
|
a.render(w, r, "backups.html", M{
|
||||||
|
"active": "backups",
|
||||||
|
"schedule": a.Cfg.Section("Backup").Key("schedule").String(),
|
||||||
|
"backup_dir": a.Cfg.Section("Backup").Key("dir").MustString("server_data/backups"),
|
||||||
|
"keep": a.Cfg.Section("Backup").Key("keep").MustInt(3),
|
||||||
|
"scheduled": scheduled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// backupScheduleUpdate saves the [Backup] schedule/dir/keep settings — a small
|
||||||
|
// dedicated handler (rather than routing through the generic Settings-page form) so it
|
||||||
|
// can validate the schedule value and redirect back to /backups instead of /settings.
|
||||||
|
func (a *App) backupScheduleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
schedule := r.FormValue("schedule")
|
||||||
|
if schedule != "" {
|
||||||
|
if _, ok := backup.IntervalForSchedule(schedule); !ok {
|
||||||
|
setFlash(w, "error", "Unrecognized backup schedule")
|
||||||
|
http.Redirect(w, r, Prefix+"/backups", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := strings.TrimSpace(r.FormValue("dir"))
|
||||||
|
if dir == "" {
|
||||||
|
dir = "server_data/backups"
|
||||||
|
}
|
||||||
|
keep := r.FormValue("keep")
|
||||||
|
|
||||||
|
sec := a.Cfg.Section("Backup")
|
||||||
|
sec.Key("schedule").SetValue(schedule)
|
||||||
|
sec.Key("dir").SetValue(dir)
|
||||||
|
if keep != "" {
|
||||||
|
sec.Key("keep").SetValue(keep)
|
||||||
|
}
|
||||||
|
if err := a.Cfg.SaveTo(a.ConfigPath); err != nil {
|
||||||
|
setFlash(w, "error", "Error saving backup settings: "+err.Error())
|
||||||
|
http.Redirect(w, r, Prefix+"/backups", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFlash(w, "success", "Backup schedule saved.")
|
||||||
|
http.Redirect(w, r, Prefix+"/backups", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBackupNow runs a scheduled-style backup immediately (written to the configured
|
||||||
|
// backup folder and rotated, unlike "Download whole-server backup now" which just
|
||||||
|
// streams to the browser) — useful both for an on-demand server-side backup and to
|
||||||
|
// confirm the configured folder/rotation actually works.
|
||||||
|
func (a *App) runBackupNow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dataDir := filepath.Join(a.Root, "server_data")
|
||||||
|
dbPath := config.AbsoluteSQLitePath(a.Cfg.Section("Database").Key("DATABASE_URL").String(), a.Root)
|
||||||
|
keep := a.Cfg.Section("Backup").Key("keep").MustInt(3)
|
||||||
|
|
||||||
|
path, err := backup.RunScheduled(a.DB, dataDir, dbPath, a.backupDir(), keep)
|
||||||
|
if err != nil {
|
||||||
|
a.Logger.Error("manual scheduled backup: %v", err)
|
||||||
|
setFlash(w, "error", "Backup failed: "+err.Error())
|
||||||
|
} else {
|
||||||
|
setFlash(w, "success", "Backup written: "+filepath.Base(path))
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, Prefix+"/backups", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadScheduledBackup streams one existing scheduled backup file to the browser.
|
||||||
|
// name is validated against the directory listing (not just pattern-matched) before
|
||||||
|
// opening, so a crafted name can't escape backupDir.
|
||||||
|
func (a *App) downloadScheduledBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
dir := a.backupDir()
|
||||||
|
entries, err := backup.ListScheduled(dir)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name == name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.Open(filepath.Join(dir, name))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||||
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
|
io.Copy(w, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadBackup streams a whole-server backup archive straight to the response — see
|
||||||
|
// internal/backup's doc comment for what it contains. Global-admin only (wired via
|
||||||
|
// requireGlobalAdmin at the route), same as every other server-wide (not
|
||||||
|
// per-domain) admin page.
|
||||||
|
func (a *App) downloadBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dataDir := filepath.Join(a.Root, "server_data")
|
||||||
|
dbPath := config.AbsoluteSQLitePath(a.Cfg.Section("Database").Key("DATABASE_URL").String(), a.Root)
|
||||||
|
passphrase := r.FormValue("passphrase")
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("mailgoserver-backup-%s.tar.gz", time.Now().UTC().Format("2006-01-02-150405"))
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||||
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
|
|
||||||
|
if err := backup.WriteServerConsistent(a.DB, w, dataDir, dbPath, passphrase); err != nil {
|
||||||
|
a.Logger.Error("backup download: %v", err)
|
||||||
|
// The archive may already be partially written to w by this point (headers
|
||||||
|
// are already sent) — nothing more useful to do than log it server-side; the
|
||||||
|
// browser will just see a truncated/failed download.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/backup"
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDownloadBackupStreamsARestorableArchive confirms the admin "Backups" page's
|
||||||
|
// download endpoint returns a real backup.RestoreServer-compatible archive (not just a
|
||||||
|
// 200 with an empty/broken body) — download it via a real HTTP round trip (CSRF token
|
||||||
|
// scraped from the rendered page, same as a real form submission would present it,
|
||||||
|
// mirroring the pattern in csrf_test.go) and restore it into a fresh directory.
|
||||||
|
func TestDownloadBackupStreamsARestorableArchive(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
// The test fixture's DATABASE_URL/mailstore paths don't live under Root/server_data
|
||||||
|
// (newTestApp scatters them directly under its temp dir) — real deployments always
|
||||||
|
// have real content there (main.go's default layout), so seed a stand-in file for
|
||||||
|
// this test rather than reshaping the shared fixture every other test also uses.
|
||||||
|
dataDir := filepath.Join(app.Root, "server_data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dataDir, "marker.txt"), []byte("seeded-for-test"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(SecurityHeaders(app.CSRFProtect(app.Mux())))
|
||||||
|
defer srv.Close()
|
||||||
|
cookie := loginSession(t, app)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
getReq, _ := http.NewRequest(http.MethodGet, srv.URL+Prefix+"/backups", nil)
|
||||||
|
getReq.AddCookie(cookie)
|
||||||
|
pageResp, err := client.Do(getReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(pageResp.Body)
|
||||||
|
pageResp.Body.Close()
|
||||||
|
if pageResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("GET /backups: status %d", pageResp.StatusCode)
|
||||||
|
}
|
||||||
|
m := regexp.MustCompile(`window\.__csrfToken\s*=\s*"([0-9a-f]+)"`).FindSubmatch(body)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("no CSRF token found on /backups page: %s", body)
|
||||||
|
}
|
||||||
|
token := string(m[1])
|
||||||
|
|
||||||
|
postReq, _ := http.NewRequest(http.MethodPost, srv.URL+Prefix+"/backups/download", strings.NewReader(url.Values{"passphrase": {""}}.Encode()))
|
||||||
|
postReq.AddCookie(cookie)
|
||||||
|
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
postReq.Header.Set("X-CSRF-Token", token)
|
||||||
|
dlResp, err := client.Do(postReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer dlResp.Body.Close()
|
||||||
|
if dlResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("download: status %d", dlResp.StatusCode)
|
||||||
|
}
|
||||||
|
if cd := dlResp.Header.Get("Content-Disposition"); !strings.Contains(cd, "attachment") || !strings.Contains(cd, ".tar.gz") {
|
||||||
|
t.Fatalf("Content-Disposition = %q, want an attachment .tar.gz filename", cd)
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreDir := filepath.Join(t.TempDir(), "restored")
|
||||||
|
if err := backup.RestoreServer(dlResp.Body, restoreDir, "", false); err != nil {
|
||||||
|
t.Fatalf("restoring the downloaded archive: %v", err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(filepath.Join(restoreDir, "marker.txt"))
|
||||||
|
if err != nil || string(got) != "seeded-for-test" {
|
||||||
|
t.Fatalf("restored marker.txt = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScheduledBackupRunNowAndDownload exercises the whole scheduled-backup flow
|
||||||
|
// through real HTTP requests: save a schedule/dir/keep setting, run one now, confirm it
|
||||||
|
// shows up in the on-disk list, and download it back.
|
||||||
|
func TestScheduledBackupRunNowAndDownload(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
dataDir := filepath.Join(app.Root, "server_data")
|
||||||
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dataDir, "marker.txt"), []byte("seeded"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(SecurityHeaders(app.CSRFProtect(app.Mux())))
|
||||||
|
defer srv.Close()
|
||||||
|
cookie := loginSession(t, app)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
fetchToken := func() string {
|
||||||
|
getReq, _ := http.NewRequest(http.MethodGet, srv.URL+Prefix+"/backups", nil)
|
||||||
|
getReq.AddCookie(cookie)
|
||||||
|
resp, err := client.Do(getReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
m := regexp.MustCompile(`window\.__csrfToken\s*=\s*"([0-9a-f]+)"`).FindSubmatch(body)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("no CSRF token found on /backups page: %s", body)
|
||||||
|
}
|
||||||
|
return string(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save a schedule (custom dir under the temp app root, keep 2).
|
||||||
|
backupDir := filepath.Join(app.Root, "scheduled-backups")
|
||||||
|
token := fetchToken()
|
||||||
|
form := url.Values{"schedule": {"daily"}, "dir": {backupDir}, "keep": {"2"}}
|
||||||
|
postReq, _ := http.NewRequest(http.MethodPost, srv.URL+Prefix+"/backups/schedule", strings.NewReader(form.Encode()))
|
||||||
|
postReq.AddCookie(cookie)
|
||||||
|
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
postReq.Header.Set("X-CSRF-Token", token)
|
||||||
|
resp, err := client.Do(postReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if got := app.Cfg.Section("Backup").Key("dir").String(); got != backupDir {
|
||||||
|
t.Fatalf("Backup.dir = %q, want %q", got, backupDir)
|
||||||
|
}
|
||||||
|
if got := app.Cfg.Section("Backup").Key("schedule").String(); got != "daily" {
|
||||||
|
t.Fatalf("Backup.schedule = %q, want daily", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a backup now.
|
||||||
|
token = fetchToken()
|
||||||
|
runReq, _ := http.NewRequest(http.MethodPost, srv.URL+Prefix+"/backups/run", nil)
|
||||||
|
runReq.AddCookie(cookie)
|
||||||
|
runReq.Header.Set("X-CSRF-Token", token)
|
||||||
|
runResp, err := client.Do(runReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, runResp.Body)
|
||||||
|
runResp.Body.Close()
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(backupDir)
|
||||||
|
if err != nil || len(entries) != 1 {
|
||||||
|
t.Fatalf("expected exactly one backup written to %s, got %v (err=%v)", backupDir, entries, err)
|
||||||
|
}
|
||||||
|
name := entries[0].Name()
|
||||||
|
|
||||||
|
dlReq, _ := http.NewRequest(http.MethodGet, srv.URL+Prefix+"/backups/scheduled/"+name, nil)
|
||||||
|
dlReq.AddCookie(cookie)
|
||||||
|
dlResp, err := client.Do(dlReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer dlResp.Body.Close()
|
||||||
|
if dlResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("download scheduled backup: status %d", dlResp.StatusCode)
|
||||||
|
}
|
||||||
|
restoreDir := filepath.Join(t.TempDir(), "restored")
|
||||||
|
if err := backup.RestoreServer(dlResp.Body, restoreDir, "", false); err != nil {
|
||||||
|
t.Fatalf("restoring downloaded scheduled backup: %v", err)
|
||||||
|
}
|
||||||
|
if got, err := os.ReadFile(filepath.Join(restoreDir, "marker.txt")); err != nil || string(got) != "seeded" {
|
||||||
|
t.Fatalf("restored marker.txt = %q, %v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path traversal against the scheduled-download endpoint must not escape backupDir.
|
||||||
|
evilReq, _ := http.NewRequest(http.MethodGet, srv.URL+Prefix+"/backups/scheduled/..%2f..%2fmarker.txt", nil)
|
||||||
|
evilReq.AddCookie(cookie)
|
||||||
|
evilResp, err := client.Do(evilReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
evilResp.Body.Close()
|
||||||
|
if evilResp.StatusCode == http.StatusOK {
|
||||||
|
t.Fatalf("expected the traversal attempt to be rejected, got 200")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackupsPageRequiresGlobalAdmin confirms a non-global-admin session (a
|
||||||
|
// domain-scoped admin) is denied the Backups page — it's server-wide, not attributable
|
||||||
|
// to one domain, same reasoning as the existing blacklist page.
|
||||||
|
func TestBackupsPageRequiresGlobalAdmin(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
|
||||||
|
hash, err := db.HashPassword("scoped-pass-123!")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
userID, err := app.DB.CreateScopedAdminUser("scoped-admin", hash, 0, []int64{domains[0].ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token, err := app.DB.CreateSession(userID, true, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cookie := &http.Cookie{Name: sessionCookieName, Value: token}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, Prefix+"/backups", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code == http.StatusOK {
|
||||||
|
t.Fatalf("expected a scoped (non-global) admin to be denied /backups, got 200")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
// CalDAV server (RFC 4791) — exposes a mailbox owner's calendar (events + reminders)
|
||||||
|
// for sync from Apple Calendar, Thunderbird, DAVx5, etc. Structurally mirrors
|
||||||
|
// carddav.go throughout (same author/family library, same ctx-based auth pattern via
|
||||||
|
// davMailboxFromCtx, same path-hierarchy depth convention). Reminders round-trip as a
|
||||||
|
// VALARM sub-component so a real CalDAV client fires its own native OS notification —
|
||||||
|
// this server has no push/email notification system of its own (see the Calendar
|
||||||
|
// plan's explicit scope note).
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-ical"
|
||||||
|
"github.com/emersion/go-webdav"
|
||||||
|
"github.com/emersion/go-webdav/caldav"
|
||||||
|
"github.com/teambition/rrule-go"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const caldavPrefix = "/caldav"
|
||||||
|
|
||||||
|
// icalProdID identifies this server as the VCALENDAR's producer, per RFC 5545 3.7.3 —
|
||||||
|
// required (checkComponent rejects an encode without it), arbitrary otherwise.
|
||||||
|
const icalProdID = "-//mailgoserver//Calendar//EN"
|
||||||
|
|
||||||
|
func caldavPrincipalPath(email string) string {
|
||||||
|
return caldavPrefix + "/" + url.PathEscape(email) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func caldavHomeSetPath(email string) string {
|
||||||
|
return caldavPrincipalPath(email) + "calendars/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// caldavCalendarPath is the one, fixed calendar every mailbox gets — no
|
||||||
|
// multi-calendar UI exists in this app, same single-collection shape as CardDAV's one
|
||||||
|
// address book.
|
||||||
|
func caldavCalendarPath(email string) string {
|
||||||
|
return caldavHomeSetPath(email) + "default/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func caldavObjectPath(email, uid string) string {
|
||||||
|
return caldavCalendarPath(email) + uid + ".ics"
|
||||||
|
}
|
||||||
|
|
||||||
|
// caldavUIDFromPath extracts the uid segment from a calendar-object request path (the
|
||||||
|
// last path segment, minus its ".ics" extension) — the inverse of caldavObjectPath.
|
||||||
|
func caldavUIDFromPath(p string) string {
|
||||||
|
return strings.TrimSuffix(path.Base(p), ".ics")
|
||||||
|
}
|
||||||
|
|
||||||
|
type caldavBackend struct {
|
||||||
|
DB *db.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *caldavBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return caldavPrincipalPath(mbox.Email), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *caldavBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return caldavHomeSetPath(mbox.Email), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *caldavBackend) calendar(mbox *db.Mailbox) caldav.Calendar {
|
||||||
|
return caldav.Calendar{
|
||||||
|
Path: caldavCalendarPath(mbox.Email),
|
||||||
|
Name: "Calendar",
|
||||||
|
Description: "Calendar for " + mbox.Email,
|
||||||
|
SupportedComponentSet: []string{ical.CompEvent},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *caldavBackend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []caldav.Calendar{b.calendar(mbox)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *caldavBackend) GetCalendar(ctx context.Context, p string) (*caldav.Calendar, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p != caldavCalendarPath(mbox.Email) {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("no such calendar"))
|
||||||
|
}
|
||||||
|
cal := b.calendar(mbox)
|
||||||
|
return &cal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCalendar: unsupported — every mailbox has exactly one fixed calendar, matching
|
||||||
|
// this app's own Calendar page, which has no concept of multiple calendars either (no
|
||||||
|
// DeleteCalendar exists in caldav.Backend to implement — the interface itself assumes
|
||||||
|
// a calendar, once created, persists).
|
||||||
|
func (b *caldavBackend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
|
||||||
|
return webdav.NewHTTPError(http.StatusForbidden, errors.New("creating calendars is not supported"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventToICal builds the VCALENDAR a CalDAV client sees for one stored event —
|
||||||
|
// PRODID/VERSION set once, one VEVENT child with a VALARM appended when a reminder is
|
||||||
|
// set (Props.SetDuration on TRIGGER encodes "N minutes before" as a negative
|
||||||
|
// duration, the standard RFC 5545 relative-trigger form).
|
||||||
|
func eventToICal(e db.CalendarEvent) *ical.Calendar {
|
||||||
|
cal := ical.NewCalendar()
|
||||||
|
cal.Props.SetText(ical.PropProductID, icalProdID)
|
||||||
|
cal.Props.SetText(ical.PropVersion, "2.0")
|
||||||
|
|
||||||
|
event := ical.NewEvent()
|
||||||
|
event.Props.SetText(ical.PropUID, e.UID)
|
||||||
|
event.Props.SetDateTime(ical.PropDateTimeStamp, e.UpdatedAt)
|
||||||
|
event.Props.SetText(ical.PropSummary, e.Summary)
|
||||||
|
if e.Description != "" {
|
||||||
|
event.Props.SetText(ical.PropDescription, e.Description)
|
||||||
|
}
|
||||||
|
if e.Location != "" {
|
||||||
|
event.Props.SetText(ical.PropLocation, e.Location)
|
||||||
|
}
|
||||||
|
if e.AllDay {
|
||||||
|
event.Props.SetDate(ical.PropDateTimeStart, e.StartAt)
|
||||||
|
event.Props.SetDate(ical.PropDateTimeEnd, e.EndAt)
|
||||||
|
} else {
|
||||||
|
event.Props.SetDateTime(ical.PropDateTimeStart, e.StartAt)
|
||||||
|
event.Props.SetDateTime(ical.PropDateTimeEnd, e.EndAt)
|
||||||
|
}
|
||||||
|
if e.RRule != "" {
|
||||||
|
if opt, err := rrule.StrToROption(e.RRule); err == nil {
|
||||||
|
event.Props.SetRecurrenceRule(opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Color != "" {
|
||||||
|
event.Props.SetText(ical.PropColor, e.Color)
|
||||||
|
}
|
||||||
|
if e.ReminderMinutes != nil {
|
||||||
|
alarm := ical.NewComponent(ical.CompAlarm)
|
||||||
|
alarm.Props.SetText(ical.PropAction, "DISPLAY")
|
||||||
|
alarm.Props.SetText(ical.PropDescription, "Reminder")
|
||||||
|
trigger := ical.NewProp(ical.PropTrigger)
|
||||||
|
trigger.SetDuration(-time.Duration(*e.ReminderMinutes) * time.Minute)
|
||||||
|
alarm.Props.Set(trigger)
|
||||||
|
event.Children = append(event.Children, alarm)
|
||||||
|
}
|
||||||
|
|
||||||
|
cal.Children = append(cal.Children, event.Component)
|
||||||
|
return cal
|
||||||
|
}
|
||||||
|
|
||||||
|
// icalToEvent is eventToICal's inverse — the PUT-side parse. Rejects anything
|
||||||
|
// caldav.ValidateCalendarObject wouldn't accept (multiple UIDs, a non-VEVENT
|
||||||
|
// component, etc. — the standard CalDAV single-object-resource rule) before reading
|
||||||
|
// fields off the one VEVENT.
|
||||||
|
func icalToEvent(cal *ical.Calendar) (summary, description, location string, start, end time.Time, allDay bool, rrule, color string, reminderMinutes *int, err error) {
|
||||||
|
eventType, _, err := caldav.ValidateCalendarObject(cal)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, err
|
||||||
|
}
|
||||||
|
if eventType != ical.CompEvent {
|
||||||
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, fmt.Errorf("caldav: only VEVENT is supported, got %q", eventType)
|
||||||
|
}
|
||||||
|
events := cal.Events()
|
||||||
|
if len(events) == 0 {
|
||||||
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, errors.New("caldav: no VEVENT in calendar object")
|
||||||
|
}
|
||||||
|
event := events[0]
|
||||||
|
|
||||||
|
summary, _ = event.Props.Text(ical.PropSummary)
|
||||||
|
description, _ = event.Props.Text(ical.PropDescription)
|
||||||
|
location, _ = event.Props.Text(ical.PropLocation)
|
||||||
|
color, _ = event.Props.Text(ical.PropColor)
|
||||||
|
|
||||||
|
startProp := event.Props.Get(ical.PropDateTimeStart)
|
||||||
|
if startProp == nil {
|
||||||
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, errors.New("caldav: VEVENT has no DTSTART")
|
||||||
|
}
|
||||||
|
allDay = startProp.ValueType() == ical.ValueDate
|
||||||
|
start, err = event.DateTimeStart(time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, err
|
||||||
|
}
|
||||||
|
end, err = event.DateTimeEnd(time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", time.Time{}, time.Time{}, false, "", "", nil, err
|
||||||
|
}
|
||||||
|
if end.IsZero() || !end.After(start) {
|
||||||
|
end = start.Add(time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opt, roErr := event.Props.RecurrenceRule(); roErr == nil && opt != nil {
|
||||||
|
rrule = opt.RRuleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, child := range event.Children {
|
||||||
|
if child.Name != ical.CompAlarm {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trigger := child.Props.Get(ical.PropTrigger)
|
||||||
|
if trigger == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dur, durErr := trigger.Duration(); durErr == nil {
|
||||||
|
minutes := int(-dur / time.Minute)
|
||||||
|
reminderMinutes = &minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary, description, location, start, end, allDay, rrule, color, reminderMinutes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventETag(e db.CalendarEvent) string {
|
||||||
|
return fmt.Sprintf("%d-%d", e.ID, e.UpdatedAt.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventToCalendarObject(email string, e db.CalendarEvent) caldav.CalendarObject {
|
||||||
|
return caldav.CalendarObject{
|
||||||
|
Path: caldavObjectPath(email, e.UID),
|
||||||
|
ModTime: e.UpdatedAt,
|
||||||
|
ETag: eventETag(e),
|
||||||
|
Data: eventToICal(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *caldavBackend) GetCalendarObject(ctx context.Context, p string, req *caldav.CalendarCompRequest) (*caldav.CalendarObject, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e, err := b.DB.GetEventByUID(mbox.ID, caldavUIDFromPath(p))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if e == nil {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("no such event"))
|
||||||
|
}
|
||||||
|
co := eventToCalendarObject(mbox.Email, *e)
|
||||||
|
return &co, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *caldavBackend) ListCalendarObjects(ctx context.Context, p string, req *caldav.CalendarCompRequest) ([]caldav.CalendarObject, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events, err := b.DB.ListAllEvents(mbox.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]caldav.CalendarObject, len(events))
|
||||||
|
for i, e := range events {
|
||||||
|
out[i] = eventToCalendarObject(mbox.Email, e)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryCalendarObjects: like carddav's QueryAddressObjects, this doesn't hand-roll
|
||||||
|
// every possible CalDAV filter — only the time-range case (by far the common one, used
|
||||||
|
// by every client's initial/incremental sync) narrows the query; anything else falls
|
||||||
|
// back to every event. A client that issued a time-range REPORT still expands
|
||||||
|
// recurring-event occurrences itself per RFC 4791, so returning the whole matching
|
||||||
|
// event resource (not per-occurrence data) is correct either way.
|
||||||
|
func (b *caldavBackend) QueryCalendarObjects(ctx context.Context, p string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
start, end := query.CompFilter.Start, query.CompFilter.End
|
||||||
|
var events []db.CalendarEvent
|
||||||
|
if !start.IsZero() && !end.IsZero() {
|
||||||
|
events, err = b.DB.ListEventsInRange(mbox.ID, start, end)
|
||||||
|
} else {
|
||||||
|
events, err = b.DB.ListAllEvents(mbox.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]caldav.CalendarObject, len(events))
|
||||||
|
for i, e := range events {
|
||||||
|
out[i] = eventToCalendarObject(mbox.Email, e)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *caldavBackend) PutCalendarObject(ctx context.Context, p string, cal *ical.Calendar, opts *caldav.PutCalendarObjectOptions) (*caldav.CalendarObject, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
summary, description, location, start, end, allDay, rrule, color, reminderMinutes, err := icalToEvent(cal)
|
||||||
|
if err != nil {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusBadRequest, err)
|
||||||
|
}
|
||||||
|
uid := caldavUIDFromPath(p)
|
||||||
|
|
||||||
|
existing, err := b.DB.GetEventByUID(mbox.ID, uid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var id int64
|
||||||
|
if existing == nil {
|
||||||
|
if id, err = b.DB.CreateEventWithUID(mbox.ID, uid, summary, description, location, start, end, allDay, rrule, color); err != nil {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusConflict, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
id = existing.ID
|
||||||
|
if err := b.DB.UpdateEvent(mbox.ID, id, summary, description, location, start, end, allDay, rrule, color); err != nil {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusConflict, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := b.DB.SetEventReminder(id, reminderMinutes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := b.DB.GetEventByUID(mbox.ID, uid)
|
||||||
|
if err != nil || stored == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
co := eventToCalendarObject(mbox.Email, *stored)
|
||||||
|
return &co, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *caldavBackend) DeleteCalendarObject(ctx context.Context, p string) error {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.DB.DeleteEventByUID(mbox.ID, caldavUIDFromPath(p))
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalDAVPutGetDeleteRoundTrip(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
createTestMailboxWithAppPassword(t, app, "caluser@example.com", domains[0].ID)
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
objURL := srv.URL + "/caldav/caluser@example.com/calendars/default/test-uid-1.ics"
|
||||||
|
icsBody := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n" +
|
||||||
|
"BEGIN:VEVENT\r\nUID:test-uid-1\r\nDTSTAMP:20260310T120000Z\r\n" +
|
||||||
|
"SUMMARY:Team Standup\r\nLOCATION:Room 1\r\n" +
|
||||||
|
"DTSTART:20260310T140000Z\r\nDTEND:20260310T150000Z\r\n" +
|
||||||
|
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n" +
|
||||||
|
"END:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||||
|
|
||||||
|
put := func() *http.Response {
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, objURL, strings.NewReader(icsBody))
|
||||||
|
req.Header.Set("Content-Type", "text/calendar")
|
||||||
|
req.SetBasicAuth("caluser@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resp := put()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("PUT: status=%d body=%s, want 201", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The webmail Calendar page reads the same underlying row — a PUT over CalDAV
|
||||||
|
// must show up there too, reminder included.
|
||||||
|
mailboxID := mustMailboxID(t, app, "caluser@example.com")
|
||||||
|
events, err := app.DB.ListAllEvents(mailboxID)
|
||||||
|
if err != nil || len(events) != 1 || events[0].Summary != "Team Standup" || events[0].Location != "Room 1" {
|
||||||
|
t.Fatalf("expected 1 event from CalDAV PUT visible via ListAllEvents, got %+v (err=%v)", events, err)
|
||||||
|
}
|
||||||
|
if events[0].ReminderMinutes == nil || *events[0].ReminderMinutes != 15 {
|
||||||
|
t.Fatalf("expected reminder parsed from VALARM TRIGGER:-PT15M as 15 minutes, got %+v", events[0].ReminderMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
get := func() *http.Response {
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, objURL, nil)
|
||||||
|
req.SetBasicAuth("caluser@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resp = get()
|
||||||
|
body, _ = io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("GET: status=%d body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"SUMMARY:Team Standup", "BEGIN:VALARM", "TRIGGER"} {
|
||||||
|
if !strings.Contains(string(body), want) {
|
||||||
|
t.Errorf("GET body missing %q: %s", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
del := func() *http.Response {
|
||||||
|
req, _ := http.NewRequest(http.MethodDelete, objURL, nil)
|
||||||
|
req.SetBasicAuth("caluser@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resp = del()
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("DELETE: status=%d, want 204/200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
resp = get()
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("GET after DELETE: status=%d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
remaining, err := app.DB.ListAllEvents(mailboxID)
|
||||||
|
if err != nil || len(remaining) != 0 {
|
||||||
|
t.Fatalf("expected no events left, got %+v (err=%v)", remaining, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalDAVRecurringEventRoundTrip(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
createTestMailboxWithAppPassword(t, app, "recur@example.com", domains[0].ID)
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
objURL := srv.URL + "/caldav/recur@example.com/calendars/default/weekly-1.ics"
|
||||||
|
icsBody := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n" +
|
||||||
|
"BEGIN:VEVENT\r\nUID:weekly-1\r\nDTSTAMP:20260301T120000Z\r\n" +
|
||||||
|
"SUMMARY:Weekly Sync\r\nDTSTART:20260303T090000Z\r\nDTEND:20260303T100000Z\r\n" +
|
||||||
|
"RRULE:FREQ=WEEKLY\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, objURL, strings.NewReader(icsBody))
|
||||||
|
req.Header.Set("Content-Type", "text/calendar")
|
||||||
|
req.SetBasicAuth("recur@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("PUT: status=%d body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
mailboxID := mustMailboxID(t, app, "recur@example.com")
|
||||||
|
events, err := app.DB.ListAllEvents(mailboxID)
|
||||||
|
if err != nil || len(events) != 1 || events[0].RRule != "FREQ=WEEKLY" {
|
||||||
|
t.Fatalf("expected 1 event with RRule FREQ=WEEKLY, got %+v (err=%v)", events, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET round-trips the RRULE back out too.
|
||||||
|
getReq, _ := http.NewRequest(http.MethodGet, objURL, nil)
|
||||||
|
getReq.SetBasicAuth("recur@example.com", carddavTestAppPassword)
|
||||||
|
getResp, err := http.DefaultClient.Do(getReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
getBody, _ := io.ReadAll(getResp.Body)
|
||||||
|
getResp.Body.Close()
|
||||||
|
if !strings.Contains(string(getBody), "RRULE:FREQ=WEEKLY") {
|
||||||
|
t.Fatalf("expected RRULE round-tripped in GET response: %s", getBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalDAVListsWebmailCreatedEvent(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithAppPassword(t, app, "calseen@example.com", domains[0].ID)
|
||||||
|
start := time.Date(2026, 3, 10, 14, 0, 0, 0, time.UTC)
|
||||||
|
if _, err := app.DB.CreateEvent(mailboxID, "Webmail Event", "made in webmail", "", start, start.Add(time.Hour), false, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
events, err := app.DB.ListAllEvents(mailboxID)
|
||||||
|
if err != nil || len(events) != 1 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
uid := events[0].UID
|
||||||
|
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/caldav/calseen@example.com/calendars/default/"+uid+".ics", nil)
|
||||||
|
req.SetBasicAuth("calseen@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Webmail Event") {
|
||||||
|
t.Fatalf("expected the webmail-created event visible over CalDAV: status=%d body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalDAVScopedToOwnMailbox mirrors TestCardDAVScopedToOwnMailbox: one mailbox's
|
||||||
|
// app-password credentials can never read another mailbox's events, even by guessing
|
||||||
|
// a path with the right uid.
|
||||||
|
func TestCalDAVScopedToOwnMailbox(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
victimID := createTestMailboxWithAppPassword(t, app, "calvictim@example.com", domains[0].ID)
|
||||||
|
createTestMailboxWithAppPassword(t, app, "calattacker@example.com", domains[0].ID)
|
||||||
|
start := time.Date(2026, 3, 10, 14, 0, 0, 0, time.UTC)
|
||||||
|
if _, err := app.DB.CreateEvent(victimID, "Secret Meeting", "", "", start, start.Add(time.Hour), false, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
events, _ := app.DB.ListAllEvents(victimID)
|
||||||
|
uid := events[0].UID
|
||||||
|
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/caldav/calvictim@example.com/calendars/default/"+uid+".ics", nil)
|
||||||
|
req.SetBasicAuth("calattacker@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 (attacker's own ctx mailbox has no such event), got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalDAVPropfindDiscovery(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
createTestMailboxWithAppPassword(t, app, "discover@example.com", domains[0].ID)
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("PROPFIND", srv.URL+"/caldav/discover@example.com/calendars/default/", nil)
|
||||||
|
req.Header.Set("Depth", "0")
|
||||||
|
req.SetBasicAuth("discover@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusMultiStatus {
|
||||||
|
t.Fatalf("PROPFIND: status=%d body=%s, want 207", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "calendar") {
|
||||||
|
t.Errorf("expected calendar resourcetype in PROPFIND response: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCalDAVColorRoundTrip confirms a COLOR property round-trips through PUT/GET —
|
||||||
|
// both directions matter: a color set via the webmail UI must show up for a CalDAV
|
||||||
|
// client, and a color set by a CalDAV client (RFC 7986) must show up in the webmail
|
||||||
|
// Calendar page.
|
||||||
|
func TestCalDAVColorRoundTrip(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
createTestMailboxWithAppPassword(t, app, "calcolor@example.com", domains[0].ID)
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
objURL := srv.URL + "/caldav/calcolor@example.com/calendars/default/color-1.ics"
|
||||||
|
icsBody := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\n" +
|
||||||
|
"BEGIN:VEVENT\r\nUID:color-1\r\nDTSTAMP:20260301T120000Z\r\n" +
|
||||||
|
"SUMMARY:Colorful Event\r\nCOLOR:#e83e8c\r\n" +
|
||||||
|
"DTSTART:20260310T140000Z\r\nDTEND:20260310T150000Z\r\n" +
|
||||||
|
"END:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, objURL, strings.NewReader(icsBody))
|
||||||
|
req.Header.Set("Content-Type", "text/calendar")
|
||||||
|
req.SetBasicAuth("calcolor@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("PUT: status=%d body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
mailboxID := mustMailboxID(t, app, "calcolor@example.com")
|
||||||
|
events, err := app.DB.ListAllEvents(mailboxID)
|
||||||
|
if err != nil || len(events) != 1 || events[0].Color != "#e83e8c" {
|
||||||
|
t.Fatalf("expected color stored from CalDAV PUT, got %+v (err=%v)", events, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
getReq, _ := http.NewRequest(http.MethodGet, objURL, nil)
|
||||||
|
getReq.SetBasicAuth("calcolor@example.com", carddavTestAppPassword)
|
||||||
|
getResp, err := http.DefaultClient.Do(getReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
getBody, _ := io.ReadAll(getResp.Body)
|
||||||
|
getResp.Body.Close()
|
||||||
|
if !strings.Contains(string(getBody), "COLOR:#e83e8c") {
|
||||||
|
t.Fatalf("expected COLOR round-tripped in GET response: %s", getBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/teambition/rrule-go"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// expandOccurrences returns every occurrence of e falling within [rangeStart,
|
||||||
|
// rangeEnd) — a single-element slice (or none, if outside the range) for a
|
||||||
|
// non-recurring event, or every matching occurrence for a recurring one (rrule-go's
|
||||||
|
// own expansion, seeded with the event's own start time as DTSTART since e.RRule only
|
||||||
|
// ever stores the bare "FREQ=..." value, not a full RRULE property with its own
|
||||||
|
// DTSTART). Display-only: an occurrence's time is never itself stored — editing or
|
||||||
|
// deleting always acts on the whole series (see caldav.go/webmail_calendar.go; a
|
||||||
|
// single-occurrence RFC 5545 RECURRENCE-ID override is out of scope for this pass).
|
||||||
|
func expandOccurrences(e db.CalendarEvent, rangeStart, rangeEnd time.Time) []time.Time {
|
||||||
|
if e.RRule == "" {
|
||||||
|
if e.StartAt.Before(rangeEnd) && e.EndAt.After(rangeStart) {
|
||||||
|
return []time.Time{e.StartAt}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
opt, err := rrule.StrToROption(e.RRule)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
opt.Dtstart = e.StartAt
|
||||||
|
rule, err := rrule.NewRRule(*opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Between's window needs to start far enough back to catch an occurrence whose
|
||||||
|
// start falls before rangeStart but whose duration still overlaps it (a
|
||||||
|
// multi-hour recurring event straddling midnight) — shift the query window back
|
||||||
|
// by the event's own duration rather than assuming same-day occurrences.
|
||||||
|
duration := e.EndAt.Sub(e.StartAt)
|
||||||
|
return rule.Between(rangeStart.Add(-duration), rangeEnd, true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExpandOccurrencesNonRecurring(t *testing.T) {
|
||||||
|
start := time.Date(2026, 3, 10, 14, 0, 0, 0, time.UTC)
|
||||||
|
e := db.CalendarEvent{StartAt: start, EndAt: start.Add(time.Hour)}
|
||||||
|
|
||||||
|
rangeStart := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
rangeEnd := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
got := expandOccurrences(e, rangeStart, rangeEnd)
|
||||||
|
if len(got) != 1 || !got[0].Equal(start) {
|
||||||
|
t.Fatalf("expected 1 occurrence at %v, got %v", start, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside the queried month entirely.
|
||||||
|
outStart := time.Date(2026, 5, 10, 14, 0, 0, 0, time.UTC)
|
||||||
|
e2 := db.CalendarEvent{StartAt: outStart, EndAt: outStart.Add(time.Hour)}
|
||||||
|
if got := expandOccurrences(e2, rangeStart, rangeEnd); len(got) != 0 {
|
||||||
|
t.Fatalf("expected no occurrences outside the range, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandOccurrencesWeeklyRecurring(t *testing.T) {
|
||||||
|
start := time.Date(2026, 3, 3, 9, 0, 0, 0, time.UTC) // a Tuesday
|
||||||
|
e := db.CalendarEvent{StartAt: start, EndAt: start.Add(time.Hour), RRule: "FREQ=WEEKLY"}
|
||||||
|
|
||||||
|
rangeStart := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
rangeEnd := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
got := expandOccurrences(e, rangeStart, rangeEnd)
|
||||||
|
// March 2026: Tuesdays on the 3rd, 10th, 17th, 24th, 31st.
|
||||||
|
if len(got) != 5 {
|
||||||
|
t.Fatalf("expected 5 weekly occurrences in March, got %d: %v", len(got), got)
|
||||||
|
}
|
||||||
|
for i, want := range []int{3, 10, 17, 24, 31} {
|
||||||
|
if got[i].Day() != want {
|
||||||
|
t.Errorf("occurrence %d: day = %d, want %d", i, got[i].Day(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandOccurrencesMonthlyRecurring(t *testing.T) {
|
||||||
|
start := time.Date(2026, 1, 15, 9, 0, 0, 0, time.UTC)
|
||||||
|
e := db.CalendarEvent{StartAt: start, EndAt: start.Add(time.Hour), RRule: "FREQ=MONTHLY"}
|
||||||
|
|
||||||
|
rangeStart := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
rangeEnd := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
got := expandOccurrences(e, rangeStart, rangeEnd)
|
||||||
|
if len(got) != 1 || got[0].Day() != 15 || got[0].Month() != time.March {
|
||||||
|
t.Fatalf("expected exactly the March 15th occurrence, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
// CardDAV server (RFC 6352) — exposes a mailbox owner's existing webmail Contacts
|
||||||
|
// (esrv_mailbox_contacts) for sync from Apple Contacts, Thunderbird, DAVx5, etc.
|
||||||
|
// Reuses the exact same contact rows/CRUD as the webmail Contacts page
|
||||||
|
// (webmail_contacts.go) — a contact added either way shows up in both.
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/emersion/go-vcard"
|
||||||
|
"github.com/emersion/go-webdav"
|
||||||
|
"github.com/emersion/go-webdav/carddav"
|
||||||
|
|
||||||
|
"mailgoserver/internal/abuseguard"
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const carddavPrefix = "/carddav"
|
||||||
|
|
||||||
|
func carddavPrincipalPath(email string) string {
|
||||||
|
return carddavPrefix + "/" + url.PathEscape(email) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func carddavHomeSetPath(email string) string {
|
||||||
|
return carddavPrincipalPath(email) + "addressbooks/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// carddavAddressBookPath is the one, fixed address book every mailbox gets — no
|
||||||
|
// multi-addressbook UI exists in this app, so there's nothing for a second one to
|
||||||
|
// represent.
|
||||||
|
func carddavAddressBookPath(email string) string {
|
||||||
|
return carddavHomeSetPath(email) + "default/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func carddavObjectPath(email, uid string) string {
|
||||||
|
return carddavAddressBookPath(email) + uid + ".vcf"
|
||||||
|
}
|
||||||
|
|
||||||
|
// carddavUIDFromPath extracts the uid segment from an address-object request path
|
||||||
|
// (the last path segment, minus its ".vcf" extension) — the inverse of
|
||||||
|
// carddavObjectPath, used for both client-supplied PUT paths and our own GET/DELETE.
|
||||||
|
func carddavUIDFromPath(p string) string {
|
||||||
|
return strings.TrimSuffix(path.Base(p), ".vcf")
|
||||||
|
}
|
||||||
|
|
||||||
|
// carddavBackend implements carddav.Backend against esrv_mailbox_contacts. Every
|
||||||
|
// method resolves the authenticated mailbox from ctx (set by DAVBasicAuth below) —
|
||||||
|
// never from the request path — so a forged path segment can never read or modify
|
||||||
|
// another mailbox's contacts, same discipline as every other webmail handler in this
|
||||||
|
// codebase (mailboxFromContext, not a path parameter, is the source of truth).
|
||||||
|
type carddavBackend struct {
|
||||||
|
DB *db.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// davMailboxFromCtx resolves the authenticated mailbox set by DAVBasicAuth — shared by
|
||||||
|
// both carddavBackend and caldavBackend (caldav.go), since both protocols use the
|
||||||
|
// exact same auth path (DAVBasicAuth) and context key (ctxMailboxKey).
|
||||||
|
func davMailboxFromCtx(ctx context.Context) (*db.Mailbox, error) {
|
||||||
|
mbox, _ := ctx.Value(ctxMailboxKey).(*db.Mailbox)
|
||||||
|
if mbox == nil {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusUnauthorized, errors.New("no authenticated mailbox"))
|
||||||
|
}
|
||||||
|
return mbox, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *carddavBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return carddavPrincipalPath(mbox.Email), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *carddavBackend) AddressBookHomeSetPath(ctx context.Context) (string, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return carddavHomeSetPath(mbox.Email), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *carddavBackend) addressBook(mbox *db.Mailbox) carddav.AddressBook {
|
||||||
|
return carddav.AddressBook{
|
||||||
|
Path: carddavAddressBookPath(mbox.Email),
|
||||||
|
Name: "Contacts",
|
||||||
|
Description: "Contacts for " + mbox.Email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *carddavBackend) ListAddressBooks(ctx context.Context) ([]carddav.AddressBook, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []carddav.AddressBook{b.addressBook(mbox)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *carddavBackend) GetAddressBook(ctx context.Context, p string) (*carddav.AddressBook, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p != carddavAddressBookPath(mbox.Email) {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("no such address book"))
|
||||||
|
}
|
||||||
|
ab := b.addressBook(mbox)
|
||||||
|
return &ab, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAddressBook/DeleteAddressBook: unsupported — every mailbox has exactly one
|
||||||
|
// fixed address book (its Contacts), matching this app's own Contacts page, which has
|
||||||
|
// no concept of multiple address books either.
|
||||||
|
func (b *carddavBackend) CreateAddressBook(ctx context.Context, addressBook *carddav.AddressBook) error {
|
||||||
|
return webdav.NewHTTPError(http.StatusForbidden, errors.New("creating address books is not supported"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *carddavBackend) DeleteAddressBook(ctx context.Context, p string) error {
|
||||||
|
return webdav.NewHTTPError(http.StatusForbidden, errors.New("deleting the address book is not supported"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// contactToCard maps a stored contact to the vCard the client sees. FN falls back to
|
||||||
|
// the email when no display name was ever set, since Encode refuses a card with an
|
||||||
|
// empty FN in practice (most clients treat a blank display name as broken).
|
||||||
|
func contactToCard(c db.MailboxContact) vcard.Card {
|
||||||
|
card := make(vcard.Card)
|
||||||
|
fn := c.Name
|
||||||
|
if fn == "" {
|
||||||
|
fn = c.Email
|
||||||
|
}
|
||||||
|
card.SetValue(vcard.FieldFormattedName, fn)
|
||||||
|
if c.GivenName != "" || c.FamilyName != "" {
|
||||||
|
card.AddName(&vcard.Name{GivenName: c.GivenName, FamilyName: c.FamilyName})
|
||||||
|
}
|
||||||
|
if c.Email != "" {
|
||||||
|
card.AddValue(vcard.FieldEmail, c.Email)
|
||||||
|
}
|
||||||
|
if c.Phone != "" {
|
||||||
|
card.AddValue(vcard.FieldTelephone, c.Phone)
|
||||||
|
}
|
||||||
|
if c.Org != "" {
|
||||||
|
card.AddValue(vcard.FieldOrganization, c.Org)
|
||||||
|
}
|
||||||
|
card.SetValue(vcard.FieldUID, c.UID)
|
||||||
|
if !c.UpdatedAt.IsZero() {
|
||||||
|
card.SetRevision(c.UpdatedAt)
|
||||||
|
}
|
||||||
|
vcard.ToV4(card)
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactETag(c db.MailboxContact) string {
|
||||||
|
return fmt.Sprintf("%d-%d", c.ID, c.UpdatedAt.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func contactToAddressObject(email string, c db.MailboxContact) carddav.AddressObject {
|
||||||
|
return carddav.AddressObject{
|
||||||
|
Path: carddavObjectPath(email, c.UID),
|
||||||
|
ModTime: c.UpdatedAt,
|
||||||
|
ETag: contactETag(c),
|
||||||
|
Card: contactToCard(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *carddavBackend) GetAddressObject(ctx context.Context, p string, req *carddav.AddressDataRequest) (*carddav.AddressObject, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c, err := b.DB.GetContactByUID(mbox.ID, carddavUIDFromPath(p))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c == nil {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusNotFound, errors.New("no such contact"))
|
||||||
|
}
|
||||||
|
ao := contactToAddressObject(mbox.Email, *c)
|
||||||
|
return &ao, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *carddavBackend) ListAddressObjects(ctx context.Context, p string, req *carddav.AddressDataRequest) ([]carddav.AddressObject, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
contacts, err := b.DB.ListContacts(mbox.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]carddav.AddressObject, len(contacts))
|
||||||
|
for i, c := range contacts {
|
||||||
|
out[i] = contactToAddressObject(mbox.Email, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryAddressObjects: this mailbox's contact list is realistically small (a personal
|
||||||
|
// address book, not a shared directory), so server-side filtering isn't worth the
|
||||||
|
// complexity of translating carddav.AddressBookQuery's PropFilters — every client that
|
||||||
|
// issues addressbook-query already filters again locally against whatever it gets
|
||||||
|
// back, per RFC 6352. Add real filtering if a mailbox's contact count ever makes that
|
||||||
|
// matter in practice.
|
||||||
|
func (b *carddavBackend) QueryAddressObjects(ctx context.Context, p string, query *carddav.AddressBookQuery) ([]carddav.AddressObject, error) {
|
||||||
|
return b.ListAddressObjects(ctx, p, &query.DataRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *carddavBackend) PutAddressObject(ctx context.Context, p string, card vcard.Card, opts *carddav.PutAddressObjectOptions) (*carddav.AddressObject, error) {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uid := carddavUIDFromPath(p)
|
||||||
|
email := card.PreferredValue(vcard.FieldEmail)
|
||||||
|
if email == "" {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusBadRequest, errors.New("vCard has no EMAIL"))
|
||||||
|
}
|
||||||
|
name := card.PreferredValue(vcard.FieldFormattedName)
|
||||||
|
phone := card.PreferredValue(vcard.FieldTelephone)
|
||||||
|
org := card.PreferredValue(vcard.FieldOrganization)
|
||||||
|
var givenName, familyName string
|
||||||
|
if n := card.Name(); n != nil {
|
||||||
|
givenName, familyName = n.GivenName, n.FamilyName
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := b.DB.GetContactByUID(mbox.ID, uid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
if _, err := b.DB.CreateContactWithUID(mbox.ID, uid, email, name, phone, givenName, familyName, org); err != nil {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusConflict, err)
|
||||||
|
}
|
||||||
|
} else if err := b.DB.UpdateContactCard(mbox.ID, existing.ID, email, name, phone, givenName, familyName, org); err != nil {
|
||||||
|
return nil, webdav.NewHTTPError(http.StatusConflict, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := b.DB.GetContactByUID(mbox.ID, uid)
|
||||||
|
if err != nil || stored == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ao := contactToAddressObject(mbox.Email, *stored)
|
||||||
|
return &ao, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *carddavBackend) DeleteAddressObject(ctx context.Context, p string) error {
|
||||||
|
mbox, err := davMailboxFromCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return b.DB.DeleteContactByUID(mbox.ID, carddavUIDFromPath(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DAVBasicAuth authenticates a CardDAV or CalDAV request (mostly protocol-agnostic —
|
||||||
|
// shared by both mounts in webui.go) via HTTP Basic Auth against the same app-password
|
||||||
|
// credential IMAP/SMTP already use (db.VerifyMailboxAppPassword) — DAV clients don't
|
||||||
|
// carry a browser session cookie, so this is a separate auth path from
|
||||||
|
// requireMailboxAuth, not a variant of it. Failed/missing auth gets the same
|
||||||
|
// admin-visible logging and IP auto-blacklist protection SMTP/IMAP auth failures
|
||||||
|
// already get (LogAuthAttempt + abuseguard), rather than a one-off parallel mechanism.
|
||||||
|
//
|
||||||
|
// protocol ("caldav" or "carddav") additionally gates the request on that protocol's
|
||||||
|
// domain-wide admin switch and this mailbox's own opt-in — both off by default (see
|
||||||
|
// schema.go's caldav_enabled/carddav_enabled comments) — with 403, not 401, once
|
||||||
|
// credentials check out but the feature is simply turned off.
|
||||||
|
func (a *App) DAVBasicAuth(protocol string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fail := func() {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="Mail"`) // shared by both CardDAV and CalDAV (caldav.go)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
email, password, ok := r.BasicAuth()
|
||||||
|
peerIP := a.requestIP(r)
|
||||||
|
if !ok || email == "" || password == "" {
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mbox, err := a.DB.VerifyMailboxAppPassword(email, password)
|
||||||
|
if err != nil || mbox == nil {
|
||||||
|
if logErr := a.DB.LogAuthAttempt("dav_login", email, peerIP, false, "invalid credentials"); logErr != nil {
|
||||||
|
a.Logger.Error("log dav auth failure: %v", logErr)
|
||||||
|
}
|
||||||
|
abuseguard.RecordFailureAndMaybeBlacklist(a.DB, a.Cfg, a.Logger, peerIP)
|
||||||
|
fail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dom, err := a.DB.GetDomainByID(mbox.DomainID)
|
||||||
|
if err != nil || dom == nil {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainOn, mailboxOn := dom.CalDAVEnabled, mbox.CalDAVEnabled
|
||||||
|
if protocol == "carddav" {
|
||||||
|
domainOn, mailboxOn = dom.CardDAVEnabled, mbox.CardDAVEnabled
|
||||||
|
}
|
||||||
|
if !domainOn || !mailboxOn {
|
||||||
|
http.Error(w, protocol+" is not enabled for this mailbox", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if logErr := a.DB.LogAuthAttempt("dav_login", email, peerIP, true, ""); logErr != nil {
|
||||||
|
a.Logger.Error("log dav auth success: %v", logErr)
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), ctxMailboxKey, mbox)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const carddavTestAppPassword = "carddav-test-app-password-1!"
|
||||||
|
|
||||||
|
// createTestMailboxWithAppPassword creates a mailbox plus an active app password for
|
||||||
|
// it — CardDAV/CalDAV auth (DAVBasicAuth) goes through db.VerifyMailboxAppPassword, the
|
||||||
|
// same credential IMAP/SMTP use, never the portal session password. Also opts both the
|
||||||
|
// domain and the mailbox into both protocols (both master-off by default — see
|
||||||
|
// schema.go's caldav_enabled/carddav_enabled comments) so every existing DAV test
|
||||||
|
// keeps exercising the actual PUT/GET/PROPFIND behavior rather than the separate
|
||||||
|
// enablement gate, which has its own dedicated tests.
|
||||||
|
func createTestMailboxWithAppPassword(t *testing.T, app *App, email string, domainID int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, email, domainID, "portal-password-unused-1!")
|
||||||
|
hash, err := db.HashPassword(carddavTestAppPassword)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := app.DB.CreateAppPassword(mailboxID, "carddav test", hash, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.DB.SetDomainCalDAVEnabled(domainID, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.DB.SetDomainCardDAVEnabled(domainID, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.DB.SetMailboxDAVEnabled(mailboxID, true, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return mailboxID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardDAVBasicAuthRequiresAppPassword(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
createTestMailboxWithAppPassword(t, app, "auther@example.com", domains[0].ID)
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// No credentials at all.
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/carddav/auther@example.com/addressbooks/default/", nil)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("no auth: status=%d, want 401", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp.Header.Get("WWW-Authenticate") == "" {
|
||||||
|
t.Error("expected WWW-Authenticate header on 401 so clients know to prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong password — must be logged and counted toward abuse-blacklist scoping, same
|
||||||
|
// as a failed SMTP/IMAP auth attempt.
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, srv.URL+"/carddav/auther@example.com/addressbooks/default/", nil)
|
||||||
|
req.SetBasicAuth("auther@example.com", "wrong-password")
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("wrong password: status=%d, want 401", resp.StatusCode)
|
||||||
|
}
|
||||||
|
logs, err := app.DB.ListRecentAuthLogs(50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, l := range logs {
|
||||||
|
if l.AuthType == "dav_login" && l.Identifier == "auther@example.com" && !l.Success {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected a failed dav_login auth attempt logged")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct credentials succeed — PROPFIND (not a bare GET, which WebDAV only
|
||||||
|
// defines for an individual resource, not a collection) on the address book
|
||||||
|
// collection itself, matching what a real client does to discover it.
|
||||||
|
req, _ = http.NewRequest("PROPFIND", srv.URL+"/carddav/auther@example.com/addressbooks/default/", nil)
|
||||||
|
req.Header.Set("Depth", "0")
|
||||||
|
req.SetBasicAuth("auther@example.com", carddavTestAppPassword)
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusMultiStatus {
|
||||||
|
t.Fatalf("correct auth PROPFIND: status=%d body=%s, want 207", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardDAVPutGetDeleteRoundTrip(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
createTestMailboxWithAppPassword(t, app, "carduser@example.com", domains[0].ID)
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
objURL := srv.URL + "/carddav/carduser@example.com/addressbooks/default/test-uid-1.vcf"
|
||||||
|
vcardBody := "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane Doe\r\nEMAIL:jane@example.com\r\nTEL:555-1234\r\nORG:Acme\r\nEND:VCARD\r\n"
|
||||||
|
|
||||||
|
put := func() *http.Response {
|
||||||
|
req, _ := http.NewRequest(http.MethodPut, objURL, strings.NewReader(vcardBody))
|
||||||
|
req.Header.Set("Content-Type", "text/vcard")
|
||||||
|
req.SetBasicAuth("carduser@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resp := put()
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("PUT: status=%d, want 201", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
get := func() *http.Response {
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, objURL, nil)
|
||||||
|
req.SetBasicAuth("carduser@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resp = get()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("GET: status=%d body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "Jane Doe") || !strings.Contains(string(body), "jane@example.com") {
|
||||||
|
t.Fatalf("GET body missing expected fields: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The webmail Contacts page reads the same underlying row — a PUT over CardDAV
|
||||||
|
// must show up there too.
|
||||||
|
contacts, err := app.DB.ListContacts(mustMailboxID(t, app, "carduser@example.com"))
|
||||||
|
if err != nil || len(contacts) != 1 || contacts[0].Email != "jane@example.com" || contacts[0].Org != "Acme" {
|
||||||
|
t.Fatalf("expected 1 contact from CardDAV PUT visible via ListContacts, got %+v (err=%v)", contacts, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
del := func() *http.Response {
|
||||||
|
req, _ := http.NewRequest(http.MethodDelete, objURL, nil)
|
||||||
|
req.SetBasicAuth("carduser@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
resp = del()
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("DELETE: status=%d, want 204/200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = get()
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("GET after DELETE: status=%d, want 404", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardDAVListsWebmailCreatedContact(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
mailboxID := createTestMailboxWithAppPassword(t, app, "seen@example.com", domains[0].ID)
|
||||||
|
if _, err := app.DB.CreateContact(mailboxID, "friend@example.com", "Friend Name", "555-0000"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contacts, err := app.DB.ListContacts(mailboxID)
|
||||||
|
if err != nil || len(contacts) != 1 {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
uid := contacts[0].UID
|
||||||
|
if uid == "" {
|
||||||
|
t.Fatal("expected a generated uid on contact created via the webmail Contacts CRUD")
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/carddav/seen@example.com/addressbooks/default/"+uid+".vcf", nil)
|
||||||
|
req.SetBasicAuth("seen@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Friend Name") {
|
||||||
|
t.Fatalf("expected the webmail-created contact visible over CardDAV: status=%d body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCardDAVScopedToOwnMailbox confirms one mailbox's app-password credentials can
|
||||||
|
// never read another mailbox's contacts, even by guessing a path with the right uid —
|
||||||
|
// the path segment is never trusted; the authenticated mailbox from Basic Auth is.
|
||||||
|
func TestCardDAVScopedToOwnMailbox(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
victimID := createTestMailboxWithAppPassword(t, app, "victim@example.com", domains[0].ID)
|
||||||
|
createTestMailboxWithAppPassword(t, app, "attacker@example.com", domains[0].ID)
|
||||||
|
if _, err := app.DB.CreateContact(victimID, "secret@example.com", "Secret Contact", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contacts, _ := app.DB.ListContacts(victimID)
|
||||||
|
uid := contacts[0].UID
|
||||||
|
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
// Attacker authenticates as themselves but requests a path under the victim's
|
||||||
|
// principal — DAVBasicAuth's ctx mailbox (attacker) is what every backend method
|
||||||
|
// actually uses, so this must 404, not leak the victim's contact.
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/carddav/victim@example.com/addressbooks/default/"+uid+".vcf", nil)
|
||||||
|
req.SetBasicAuth("attacker@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 (attacker's own ctx mailbox has no such contact), got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustMailboxID(t *testing.T, app *App, email string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
mbox, err := app.DB.GetMailboxByEmail(email)
|
||||||
|
if err != nil || mbox == nil {
|
||||||
|
t.Fatalf("mailbox %s not found: %v", email, err)
|
||||||
|
}
|
||||||
|
return mbox.ID
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestMailboxWithAppPasswordDAVOff is createTestMailboxWithAppPassword without the
|
||||||
|
// domain/mailbox CalDAV+CardDAV opt-ins — for tests that need to exercise the
|
||||||
|
// enablement gate itself rather than have it pre-satisfied.
|
||||||
|
func createTestMailboxWithAppPasswordDAVOff(t *testing.T, app *App, email string, domainID int64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
mailboxID := createTestMailboxWithPassword(t, app, email, domainID, "portal-password-unused-1!")
|
||||||
|
hash, err := db.HashPassword(carddavTestAppPassword)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := app.DB.CreateAppPassword(mailboxID, "dav test", hash, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return mailboxID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDAVDisabledByDefault confirms both protocols reject an otherwise-valid
|
||||||
|
// app-password login with 403 (not 401 — credentials are fine, the feature is just
|
||||||
|
// off) when neither the domain nor the mailbox has opted in, matching the requested
|
||||||
|
// default of "disabled for everyone."
|
||||||
|
func TestDAVDisabledByDefault(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
createTestMailboxWithAppPasswordDAVOff(t, app, "davoff@example.com", domains[0].ID)
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
"/caldav/davoff@example.com/calendars/default/",
|
||||||
|
"/carddav/davoff@example.com/addressbooks/default/",
|
||||||
|
} {
|
||||||
|
req, _ := http.NewRequest("PROPFIND", srv.URL+path, nil)
|
||||||
|
req.Header.Set("Depth", "0")
|
||||||
|
req.SetBasicAuth("davoff@example.com", carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("%s: status=%d, want 403 (disabled by default)", path, resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDAVRequiresBothDomainAndMailboxOptIn confirms neither the domain-wide admin
|
||||||
|
// switch nor the mailbox's own opt-in is sufficient alone — both must be on.
|
||||||
|
func TestDAVRequiresBothDomainAndMailboxOptIn(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
domainID := domains[0].ID
|
||||||
|
|
||||||
|
createTestMailboxWithAppPasswordDAVOff(t, app, "domainonly@example.com", domainID)
|
||||||
|
bothID := createTestMailboxWithAppPasswordDAVOff(t, app, "both@example.com", domainID)
|
||||||
|
|
||||||
|
if err := app.DB.SetDomainCalDAVEnabled(domainID, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := app.DB.SetMailboxDAVEnabled(bothID, true, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
propfind := func(email string) int {
|
||||||
|
req, _ := http.NewRequest("PROPFIND", srv.URL+"/caldav/"+email+"/calendars/default/", nil)
|
||||||
|
req.Header.Set("Depth", "0")
|
||||||
|
req.SetBasicAuth(email, carddavTestAppPassword)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := propfind("domainonly@example.com"); got != http.StatusForbidden {
|
||||||
|
t.Fatalf("domain-only (mailbox not opted in): status=%d, want 403", got)
|
||||||
|
}
|
||||||
|
if got := propfind("both@example.com"); got != http.StatusMultiStatus {
|
||||||
|
t.Fatalf("domain+mailbox both on: status=%d, want 207", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now flip it around: mailbox opted in, but the domain-wide switch off.
|
||||||
|
if err := app.DB.SetDomainCalDAVEnabled(domainID, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := propfind("both@example.com"); got != http.StatusForbidden {
|
||||||
|
t.Fatalf("mailbox on but domain off: status=%d, want 403", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+163
-7
@@ -2,6 +2,7 @@ package webui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"mailgoserver/internal/db"
|
"mailgoserver/internal/db"
|
||||||
"mailgoserver/internal/dkim"
|
"mailgoserver/internal/dkim"
|
||||||
|
"mailgoserver/internal/dnspublish"
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
@@ -73,7 +75,19 @@ func (a *App) dkimList(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
a.render(w, r, "dkim.html", M{"active": "dkim", "dkim_data": dkimData, "old_dkim_data": oldData})
|
warningDays := a.Cfg.Section("DKIM").Key("key_age_warning_days").MustInt(180)
|
||||||
|
|
||||||
|
globalKey, _ := a.DKIM.GetActiveGlobalDKIMKey()
|
||||||
|
globalHostname := a.Cfg.Section("DKIM").Key("global_dkim_hostname").String()
|
||||||
|
var globalRecord *dkim.DNSRecord
|
||||||
|
if globalKey != nil && globalHostname != "" {
|
||||||
|
globalRecord, _ = a.DKIM.GlobalDKIMPublicKeyRecord(globalHostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.render(w, r, "dkim.html", M{
|
||||||
|
"active": "dkim", "dkim_data": dkimData, "old_dkim_data": oldData, "key_age_warning_days": warningDays,
|
||||||
|
"global_key": globalKey, "global_hostname": globalHostname, "global_record": globalRecord,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// createDKIM mirrors dkim.py's create_dkim().
|
// createDKIM mirrors dkim.py's create_dkim().
|
||||||
@@ -97,12 +111,16 @@ func (a *App) createDKIM(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": "Failed to create DKIM key"})
|
writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": "Failed to create DKIM key"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ok, err := a.DKIM.GenerateDKIMKeypair(domain, selector, true)
|
ok, publishErr, err := a.DKIM.GenerateAndPublish(domain, selector, true)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": "Failed to create DKIM key"})
|
writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": "Failed to create DKIM key"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, M{"success": true, "message": "DKIM key created successfully"})
|
message := "DKIM key created successfully"
|
||||||
|
if publishErr != nil {
|
||||||
|
message = "DKIM key created, but automatic DNS publish failed: " + publishErr.Error() + " — update your DNS manually"
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, M{"success": true, "message": message})
|
||||||
}
|
}
|
||||||
|
|
||||||
// regenerateDKIM mirrors dkim.py's regenerate_dkim() — path id is a DOMAIN id.
|
// regenerateDKIM mirrors dkim.py's regenerate_dkim() — path id is a DOMAIN id.
|
||||||
@@ -125,11 +143,15 @@ func (a *App) regenerateDKIM(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.dkimActionFailed(w, r, "Failed to regenerate DKIM key")
|
a.dkimActionFailed(w, r, "Failed to regenerate DKIM key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ok, err := a.DKIM.GenerateDKIMKeypair(dom.DomainName, selector, true)
|
ok, publishErr, err := a.DKIM.GenerateAndPublish(dom.DomainName, selector, true)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
a.dkimActionFailed(w, r, "Failed to regenerate DKIM key")
|
a.dkimActionFailed(w, r, "Failed to regenerate DKIM key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
message := "DKIM key regenerated successfully"
|
||||||
|
if publishErr != nil {
|
||||||
|
message = "DKIM key regenerated, but automatic DNS publish failed: " + publishErr.Error() + " — update your DNS manually"
|
||||||
|
}
|
||||||
|
|
||||||
if isAjax(r) {
|
if isAjax(r) {
|
||||||
publicIP := getPublicIP(a.Cfg)
|
publicIP := getPublicIP(a.Cfg)
|
||||||
@@ -144,14 +166,18 @@ func (a *App) regenerateDKIM(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
newKey, _ := a.DB.GetActiveDKIMKeyByDomainID(domainID)
|
newKey, _ := a.DB.GetActiveDKIMKeyByDomainID(domainID)
|
||||||
writeJSON(w, http.StatusOK, M{
|
writeJSON(w, http.StatusOK, M{
|
||||||
"success": true, "message": "DKIM key regenerated successfully",
|
"success": true, "message": message,
|
||||||
"new_key": newKey, "dns_record": rec, "existing_spf": existingSPF,
|
"new_key": newKey, "dns_record": rec, "existing_spf": existingSPF,
|
||||||
"recommended_spf": generateSPFRecord(publicIP, existingSPF),
|
"recommended_spf": generateSPFRecord(publicIP, existingSPF),
|
||||||
"public_ip": publicIP, "domain": dom.DomainName,
|
"public_ip": publicIP, "domain": dom.DomainName,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setFlash(w, "success", "DKIM key regenerated successfully")
|
category := "success"
|
||||||
|
if publishErr != nil {
|
||||||
|
category = "error"
|
||||||
|
}
|
||||||
|
setFlash(w, category, message)
|
||||||
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +196,8 @@ func (a *App) editDKIMForm(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dom, _ := a.DB.GetDomainByID(key.DomainID)
|
dom, _ := a.DB.GetDomainByID(key.DomainID)
|
||||||
a.render(w, r, "edit_dkim.html", M{"active": "dkim", "dkim_key": key, "domain": dom})
|
dnsCreds, _ := a.DB.GetDomainDNSCredentials(key.DomainID)
|
||||||
|
a.render(w, r, "edit_dkim.html", M{"active": "dkim", "dkim_key": key, "domain": dom, "dns_creds": dnsCreds})
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectorPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
var selectorPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||||
@@ -261,6 +288,135 @@ func (a *App) removeDKIM(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dkimDNSAutomationProviders are the DNS providers dnspublish.SetTXTRecord supports —
|
||||||
|
// shared by both the per-domain automation form here and the settings-page provider
|
||||||
|
// select (mirrors internal/acmecert's fixed [LetsEncrypt] provider set).
|
||||||
|
var dkimDNSAutomationProviders = map[string]bool{"cloudflare": true, "route53": true, "digitalocean": true, "gcloud": true}
|
||||||
|
|
||||||
|
// dkimDNSAutomationUpdate saves a domain's dkim_dns_automation mode and (if provided)
|
||||||
|
// its DNS provider credentials for GenerateAndPublish to use on the next
|
||||||
|
// create/regenerate. Routed as /dkim/{id}/dns-automation with a KEY id (not a domain
|
||||||
|
// id) — same as editDKIM/toggleDKIM above, and the page it's embedded in
|
||||||
|
// (edit_dkim.html, reached via that same key id) — unlike regenerateDKIM's
|
||||||
|
// /dkim/{id}/regenerate, which is the one DKIM route keyed by domain id instead.
|
||||||
|
func (a *App) dkimDNSAutomationUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key, ok := a.dkimKeyWithAccess(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainID := key.DomainID
|
||||||
|
back := fmt.Sprintf("%s/dkim/%d/edit", Prefix, key.ID)
|
||||||
|
dom, err := a.DB.GetDomainByID(domainID)
|
||||||
|
if err != nil || dom == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := r.FormValue("dkim_dns_automation")
|
||||||
|
if mode != "manual" && mode != "automatic" {
|
||||||
|
setFlash(w, "error", "Invalid DNS automation mode")
|
||||||
|
http.Redirect(w, r, back, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.DB.SetDomainDKIMDNSAutomation(domainID, mode); err != nil {
|
||||||
|
setFlash(w, "error", "Error saving DNS automation setting")
|
||||||
|
http.Redirect(w, r, back, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := r.FormValue("dns_provider")
|
||||||
|
if provider != "" {
|
||||||
|
if !dkimDNSAutomationProviders[provider] {
|
||||||
|
setFlash(w, "error", "Unrecognized DNS provider")
|
||||||
|
http.Redirect(w, r, back, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
zoneName := strings.TrimSpace(r.FormValue("zone_name"))
|
||||||
|
if zoneName == "" {
|
||||||
|
zoneName = dom.DomainName
|
||||||
|
}
|
||||||
|
creds := db.DomainDNSCredentials{
|
||||||
|
DomainID: domainID, Provider: provider, ZoneName: zoneName,
|
||||||
|
CloudflareAPIToken: r.FormValue("cloudflare_api_token"),
|
||||||
|
Route53AccessKeyID: r.FormValue("route53_access_key_id"),
|
||||||
|
Route53SecretAccessKey: r.FormValue("route53_secret_access_key"),
|
||||||
|
Route53Region: r.FormValue("route53_region"),
|
||||||
|
DigitalOceanAPIToken: r.FormValue("digitalocean_api_token"),
|
||||||
|
GCloudProject: r.FormValue("gcloud_project"),
|
||||||
|
GCloudServiceAccountJSON: r.FormValue("gcloud_service_account_json"),
|
||||||
|
}
|
||||||
|
if err := a.DB.SetDomainDNSCredentials(creds); err != nil {
|
||||||
|
setFlash(w, "error", "Error saving DNS provider credentials")
|
||||||
|
http.Redirect(w, r, back, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlash(w, "success", "DNS automation settings saved")
|
||||||
|
http.Redirect(w, r, back, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// globalDKIMCredsFromConfig builds dnspublish.Credentials from [DKIM]'s
|
||||||
|
// global_dkim_* settings, or nil if no provider is configured — mirrors
|
||||||
|
// internal/acmecert's buildDNSProvider, just against dnspublish's generic interface
|
||||||
|
// instead of lego's ACME-challenge-shaped one (see internal/dnspublish's doc comment
|
||||||
|
// for why those can't be reused for each other).
|
||||||
|
func (a *App) globalDKIMCredsFromConfig() *dnspublish.Credentials {
|
||||||
|
sec := a.Cfg.Section("DKIM")
|
||||||
|
provider := sec.Key("global_dkim_provider").String()
|
||||||
|
if provider == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &dnspublish.Credentials{
|
||||||
|
Provider: provider, ZoneName: sec.Key("global_dkim_hostname").String(),
|
||||||
|
CloudflareAPIToken: sec.Key("global_dkim_cloudflare_api_token").String(),
|
||||||
|
Route53AccessKeyID: sec.Key("global_dkim_route53_access_key_id").String(),
|
||||||
|
Route53SecretAccessKey: sec.Key("global_dkim_route53_secret_access_key").String(),
|
||||||
|
Route53Region: sec.Key("global_dkim_route53_region").String(),
|
||||||
|
DigitalOceanAPIToken: sec.Key("global_dkim_digitalocean_api_token").String(),
|
||||||
|
GCloudProject: sec.Key("global_dkim_gcloud_project").String(),
|
||||||
|
GCloudServiceAccountJSON: sec.Key("global_dkim_gcloud_service_account_json").String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// regenerateGlobalDKIM (re)generates the one shared/global DKIM key — global-admin
|
||||||
|
// only (wired via requireGlobalAdmin at the route), since it's server-wide, not
|
||||||
|
// attributable to any one domain, same reasoning as /backups and /monitoring. Routed
|
||||||
|
// as the parameter-free /dkim/global-key/regenerate (not /dkim/{id}/regenerate's
|
||||||
|
// {id}-shaped sibling) to avoid any ambiguity with that domain-id route.
|
||||||
|
func (a *App) regenerateGlobalDKIM(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hostname := a.Cfg.Section("DKIM").Key("global_dkim_hostname").String()
|
||||||
|
publishErr, err := a.DKIM.GenerateGlobalDKIMKey(hostname, a.globalDKIMCredsFromConfig())
|
||||||
|
if err != nil {
|
||||||
|
setFlash(w, "error", "Failed to regenerate the shared DKIM key")
|
||||||
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if publishErr != nil {
|
||||||
|
setFlash(w, "error", "Shared DKIM key regenerated, but automatic DNS publish failed: "+publishErr.Error()+" — update your DNS manually")
|
||||||
|
} else {
|
||||||
|
setFlash(w, "success", "Shared DKIM key regenerated successfully")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, Prefix+"/dkim", http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dkimUseGlobalUpdate saves whether this domain signs with the shared/global DKIM key
|
||||||
|
// instead of its own — routed by KEY id, same as dkimDNSAutomationUpdate above.
|
||||||
|
func (a *App) dkimUseGlobalUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
key, ok := a.dkimKeyWithAccess(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := fmt.Sprintf("%s/dkim/%d/edit", Prefix, key.ID)
|
||||||
|
if err := a.DB.SetDomainUseGlobalDKIM(key.DomainID, r.FormValue("use_global_dkim") == "on"); err != nil {
|
||||||
|
setFlash(w, "error", "Error saving setting")
|
||||||
|
http.Redirect(w, r, back, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFlash(w, "success", "Setting saved")
|
||||||
|
http.Redirect(w, r, back, http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
// checkDKIMDNS mirrors dkim.py's check_dkim_dns().
|
// checkDKIMDNS mirrors dkim.py's check_dkim_dns().
|
||||||
func (a *App) checkDKIMDNS(w http.ResponseWriter, r *http.Request) {
|
func (a *App) checkDKIMDNS(w http.ResponseWriter, r *http.Request) {
|
||||||
domain := r.FormValue("domain")
|
domain := r.FormValue("domain")
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDKIMPageShowsRotationWarningForOldKey confirms a fresh key shows no rotation
|
||||||
|
// badge, and one older than [DKIM] key_age_warning_days does.
|
||||||
|
func TestDKIMPageShowsRotationWarningForOldKey(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
cookie := loginSession(t, app)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, Prefix+"/dkim", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "Consider rotating this key") {
|
||||||
|
t.Fatal("expected no rotation-warning badge for a freshly generated key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backdate the seeded key past the 180-day default threshold directly via SQL —
|
||||||
|
// there's no CRUD setter for created_at (it's meant to be set once, at insert).
|
||||||
|
if _, err := app.DB.Exec(`UPDATE esrv_dkim_keys SET created_at = datetime('now', '-200 days')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, Prefix+"/dkim", nil)
|
||||||
|
req2.AddCookie(cookie)
|
||||||
|
mux.ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusOK || !strings.Contains(rec2.Body.String(), "Consider rotating this key") {
|
||||||
|
t.Fatalf("expected a rotation-warning badge for a 200-day-old key, status=%d, body: %s", rec2.Code, rec2.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/dnspublish"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDKIMDNSAutomationEndToEnd exercises the whole per-domain auto-publish flow
|
||||||
|
// through real HTTP requests: save Automatic mode + Cloudflare credentials on the
|
||||||
|
// domain's DKIM edit page, then Regenerate — and confirms the regenerate actually
|
||||||
|
// issued the expected Cloudflare API calls (against a fake httptest.Server via
|
||||||
|
// dnspublish's exported test override, not a mock).
|
||||||
|
func TestDKIMDNSAutomationEndToEnd(t *testing.T) {
|
||||||
|
var sawZoneLookup, sawRecordCreate bool
|
||||||
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/zones":
|
||||||
|
sawZoneLookup = true
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[{"id":"zone1"}]}`))
|
||||||
|
case r.URL.Path == "/zones/zone1/dns_records" && r.Method == http.MethodGet:
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[]}`))
|
||||||
|
case r.URL.Path == "/zones/zone1/dns_records" && r.Method == http.MethodPost:
|
||||||
|
sawRecordCreate = true
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":{"id":"rec1"}}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer fake.Close()
|
||||||
|
origBase := dnspublish.CloudflareAPIBase
|
||||||
|
dnspublish.CloudflareAPIBase = fake.URL
|
||||||
|
defer func() { dnspublish.CloudflareAPIBase = origBase }()
|
||||||
|
|
||||||
|
app := newTestApp(t)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
domainID := domains[0].ID
|
||||||
|
key, err := app.DB.GetActiveDKIMKeyByDomainID(domainID)
|
||||||
|
if err != nil || key == nil {
|
||||||
|
t.Fatalf("expected a seeded active DKIM key: %v %v", key, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(SecurityHeaders(app.CSRFProtect(app.Mux())))
|
||||||
|
defer srv.Close()
|
||||||
|
cookie := loginSession(t, app)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
tokenPattern := regexp.MustCompile(`window\.__csrfToken\s*=\s*"([0-9a-f]+)"`)
|
||||||
|
fetchToken := func(path string) string {
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
m := tokenPattern.FindSubmatch(body)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("no CSRF token found on %s: %s", path, body)
|
||||||
|
}
|
||||||
|
return string(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
keyIDStr := strconv.FormatInt(key.ID, 10)
|
||||||
|
editPath := "/smtp-server/dkim/" + keyIDStr + "/edit"
|
||||||
|
token := fetchToken(editPath)
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"dkim_dns_automation": {"automatic"},
|
||||||
|
"dns_provider": {"cloudflare"},
|
||||||
|
"zone_name": {domains[0].DomainName},
|
||||||
|
"cloudflare_api_token": {"fake-token"},
|
||||||
|
}
|
||||||
|
postReq, _ := http.NewRequest(http.MethodPost, srv.URL+"/smtp-server/dkim/"+keyIDStr+"/dns-automation", strings.NewReader(form.Encode()))
|
||||||
|
postReq.AddCookie(cookie)
|
||||||
|
postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
postReq.Header.Set("X-CSRF-Token", token)
|
||||||
|
postResp, err := client.Do(postReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, postResp.Body)
|
||||||
|
postResp.Body.Close()
|
||||||
|
|
||||||
|
dom, err := app.DB.GetDomainByID(domainID)
|
||||||
|
if err != nil || dom == nil || dom.DKIMDNSAutomation != "automatic" {
|
||||||
|
t.Fatalf("expected dkim_dns_automation saved as automatic, got %+v (err=%v)", dom, err)
|
||||||
|
}
|
||||||
|
creds, err := app.DB.GetDomainDNSCredentials(domainID)
|
||||||
|
if err != nil || creds == nil || creds.Provider != "cloudflare" {
|
||||||
|
t.Fatalf("expected DNS credentials saved, got %+v (err=%v)", creds, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now regenerate — this is what should actually trigger the auto-publish.
|
||||||
|
token = fetchToken(editPath)
|
||||||
|
regenReq, _ := http.NewRequest(http.MethodPost, srv.URL+"/smtp-server/dkim/"+strconv.FormatInt(domainID, 10)+"/regenerate", nil)
|
||||||
|
regenReq.AddCookie(cookie)
|
||||||
|
regenReq.Header.Set("X-CSRF-Token", token)
|
||||||
|
regenResp, err := client.Do(regenReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, regenResp.Body)
|
||||||
|
regenResp.Body.Close()
|
||||||
|
|
||||||
|
if !sawZoneLookup || !sawRecordCreate {
|
||||||
|
t.Fatalf("expected the regenerate to call the Cloudflare API (zoneLookup=%v, recordCreate=%v)", sawZoneLookup, sawRecordCreate)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/config"
|
||||||
|
"mailgoserver/internal/dnspublish"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGlobalDKIMEndToEnd exercises the full shared-DKIM-key flow through real HTTP
|
||||||
|
// requests: save the hostname/provider on Settings, regenerate the shared key
|
||||||
|
// (confirming it actually calls the DNS provider API against a fake server), opt a
|
||||||
|
// domain in via its Edit DKIM page, and confirm outbound mail for that domain signs
|
||||||
|
// with the shared key's material afterward. Swaps in a real config.Load config (same
|
||||||
|
// pattern as TestSettingsUpdateSavesVirusScanFields) so this pass's new [DKIM]
|
||||||
|
// global_dkim_* defaults actually exist for settingsUpdate's generic
|
||||||
|
// only-update-existing-keys logic to find.
|
||||||
|
func TestGlobalDKIMEndToEnd(t *testing.T) {
|
||||||
|
var sawZoneLookup, sawRecordCreate bool
|
||||||
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/zones":
|
||||||
|
sawZoneLookup = true
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[{"id":"zone1"}]}`))
|
||||||
|
case r.URL.Path == "/zones/zone1/dns_records" && r.Method == http.MethodGet:
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":[]}`))
|
||||||
|
case r.URL.Path == "/zones/zone1/dns_records" && r.Method == http.MethodPost:
|
||||||
|
sawRecordCreate = true
|
||||||
|
w.Write([]byte(`{"success":true,"errors":[],"result":{"id":"rec1"}}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer fake.Close()
|
||||||
|
origBase := dnspublish.CloudflareAPIBase
|
||||||
|
dnspublish.CloudflareAPIBase = fake.URL
|
||||||
|
defer func() { dnspublish.CloudflareAPIBase = origBase }()
|
||||||
|
|
||||||
|
app := newTestApp(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "settings.ini")
|
||||||
|
realCfg, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app.Cfg = realCfg
|
||||||
|
app.ConfigPath = configPath
|
||||||
|
mux := app.Mux()
|
||||||
|
cookie := loginSession(t, app)
|
||||||
|
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
domainID := domains[0].ID
|
||||||
|
key, err := app.DB.GetActiveDKIMKeyByDomainID(domainID)
|
||||||
|
if err != nil || key == nil {
|
||||||
|
t.Fatalf("expected a seeded active DKIM key: %v %v", key, err)
|
||||||
|
}
|
||||||
|
ownPrivateKey := key.PrivateKey
|
||||||
|
|
||||||
|
post := func(path string, form url.Values) *httptest.ResponseRecorder {
|
||||||
|
var body strings.Reader
|
||||||
|
if form != nil {
|
||||||
|
body = *strings.NewReader(form.Encode())
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, &body)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the hostname + Cloudflare provider on Settings.
|
||||||
|
settingsRec := post(Prefix+"/settings_update", url.Values{
|
||||||
|
"DKIM.global_dkim_hostname": {"dkim.example.com"},
|
||||||
|
"DKIM.global_dkim_provider": {"cloudflare"},
|
||||||
|
"DKIM.global_dkim_cloudflare_api_token": {"fake-token"},
|
||||||
|
})
|
||||||
|
if settingsRec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("settings_update: status=%d body=%s", settingsRec.Code, settingsRec.Body.String())
|
||||||
|
}
|
||||||
|
if got := app.Cfg.Section("DKIM").Key("global_dkim_hostname").String(); got != "dkim.example.com" {
|
||||||
|
t.Fatalf("global_dkim_hostname = %q, want dkim.example.com", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate the shared key — should call the fake Cloudflare API.
|
||||||
|
regenRec := post(Prefix+"/dkim/global-key/regenerate", nil)
|
||||||
|
if regenRec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("regenerate: status=%d body=%s", regenRec.Code, regenRec.Body.String())
|
||||||
|
}
|
||||||
|
if !sawZoneLookup || !sawRecordCreate {
|
||||||
|
t.Fatalf("expected regenerate to call the Cloudflare API (zoneLookup=%v, recordCreate=%v)", sawZoneLookup, sawRecordCreate)
|
||||||
|
}
|
||||||
|
globalKey, err := app.DKIM.GetActiveGlobalDKIMKey()
|
||||||
|
if err != nil || globalKey == nil {
|
||||||
|
t.Fatalf("expected an active global key, got %v %v", globalKey, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt the domain in via its Edit DKIM page.
|
||||||
|
useGlobalRec := post(Prefix+"/dkim/"+strconv.FormatInt(key.ID, 10)+"/use-global", url.Values{"use_global_dkim": {"on"}})
|
||||||
|
if useGlobalRec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("use-global: status=%d body=%s", useGlobalRec.Code, useGlobalRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
dom, err := app.DB.GetDomainByID(domainID)
|
||||||
|
if err != nil || dom == nil || !dom.UseGlobalDKIM {
|
||||||
|
t.Fatalf("expected use_global_dkim saved as true, got %+v (err=%v)", dom, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm signing now uses the shared key's material, keeping the domain's own
|
||||||
|
// selector.
|
||||||
|
activeKey, err := app.DKIM.GetActiveDKIMKey(dom.DomainName)
|
||||||
|
if err != nil || activeKey == nil {
|
||||||
|
t.Fatalf("GetActiveDKIMKey: %v %v", activeKey, err)
|
||||||
|
}
|
||||||
|
if activeKey.Selector != key.Selector {
|
||||||
|
t.Fatalf("expected the domain's own selector preserved, got %q want %q", activeKey.Selector, key.Selector)
|
||||||
|
}
|
||||||
|
if activeKey.PrivateKey != globalKey.PrivateKey {
|
||||||
|
t.Fatal("expected signing to use the shared key's private key material")
|
||||||
|
}
|
||||||
|
if activeKey.PrivateKey == ownPrivateKey {
|
||||||
|
t.Fatal("expected the domain's own private key to no longer be used once global mode is on")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/backup"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exportDomain streams a passphrase-encrypted archive of every mailbox in a domain —
|
||||||
|
// see internal/backup.WriteDomain for exactly what's included.
|
||||||
|
func (a *App) exportDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dom, err := a.DB.GetDomainByID(pathID(r))
|
||||||
|
if err != nil || dom == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireDomainAccess(w, r, dom.ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passphrase := r.FormValue("passphrase")
|
||||||
|
if passphrase == "" {
|
||||||
|
setFlash(w, "error", "A passphrase is required to export a domain")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("%s/domains/%d/edit", Prefix, dom.ID), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s-export-%s.tar.gz", dom.DomainName, time.Now().UTC().Format("2006-01-02-150405"))
|
||||||
|
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||||
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
|
if err := backup.WriteDomain(w, a.DB, a.Mailstore, dom.ID, passphrase); err != nil {
|
||||||
|
a.Logger.Error("domain export %s: %v", dom.DomainName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// importDomain restores an archive produced by exportDomain into an existing domain.
|
||||||
|
// A mailbox whose email already exists anywhere on this server is skipped, not
|
||||||
|
// overwritten — see internal/backup.RestoreDomain.
|
||||||
|
func (a *App) importDomain(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dom, err := a.DB.GetDomainByID(pathID(r))
|
||||||
|
if err != nil || dom == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireDomainAccess(w, r, dom.ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirect := fmt.Sprintf("%s/domains/%d/edit", Prefix, dom.ID)
|
||||||
|
|
||||||
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||||
|
setFlash(w, "error", "Invalid upload")
|
||||||
|
http.Redirect(w, r, redirect, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passphrase := r.FormValue("passphrase")
|
||||||
|
file, _, err := r.FormFile("archive")
|
||||||
|
if err != nil {
|
||||||
|
setFlash(w, "error", "No archive file provided")
|
||||||
|
http.Redirect(w, r, redirect, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
skipped, err := backup.RestoreDomain(file, a.DB, a.Mailstore, dom.ID, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
a.Logger.Error("domain import into %s: %v", dom.DomainName, err)
|
||||||
|
setFlash(w, "error", "Import failed: "+err.Error())
|
||||||
|
http.Redirect(w, r, redirect, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(skipped) > 0 {
|
||||||
|
setFlash(w, "success", fmt.Sprintf("Import complete. Skipped %d mailbox(es) whose address already existed: %v", len(skipped), skipped))
|
||||||
|
} else {
|
||||||
|
setFlash(w, "success", "Import complete")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, redirect, http.StatusFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"mailgoserver/internal/db"
|
||||||
|
"mailgoserver/internal/mailstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestExportImportDomainRoundTrip exercises the admin domain page's export/import
|
||||||
|
// routes end-to-end over real HTTP (CSRF token scraped from the rendered page, same
|
||||||
|
// as csrf_test.go/backups_test.go) — export a domain from one server, import it into a
|
||||||
|
// domain on a SECOND, entirely separate server (two independent Apps/DBs, matching the
|
||||||
|
// real cross-server-portability scenario this feature exists for — a same-server
|
||||||
|
// import would trivially "already exist" since the seeded mailbox's email is global to
|
||||||
|
// one DB), confirm the mailbox landed there with its data intact.
|
||||||
|
func TestExportImportDomainRoundTrip(t *testing.T) {
|
||||||
|
srcApp := newTestApp(t)
|
||||||
|
srcSrv := httptest.NewServer(SecurityHeaders(srcApp.CSRFProtect(srcApp.Mux())))
|
||||||
|
defer srcSrv.Close()
|
||||||
|
srcCookie := loginSession(t, srcApp)
|
||||||
|
|
||||||
|
dstApp := newTestApp(t)
|
||||||
|
dstSrv := httptest.NewServer(SecurityHeaders(dstApp.CSRFProtect(dstApp.Mux())))
|
||||||
|
defer dstSrv.Close()
|
||||||
|
dstCookie := loginSession(t, dstApp)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
// The import/export POST handlers redirect (302) or stream a download — following
|
||||||
|
// redirects automatically would turn the import assertion below into a check on
|
||||||
|
// whatever page it redirects to instead of the POST's own response.
|
||||||
|
noRedirectClient := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||||
|
|
||||||
|
scrapeToken := func(srvURL, path string, cookie *http.Cookie) string {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, srvURL+path, nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s: status %d, body: %s", path, resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
m := regexp.MustCompile(`window\.__csrfToken\s*=\s*"([0-9a-f]+)"`).FindSubmatch(body)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("no CSRF token found on %s: %s", path, body)
|
||||||
|
}
|
||||||
|
return string(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
srcDomains, _ := srcApp.DB.ListDomains()
|
||||||
|
srcDomainID := srcDomains[0].ID
|
||||||
|
|
||||||
|
// newTestApp seeds "inbox@example.com" on EVERY App, so a cross-server import of
|
||||||
|
// just that address would always hit the skip path (dst already has its own copy)
|
||||||
|
// rather than exercising a genuine successful import — add a second, distinct
|
||||||
|
// mailbox on the source specifically so this test proves data actually arrives.
|
||||||
|
hash, err := db.HashPassword("second-mailbox-pw-123!")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dek := mailstore.GenerateDEK()
|
||||||
|
wrapped, nonce, err := srcApp.Mailstore.WrapDEK(dek)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
secondMailboxID, err := srcApp.DB.CreateMailbox("second@example.com", hash, srcDomainID, 5*1024*1024*1024, wrapped, nonce)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := srcApp.Mailstore.StoreMessage(secondMailboxID, "INBOX", []byte("From: a@b.com\r\nSubject: exported\r\n\r\nexported body"), "<m1@b.com>", "a@b.com", "exported"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srcEditPath := Prefix + "/domains/" + itoa(srcDomainID) + "/edit"
|
||||||
|
exportToken := scrapeToken(srcSrv.URL, srcEditPath, srcCookie)
|
||||||
|
|
||||||
|
exportReq, _ := http.NewRequest(http.MethodPost, srcSrv.URL+Prefix+"/domains/"+itoa(srcDomainID)+"/export",
|
||||||
|
strings.NewReader(url.Values{"passphrase": {"export-pw"}}.Encode()))
|
||||||
|
exportReq.AddCookie(srcCookie)
|
||||||
|
exportReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
exportReq.Header.Set("X-CSRF-Token", exportToken)
|
||||||
|
exportResp, err := client.Do(exportReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archive, _ := io.ReadAll(exportResp.Body)
|
||||||
|
exportResp.Body.Close()
|
||||||
|
if exportResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("export: status %d, body: %s", exportResp.StatusCode, archive)
|
||||||
|
}
|
||||||
|
if cd := exportResp.Header.Get("Content-Disposition"); !strings.Contains(cd, "attachment") {
|
||||||
|
t.Fatalf("Content-Disposition = %q, want an attachment", cd)
|
||||||
|
}
|
||||||
|
if len(archive) == 0 {
|
||||||
|
t.Fatal("exported archive is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
dstDomains, _ := dstApp.DB.ListDomains()
|
||||||
|
dstDomainID := dstDomains[0].ID
|
||||||
|
dstEditPath := Prefix + "/domains/" + itoa(dstDomainID) + "/edit"
|
||||||
|
importToken := scrapeToken(dstSrv.URL, dstEditPath, dstCookie)
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
part, err := mw.CreateFormFile("archive", "export.tar.gz")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
part.Write(archive)
|
||||||
|
mw.WriteField("passphrase", "export-pw")
|
||||||
|
mw.Close()
|
||||||
|
|
||||||
|
importReq, _ := http.NewRequest(http.MethodPost, dstSrv.URL+Prefix+"/domains/"+itoa(dstDomainID)+"/import", &body)
|
||||||
|
importReq.AddCookie(dstCookie)
|
||||||
|
importReq.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
importReq.Header.Set("X-CSRF-Token", importToken)
|
||||||
|
importResp, err := noRedirectClient.Do(importReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, importResp.Body)
|
||||||
|
importResp.Body.Close()
|
||||||
|
if importResp.StatusCode != http.StatusFound {
|
||||||
|
t.Fatalf("import: status %d, want a redirect", importResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dstDomainID already has its own seeded "inbox@example.com" (every newTestApp
|
||||||
|
// seeds one) — that address collides and must be skipped, while "second@example.com"
|
||||||
|
// is new to dst and must actually arrive with its content intact.
|
||||||
|
mailboxes, err := dstApp.DB.ListMailboxesForDomain(dstDomainID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var imported *db.Mailbox
|
||||||
|
for i := range mailboxes {
|
||||||
|
if mailboxes[i].Email == "second@example.com" {
|
||||||
|
imported = &mailboxes[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mailboxes) != 2 || imported == nil {
|
||||||
|
t.Fatalf("expected the pre-existing seeded mailbox plus the newly imported second@example.com, got %+v", mailboxes)
|
||||||
|
}
|
||||||
|
msgs, err := dstApp.DB.ListMessagesInFolder(imported.ID, "INBOX")
|
||||||
|
if err != nil || len(msgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 imported message, got %d (err=%v)", len(msgs), err)
|
||||||
|
}
|
||||||
|
raw, err := dstApp.Mailstore.FetchMessage(imported.ID, msgs[0].ID)
|
||||||
|
if err != nil || !strings.Contains(string(raw), "exported body") {
|
||||||
|
t.Fatalf("imported message content wrong: %q, err=%v", raw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEditDomainSetsAndClearsCatchall confirms the domain edit form's catch-all
|
||||||
|
// mailbox select actually persists (and can be cleared back to "off").
|
||||||
|
func TestEditDomainSetsAndClearsCatchall(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
cookie := loginSession(t, app)
|
||||||
|
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
domainID := domains[0].ID
|
||||||
|
mailboxes, _ := app.DB.ListMailboxesForDomain(domainID)
|
||||||
|
if len(mailboxes) == 0 {
|
||||||
|
t.Fatal("expected the seeded domain to already have a mailbox")
|
||||||
|
}
|
||||||
|
mailboxID := mailboxes[0].ID
|
||||||
|
|
||||||
|
post := func(form url.Values) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, Prefix+"/domains/"+strconv.FormatInt(domainID, 10)+"/edit", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := post(url.Values{"domain_name": {"example.com"}, "catchall_mailbox_id": {strconv.FormatInt(mailboxID, 10)}})
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("set catchall: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
dom, err := app.DB.GetDomainByID(domainID)
|
||||||
|
if err != nil || dom.CatchallMailboxID == nil || *dom.CatchallMailboxID != mailboxID {
|
||||||
|
t.Fatalf("expected catchall set to %d, got %+v (err=%v)", mailboxID, dom, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = post(url.Values{"domain_name": {"example.com"}})
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("clear catchall: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
dom, err = app.DB.GetDomainByID(domainID)
|
||||||
|
if err != nil || dom.CatchallMailboxID != nil {
|
||||||
|
t.Fatalf("expected catchall cleared, got %+v (err=%v)", dom, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditDomainSetsAndClearsSendRateLimit(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
mux := app.Mux()
|
||||||
|
cookie := loginSession(t, app)
|
||||||
|
domains, _ := app.DB.ListDomains()
|
||||||
|
domainID := domains[0].ID
|
||||||
|
|
||||||
|
post := func(form url.Values) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, Prefix+"/domains/"+strconv.FormatInt(domainID, 10)+"/edit", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := post(url.Values{"domain_name": {"example.com"}, "send_rate_limit_per_hour": {"100"}})
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("set rate limit: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
dom, err := app.DB.GetDomainByID(domainID)
|
||||||
|
if err != nil || dom.SendRateLimitPerHour == nil || *dom.SendRateLimitPerHour != 100 {
|
||||||
|
t.Fatalf("expected rate limit set to 100, got %+v (err=%v)", dom, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = post(url.Values{"domain_name": {"example.com"}})
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("clear rate limit: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
dom, err = app.DB.GetDomainByID(domainID)
|
||||||
|
if err != nil || dom.SendRateLimitPerHour != nil {
|
||||||
|
t.Fatalf("expected rate limit cleared, got %+v (err=%v)", dom, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,7 +110,12 @@ func (a *App) editDomainForm(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !requireDomainAccess(w, r, dom.ID) {
|
if !requireDomainAccess(w, r, dom.ID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.render(w, r, "edit_domain.html", M{"active": "domains", "domain": dom})
|
mailboxes, _ := a.DB.ListMailboxesForDomain(dom.ID)
|
||||||
|
var catchallID int64
|
||||||
|
if dom.CatchallMailboxID != nil {
|
||||||
|
catchallID = *dom.CatchallMailboxID
|
||||||
|
}
|
||||||
|
a.render(w, r, "edit_domain.html", M{"active": "domains", "domain": dom, "mailboxes": mailboxes, "catchall_id": catchallID})
|
||||||
}
|
}
|
||||||
|
|
||||||
// editDomain mirrors domains.py's edit_domain() POST branch.
|
// editDomain mirrors domains.py's edit_domain() POST branch.
|
||||||
@@ -146,6 +151,43 @@ func (a *App) editDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var catchallID *int64
|
||||||
|
if v := r.FormValue("catchall_mailbox_id"); v != "" {
|
||||||
|
id64 := int64(atoi(v))
|
||||||
|
catchallID = &id64
|
||||||
|
}
|
||||||
|
if err := a.DB.SetDomainCatchall(id, catchallID); err != nil {
|
||||||
|
setFlash(w, "error", "Error updating domain")
|
||||||
|
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rateLimit *int
|
||||||
|
if v := strings.TrimSpace(r.FormValue("send_rate_limit_per_hour")); v != "" {
|
||||||
|
n := atoi(v)
|
||||||
|
rateLimit = &n
|
||||||
|
}
|
||||||
|
if err := a.DB.SetDomainSendRateLimit(id, rateLimit); err != nil {
|
||||||
|
setFlash(w, "error", "Error updating domain")
|
||||||
|
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode := r.FormValue("mta_sts_mode"); mode == "testing" || mode == "enforce" {
|
||||||
|
if err := a.DB.SetDomainMTASTSMode(id, mode); err != nil {
|
||||||
|
setFlash(w, "error", "Error updating domain")
|
||||||
|
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.DB.SetDomainCalDAVEnabled(id, r.FormValue("caldav_enabled") == "on"); err != nil {
|
||||||
|
setFlash(w, "error", "Error updating domain")
|
||||||
|
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.DB.SetDomainCardDAVEnabled(id, r.FormValue("carddav_enabled") == "on"); err != nil {
|
||||||
|
setFlash(w, "error", "Error updating domain")
|
||||||
|
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
setFlash(w, "success", "Domain updated successfully")
|
setFlash(w, "success", "Domain updated successfully")
|
||||||
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mailgoserver/internal/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMailStreamPushesOnNewMessage confirms the SSE endpoint the folder view's JS
|
||||||
|
// connects to actually emits an event when a message lands in the mailbox/folder it's
|
||||||
|
// watching — the real end-to-end wiring from internal/notify through to an HTTP
|
||||||
|
// response, not just the Bus's own unit tests.
|
||||||
|
func TestMailStreamPushesOnNewMessage(t *testing.T) {
|
||||||
|
app := newTestApp(t)
|
||||||
|
app.Notify = notify.NewBus()
|
||||||
|
srv := httptest.NewServer(app.Mux())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
mailboxes, err := app.DB.ListMailboxes()
|
||||||
|
if err != nil || len(mailboxes) == 0 {
|
||||||
|
t.Fatalf("no seeded mailbox: %v", err)
|
||||||
|
}
|
||||||
|
mailboxID := mailboxes[0].ID
|
||||||
|
cookie := webmailLoginSession(t, app, mailboxID)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+MailboxPrefix+"/mail/INBOX/stream", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("stream: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
|
||||||
|
t.Fatalf("Content-Type = %q, want text/event-stream", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the handler time to reach its Subscribe call before publishing —
|
||||||
|
// Publish is fire-and-forget, not queued for a subscriber that hasn't
|
||||||
|
// registered yet.
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
if _, err := app.Mailstore.StoreMessage(mailboxID, "INBOX", []byte("Subject: pushed\r\n\r\nbody"), "<p@example.com>", "sender@example.com", "pushed"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
app.Notify.Publish(mailboxID, "INBOX")
|
||||||
|
|
||||||
|
got := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "data:") {
|
||||||
|
got <- line
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case line := <-got:
|
||||||
|
if line != "data: refresh" {
|
||||||
|
t.Fatalf("got %q, want %q", line, "data: refresh")
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for an SSE push")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,9 @@ func (a *App) addAllowBlockEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/lists", http.StatusFound)
|
http.Redirect(w, r, Prefix+"/mailboxes/"+idStr(r)+"/lists", http.StatusFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := a.DB.AddAllowBlockEntry(mailbox.ID, listType, pattern); err != nil {
|
// Admin-added allow entries stay full-bypass ("all") — the per-check scope picker
|
||||||
|
// is only exposed on the webmail self-service Whitelist (webmail_blocklist.go).
|
||||||
|
if _, err := a.DB.AddAllowBlockEntry(mailbox.ID, listType, pattern, "all"); err != nil {
|
||||||
setFlash(w, "error", "Error adding entry")
|
setFlash(w, "error", "Error adding entry")
|
||||||
} else {
|
} else {
|
||||||
setFlash(w, "success", "Entry added")
|
setFlash(w, "success", "Entry added")
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ var validConditionFields = map[string]bool{
|
|||||||
}
|
}
|
||||||
var validConditionOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
|
var validConditionOps = map[string]bool{"contains": true, "equals": true, "starts_with": true}
|
||||||
var validActions = map[string]bool{
|
var validActions = map[string]bool{
|
||||||
"move_to_folder": true, "delete": true, "mark_read": true, "mark_as_spam": true, "forward": true,
|
"move_to_folder": true, "delete": true, "mark_read": true, "mark_as_spam": true, "forward": true, "auto_reply": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseRuleConditions reads the rule-builder's parallel condition_field/op/value
|
// parseRuleConditions reads the rule-builder's parallel condition_field/op/value
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// monitoringPage is the admin dashboard's in-app metrics view — message volume,
|
||||||
|
// delivery success rate, per-domain send counts, and mailbox quota usage, all backed
|
||||||
|
// by aggregate queries over the existing esrv_email_logs/esrv_email_recipient_logs
|
||||||
|
// tables (no new metrics infrastructure, no external tool). Global-admin only, same
|
||||||
|
// reasoning as Backups/Blacklist: server-wide, not attributable to one domain.
|
||||||
|
func (a *App) monitoringPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
const hours = 24
|
||||||
|
volume, err := a.DB.MessageVolumeByHour(hours)
|
||||||
|
if err != nil {
|
||||||
|
a.Logger.Error("message volume query: %v", err)
|
||||||
|
}
|
||||||
|
success, failed, err := a.DB.DeliveryStats(hours)
|
||||||
|
if err != nil {
|
||||||
|
a.Logger.Error("delivery stats query: %v", err)
|
||||||
|
}
|
||||||
|
domainCounts, err := a.DB.SendCountsByDomain(hours)
|
||||||
|
if err != nil {
|
||||||
|
a.Logger.Error("send counts by domain query: %v", err)
|
||||||
|
}
|
||||||
|
quotaUsage, err := a.DB.TopMailboxesByQuotaUsage(10)
|
||||||
|
if err != nil {
|
||||||
|
a.Logger.Error("quota usage query: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxVolume := 0
|
||||||
|
for _, b := range volume {
|
||||||
|
if n := b.Relayed + b.Partial + b.Failed; n > maxVolume {
|
||||||
|
maxVolume = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxDomainCount := 0
|
||||||
|
for _, c := range domainCounts {
|
||||||
|
if c.Count > maxDomainCount {
|
||||||
|
maxDomainCount = c.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.render(w, r, "monitoring.html", M{
|
||||||
|
"active": "monitoring", "hours": hours,
|
||||||
|
"volume": volume, "max_volume": maxVolume,
|
||||||
|
"delivery_success": success, "delivery_failed": failed,
|
||||||
|
"domain_counts": domainCounts, "max_domain_count": maxDomainCount,
|
||||||
|
"quota_usage": quotaUsage,
|
||||||
|
})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user