polishing and DKIM,caldav,cardav
This commit is contained in:
@@ -5,5 +5,6 @@ settings.ini
|
||||
tests/
|
||||
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
*.sum
|
||||
@@ -3,29 +3,43 @@ module mailgoserver
|
||||
go 1.26.4
|
||||
|
||||
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-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
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/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
|
||||
modernc.org/sqlite v1.56.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.3
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // 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/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/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/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/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/sso v1.30.16 // 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/cloudflare/circl v1.6.2 // 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/felixge/httpsnoop v1.0.4 // 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-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // 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/golang-jwt/jwt/v5 v5.3.1 // 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/gorilla/css v1.0.1 // 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/ncruces/go-strftime v1.0.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/tinylib/msgp v1.6.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/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.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/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.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/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // 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/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/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 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
||||
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/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.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.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/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/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/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/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/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
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-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/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
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/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/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/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/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
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.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/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
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/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
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.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/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/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
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/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
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/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-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/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.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/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
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-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.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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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-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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
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-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.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/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
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/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/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
|
||||
@@ -7,6 +7,7 @@ package abuseguard
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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
|
||||
// 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 {
|
||||
net.Listener
|
||||
database *db.DB
|
||||
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
|
||||
// blacklist (skipping the check entirely for abuse-whitelisted IPs) before the caller
|
||||
// ever sees it.
|
||||
func GuardListener(inner net.Listener, database *db.DB, logger *toolbox.Logger) net.Listener {
|
||||
return &guardedListener{Listener: inner, database: database, logger: logger}
|
||||
// blacklist (skipping the check entirely for abuse-whitelisted IPs) and the per-IP
|
||||
// concurrent-connection cap ([Security] max_connections_per_ip, default 20) before the
|
||||
// caller ever sees it.
|
||||
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) {
|
||||
@@ -78,19 +91,58 @@ func (g *guardedListener) Accept() (net.Conn, error) {
|
||||
if splitErr != nil {
|
||||
host = conn.RemoteAddr().String()
|
||||
}
|
||||
abuseWhitelisted := false
|
||||
if whitelisted, wErr := g.database.IsIPAbuseWhitelisted(host); wErr == nil && whitelisted {
|
||||
return conn, nil
|
||||
abuseWhitelisted = true
|
||||
}
|
||||
blocked, bErr := g.database.IsIPBlacklisted(host)
|
||||
if bErr != nil {
|
||||
return conn, nil // fail open on a DB error
|
||||
if !abuseWhitelisted {
|
||||
blocked, bErr := g.database.IsIPBlacklisted(host)
|
||||
if bErr == nil && blocked {
|
||||
if g.logger != nil {
|
||||
g.logger.Warning("abuseguard: rejected connection from blacklisted IP %s", host)
|
||||
}
|
||||
conn.Close()
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !blocked {
|
||||
return conn, nil
|
||||
// The concurrent-connection cap applies even to an abuse-whitelisted IP —
|
||||
// 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 {
|
||||
g.logger.Warning("abuseguard: rejected connection from blacklisted IP %s", host)
|
||||
}
|
||||
conn.Close()
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
|
||||
@@ -118,7 +119,7 @@ func TestGuardListenerRejectsBlacklistedIP(t *testing.T) {
|
||||
defer blockedPeer.Close()
|
||||
|
||||
inner := newFakeListener(&addrOverrideConn{Conn: blockedConn, remote: hostPortAddr(blockedIP)})
|
||||
guarded := GuardListener(inner, database, nil)
|
||||
guarded := GuardListener(inner, database, nil, nil)
|
||||
|
||||
go func() {
|
||||
guarded.Accept()
|
||||
@@ -144,3 +145,67 @@ type hostPortAddr string
|
||||
|
||||
func (hostPortAddr) Network() string { return "tcp" }
|
||||
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", ""},
|
||||
{"", "", "Provide Public IP address of server, used for SPF in case detection fails"},
|
||||
{"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{
|
||||
{"", "", "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"},
|
||||
{"abuse_blacklist_base_hours", "12", ""},
|
||||
{"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 server configuration for mailbox retrieval (Thunderbird, etc.)"},
|
||||
@@ -145,6 +168,21 @@ var defaults = []struct {
|
||||
{"app_password_min_length", "25", ""},
|
||||
{"", "", "Reject messages scoring at or above this built-in heuristic spam score"},
|
||||
{"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{
|
||||
{"", "", "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_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{
|
||||
{"", "", "Let's Encrypt (ACME, DNS-01) automatic certificate configuration. This obtains a"},
|
||||
{"", "", "separate certificate from [LetsEncryptHTTP] below - assign each independently to"},
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestAddAllowBlockJunkEntryWorksAfterMigration(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
)
|
||||
@@ -17,7 +18,9 @@ func (d *DB) ListDomains() ([]Domain, error) {
|
||||
var dm Domain
|
||||
var createdAt 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
|
||||
}
|
||||
dm.CreatedAt, _ = parseTime(createdAt)
|
||||
@@ -25,11 +28,66 @@ func (d *DB) ListDomains() ([]Domain, error) {
|
||||
t, _ := parseTime(*verifiedAt)
|
||||
dm.VerifiedAt = &t
|
||||
}
|
||||
if catchall.Valid {
|
||||
dm.CatchallMailboxID = &catchall.Int64
|
||||
}
|
||||
if rateLimit.Valid {
|
||||
n := int(rateLimit.Int64)
|
||||
dm.SendRateLimitPerHour = &n
|
||||
}
|
||||
out = append(out, dm)
|
||||
}
|
||||
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
|
||||
// add/edit sender and IP forms.
|
||||
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
|
||||
// detection: SMTP/IMAP traffic, not admin/webmail dashboard logins (those already have
|
||||
// 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,
|
||||
// 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
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"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) {
|
||||
var c MailboxContact
|
||||
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 {
|
||||
return c, err
|
||||
}
|
||||
c.CreatedAt, _ = parseTime(createdAt)
|
||||
if updatedAt.Valid {
|
||||
c.UpdatedAt, _ = parseTime(updatedAt.String)
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (d *DB) UpdateContact(mailboxID, id int64, email, name, phone string) error {
|
||||
_, err := d.Exec(`UPDATE esrv_mailbox_contacts SET email = ?, name = ?, phone = ? WHERE id = ? AND mailbox_id = ?`, email, name, phone, id, mailboxID)
|
||||
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 = ?, 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// this can never clobber a manual edit.
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package db
|
||||
import "strings"
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -13,7 +13,7 @@ func (d *DB) ListAllowBlock(mailboxID int64) ([]MailboxAllowBlockEntry, error) {
|
||||
for rows.Next() {
|
||||
var e MailboxAllowBlockEntry
|
||||
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
|
||||
}
|
||||
e.CreatedAt, _ = parseTime(createdAt)
|
||||
@@ -22,9 +22,11 @@ func (d *DB) ListAllowBlock(mailboxID int64) ([]MailboxAllowBlockEntry, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) AddAllowBlockEntry(mailboxID int64, listType, pattern string) (int64, error) {
|
||||
res, err := d.Exec(`INSERT OR IGNORE INTO esrv_mailbox_allowblock (mailbox_id, list_type, pattern) VALUES (?, ?, ?)`,
|
||||
mailboxID, listType, strings.ToLower(pattern))
|
||||
// AddAllowBlockEntry stores scope as given for "allow" entries; block/junk entries
|
||||
// don't use scope (the caller passes "all", meaningless but harmless there).
|
||||
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 {
|
||||
return 0, err
|
||||
}
|
||||
@@ -45,6 +47,30 @@ func (d *DB) IsAllowed(mailboxID int64, senderAddr string) (bool, error) {
|
||||
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
|
||||
// Blocklist ('junk' entries — see esrv_mailbox_allowblock's schema comment for how
|
||||
// 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)
|
||||
}
|
||||
|
||||
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()
|
||||
_, err := d.Exec(`INSERT INTO esrv_mailbox_sessions (token, mailbox_id, mfa_verified, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
token, mailboxID, mfaVerified, time.Now().Add(ttl))
|
||||
_, 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), userAgent, ip)
|
||||
if err != nil {
|
||||
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) {
|
||||
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 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) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -51,6 +56,43 @@ func (d *DB) DeleteMailboxSession(token string) error {
|
||||
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)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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) {
|
||||
var m Mailbox
|
||||
var createdAt string
|
||||
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) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -21,6 +22,9 @@ func scanMailbox(row *sql.Row) (*Mailbox, error) {
|
||||
if createdBy.Valid {
|
||||
m.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
if forwardTo.Valid {
|
||||
m.ForwardTo = &forwardTo.String
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
@@ -31,7 +35,7 @@ type MailboxWithDomain struct {
|
||||
}
|
||||
|
||||
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`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -42,13 +46,17 @@ func (d *DB) ListMailboxes() ([]MailboxWithDomain, error) {
|
||||
var m MailboxWithDomain
|
||||
var createdAt string
|
||||
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
|
||||
}
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
m.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
if forwardTo.Valid {
|
||||
m.ForwardTo = &forwardTo.String
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -65,13 +73,17 @@ func (d *DB) ListMailboxesForDomain(domainID int64) ([]Mailbox, error) {
|
||||
var m Mailbox
|
||||
var createdAt string
|
||||
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
|
||||
}
|
||||
m.CreatedAt, _ = parseTime(createdAt)
|
||||
if createdBy.Valid {
|
||||
m.CreatedBy = &createdBy.Int64
|
||||
}
|
||||
if forwardTo.Valid {
|
||||
m.ForwardTo = &forwardTo.String
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -133,6 +145,25 @@ func (d *DB) SetMailboxRemoteImagesMode(id int64, mode string) error {
|
||||
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 {
|
||||
_, err := d.Exec(`UPDATE esrv_mailboxes SET quota_bytes = ? WHERE id = ?`, quotaBytes, id)
|
||||
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
|
||||
// remote_images_mode column comment in schema.go.
|
||||
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
|
||||
@@ -41,6 +50,8 @@ type MailboxSession struct {
|
||||
MFAVerified bool
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
UserAgent string
|
||||
IPAddress string
|
||||
}
|
||||
|
||||
// MailboxWebAuthnCredential is a mailbox owner's passkey — a parallel schema to
|
||||
@@ -74,17 +85,43 @@ type MailboxAllowBlockEntry struct {
|
||||
MailboxID int64
|
||||
ListType string // "allow" | "block" | "junk"
|
||||
Pattern string
|
||||
Scope string // "all" | "spf" | "dkim" | "spam" — which check(s) an "allow" entry suppresses
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MailboxContact is one entry in a mailbox owner's own address book.
|
||||
type MailboxContact struct {
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Email string
|
||||
Name string
|
||||
Phone string
|
||||
CreatedAt time.Time
|
||||
ID int64
|
||||
MailboxID int64
|
||||
Email string
|
||||
Name string
|
||||
Phone string
|
||||
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.
|
||||
@@ -137,6 +174,19 @@ func (r MailboxFilterRule) ActionOptions() RuleActionOptions {
|
||||
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,
|
||||
// "any"=OR) — parses ConditionsJSON when present, falling back to the single legacy
|
||||
// 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
|
||||
// domain, regardless of that mailbox's own MFAExempt.
|
||||
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 {
|
||||
|
||||
+11
-2
@@ -54,14 +54,16 @@ func (d *DB) GetSenderByEmail(email string) (*Sender, error) {
|
||||
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.
|
||||
func scanDomain(row *sql.Row) (*Domain, error) {
|
||||
var dom Domain
|
||||
var createdAt string
|
||||
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) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -72,6 +74,13 @@ func scanDomain(row *sql.Row) (*Domain, error) {
|
||||
t, _ := parseTime(verifiedAt.String)
|
||||
dom.VerifiedAt = &t
|
||||
}
|
||||
if catchall.Valid {
|
||||
dom.CatchallMailboxID = &catchall.Int64
|
||||
}
|
||||
if rateLimit.Valid {
|
||||
n := int(rateLimit.Int64)
|
||||
dom.SendRateLimitPerHour = &n
|
||||
}
|
||||
return &dom, nil
|
||||
}
|
||||
|
||||
|
||||
+218
-6
@@ -24,7 +24,58 @@ CREATE TABLE IF NOT EXISTS esrv_domains (
|
||||
is_verified INTEGER NOT NULL DEFAULT 0,
|
||||
verified_at DATETIME,
|
||||
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 (
|
||||
@@ -131,6 +182,25 @@ CREATE TABLE IF NOT EXISTS esrv_dkim_keys (
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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,
|
||||
-- offered anyway since it's the mailbox owner's own call). See
|
||||
-- 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
|
||||
@@ -231,7 +312,13 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_sessions (
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
mfa_verified INTEGER NOT NULL DEFAULT 0,
|
||||
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 (
|
||||
@@ -287,6 +374,7 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_allowblock (
|
||||
mailbox_id INTEGER NOT NULL REFERENCES esrv_mailboxes(id),
|
||||
list_type TEXT NOT NULL CHECK(list_type IN ('allow','block','junk')),
|
||||
pattern TEXT NOT NULL,
|
||||
scope TEXT NOT NULL DEFAULT 'all',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(mailbox_id, list_type, pattern)
|
||||
);
|
||||
@@ -309,6 +397,43 @@ CREATE TABLE IF NOT EXISTS esrv_mailbox_contacts (
|
||||
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
|
||||
-- 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.
|
||||
@@ -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_op TEXT NOT NULL CHECK(condition_op IN ('contains','equals','starts_with')),
|
||||
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_options_json TEXT NOT NULL DEFAULT '',
|
||||
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
|
||||
);
|
||||
|
||||
-- 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
|
||||
-- (esrv_mailboxes.remote_images_mode = 'trusted') — added either from the account
|
||||
-- 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.
|
||||
`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 ''`,
|
||||
// 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
|
||||
// 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)
|
||||
migrateFilterRulesMarkAsSpamCheck(db)
|
||||
migrateFilterRulesAdvancedCheck(db)
|
||||
migrateFilterRulesAutoReplyCheck(db)
|
||||
migrateAllowBlockJunkCheck(db)
|
||||
migrateContactUIDs(db)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
db.Exec(`INSERT INTO esrv_mailbox_allowblock (id, mailbox_id, list_type, pattern, created_at)
|
||||
SELECT id, mailbox_id, list_type, pattern, created_at FROM esrv_mailbox_allowblock_old`)
|
||||
db.Exec(`INSERT INTO esrv_mailbox_allowblock (id, mailbox_id, list_type, pattern, scope, created_at)
|
||||
SELECT id, mailbox_id, list_type, pattern, scope, created_at FROM 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`)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -702,6 +892,28 @@ func migrateSpamRenamedToJunk(db *sql.DB) {
|
||||
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.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
|
||||
+112
-2
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
msgdkim "github.com/emersion/go-msgauth/dkim"
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/dnspublish"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
@@ -115,13 +210,28 @@ func encodeKeyPair(priv *rsa.PrivateKey) (privPEM, pubPEM string, err error) {
|
||||
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) {
|
||||
dom, err := m.DB.GetDomainByName(domainName)
|
||||
if err != nil || dom == nil {
|
||||
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
|
||||
|
||||
@@ -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 (
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/mailstore"
|
||||
"mailgoserver/internal/notify"
|
||||
"mailgoserver/internal/toolbox"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
@@ -21,6 +22,8 @@ type Backend struct {
|
||||
Mailstore *mailstore.Store
|
||||
Logger *toolbox.Logger
|
||||
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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/imapserver"
|
||||
"mailgoserver/internal/mailstore"
|
||||
"mailgoserver/internal/notify"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/mail"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
goimapserver "github.com/emersion/go-imap/v2/imapserver"
|
||||
|
||||
"mailgoserver/internal/abuseguard"
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/mailstore"
|
||||
)
|
||||
|
||||
const inboxName = "INBOX"
|
||||
@@ -20,14 +24,19 @@ var _ goimapserver.Session = (*Session)(nil)
|
||||
// Session implements goimapserver.Session against one mailbox's messages via
|
||||
// mailstore. Sequence numbers are recomputed fresh from the DB on every command
|
||||
// rather than cached/tracked across concurrent updates.
|
||||
// ponytail: no MailboxTracker/IDLE push support — Idle just blocks until the client
|
||||
// sends DONE, so a connected client still gets new mail via NOOP/periodic re-SELECT,
|
||||
// just not an instant push. Add a tracker if that matters.
|
||||
// ponytail: push (Idle/Poll below) only ever reports a changed NumMessages (new mail
|
||||
// arriving/being APPENDed) — it doesn't use go-imap/v2's MailboxTracker machinery, so
|
||||
// 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 {
|
||||
backend *Backend
|
||||
peerIP string
|
||||
mailbox *db.Mailbox // set once Login succeeds
|
||||
selectedFolder string // set by Select; defaults to INBOX if empty
|
||||
backend *Backend
|
||||
peerIP string
|
||||
mailbox *db.Mailbox // set once Login succeeds
|
||||
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 }
|
||||
@@ -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] })
|
||||
permanent := append(append([]imap.Flag{}, flags...), imap.FlagWildcard)
|
||||
|
||||
s.lastKnownNumMessages = uint32(len(msgs))
|
||||
return &imap.SelectData{
|
||||
Flags: flags,
|
||||
PermanentFlags: permanent,
|
||||
@@ -269,15 +279,105 @@ func (s *Session) Status(mailbox string, options *imap.StatusOptions) (*imap.Sta
|
||||
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) {
|
||||
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 {
|
||||
<-stop
|
||||
return nil
|
||||
if s.mailbox == nil || s.selectedFolder == "" || s.backend.Notify == 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
|
||||
|
||||
@@ -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
|
||||
|
||||
import "mailgoserver/internal/db"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
// ResolveRecipient looks up a local mailbox for addr — its primary email first, then
|
||||
// any active alias — so mail sent to an alias lands in the owning mailbox's INBOX.
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
mbox, err := s.DB.GetMailboxByEmail(addr)
|
||||
if err != nil || mbox != nil {
|
||||
return mbox, err
|
||||
}
|
||||
alias, err := s.DB.GetAliasByEmail(addr)
|
||||
if err != nil || alias == nil {
|
||||
if alias, err := s.DB.GetAliasByEmail(addr); err != 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 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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
@@ -18,6 +19,21 @@ type FilterAction struct {
|
||||
// the recipient's own local copy is still stored too.
|
||||
ForwardTo string
|
||||
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
|
||||
@@ -53,6 +69,10 @@ func (s *Store) ApplyRules(mailboxID int64, headers map[string]string) (FilterAc
|
||||
return FilterAction{MarkRead: true}, nil
|
||||
case "forward":
|
||||
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
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// 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) {
|
||||
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)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -80,8 +95,11 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
dir := filepath.Join(s.BasePath, sanitizePathSegment(mbox.Email), folder, now.Format("2006-02-Jan"))
|
||||
// The on-disk sharding directory is always bucketed by the real current time (when
|
||||
// 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 {
|
||||
return 0, err
|
||||
}
|
||||
@@ -92,7 +110,7 @@ func (s *Store) StoreMessage(mailboxID int64, folder string, raw []byte, message
|
||||
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 {
|
||||
os.Remove(storagePath)
|
||||
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 (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
@@ -50,6 +52,29 @@ type Relay struct {
|
||||
// before that, so this is assigned afterward rather than threaded through the
|
||||
// constructor. Nil-safe: SendBounce falls back to relaying out when unset.
|
||||
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
|
||||
@@ -169,7 +194,7 @@ func (r *Relay) deliverToDomain(domain, mailFrom string, rcpts []string, content
|
||||
var lastErr error
|
||||
for _, mx := range mxRecords {
|
||||
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 {
|
||||
return "success", resp, "", ""
|
||||
}
|
||||
@@ -179,8 +204,45 @@ func (r *Relay) deliverToDomain(domain, mailFrom string, rcpts []string, content
|
||||
return "failed", "", "RELAY", fmt.Sprintf("%v", lastErr)
|
||||
}
|
||||
|
||||
func (r *Relay) trySend(host, mailFrom string, rcpts []string, content string) (string, error) {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(mxPort)), r.Timeout)
|
||||
// errStartTLSVerifyFailed wraps a STARTTLS failure that happened during real
|
||||
// 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 {
|
||||
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 —
|
||||
// mirrors relay_email_async's "if starttls in extensions" check with no hard
|
||||
// requirement, and no strict certificate verification since arbitrary receiving
|
||||
// MTAs commonly present certs that don't chain cleanly (matches the Python code,
|
||||
// which never configures certificate verification for this opportunistic hop).
|
||||
// requirement. Certificate verification is real (ServerName set, no blanket
|
||||
// InsecureSkipVerify) unless this is the unverified-fallback attempt — see
|
||||
// trySend above for why/when that happens.
|
||||
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 !skipVerify {
|
||||
return "", fmt.Errorf("%w: %v", errStartTLSVerifyFailed, 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
|
||||
}
|
||||
|
||||
// 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,
|
||||
// fall back to extension sniffing, then a small fixed extension map.
|
||||
func getContentType(partContentType, filename string) string {
|
||||
|
||||
@@ -91,3 +91,47 @@ func TestExtractMessageIDKeepsMatchingHostname(t *testing.T) {
|
||||
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
|
||||
// internal/mailstore's own tests for that), so disable rejection entirely here.
|
||||
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()
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if err := send(t); err != nil {
|
||||
@@ -96,7 +96,7 @@ func TestAllowListBypassesSpamQuarantine(t *testing.T) {
|
||||
// but decided unconditionally rather than computed.
|
||||
func TestJunkListQuarantinesWithoutRejecting(t *testing.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)
|
||||
}
|
||||
addr := startTestServer(t, backend)
|
||||
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// STARTTLS is never offered, and AUTH is advertised and usable in plaintext
|
||||
// (auth_require_tls=False).
|
||||
@@ -38,6 +49,7 @@ func NewPlainServer(backend *Backend, addr, banner string) *smtp.Server {
|
||||
s.AllowInsecureAuth = true
|
||||
s.ReadTimeout = 5 * time.Minute
|
||||
s.WriteTimeout = 5 * time.Minute
|
||||
applyLimits(s, backend.Cfg)
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -54,5 +66,6 @@ func NewTLSServer(backend *Backend, addr, banner string, tlsConfig *tls.Config)
|
||||
s.AllowInsecureAuth = true
|
||||
s.ReadTimeout = 5 * time.Minute
|
||||
s.WriteTimeout = 5 * time.Minute
|
||||
applyLimits(s, backend.Cfg)
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -215,3 +215,67 @@ func TestSenderCannotSpoofOtherAddress(t *testing.T) {
|
||||
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/mailstore"
|
||||
"mailgoserver/internal/mailview"
|
||||
"mailgoserver/internal/notify"
|
||||
"mailgoserver/internal/relay"
|
||||
"mailgoserver/internal/toolbox"
|
||||
)
|
||||
@@ -33,6 +34,11 @@ type Backend struct {
|
||||
Logger *toolbox.Logger
|
||||
HeloHostname 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) {
|
||||
@@ -246,6 +252,23 @@ func (s *Session) Data(r io.Reader) error {
|
||||
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)
|
||||
toHeader := rebuiltHeaders["to"]
|
||||
ccHeader := rebuiltHeaders["cc"]
|
||||
@@ -328,7 +351,16 @@ func (s *Session) Data(r io.Reader) error {
|
||||
|
||||
var results []relay.Result
|
||||
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 {
|
||||
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))
|
||||
for i, rcpt := range rcpts {
|
||||
mbox := s.localMailboxes[strings.ToLower(rcpt)]
|
||||
folder := "INBOX"
|
||||
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
|
||||
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
|
||||
// "Mark as Junk" action — internal/webui's webmailMarkAsJunk) also bypasses
|
||||
// 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
|
||||
// (IsBlocked, checked at RCPT time — see Rcpt()) — those are different
|
||||
// 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"
|
||||
spamGated = true
|
||||
} else {
|
||||
folder = "Junk"
|
||||
spamGated = true
|
||||
} 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
|
||||
hardReject := false
|
||||
if rspamdEnabled {
|
||||
if score, rAction, err := mailstore.CheckRspamd(rspamdURL, []byte(signedContent), s.mailFrom, rcpt); err == nil {
|
||||
// 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,
|
||||
// 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 {
|
||||
folder = "Junk"
|
||||
spamGated = true
|
||||
tags = append(tags, "SPAM")
|
||||
}
|
||||
}
|
||||
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
|
||||
// in their INBOX — a quarantined message skips them entirely and always lands
|
||||
// in Junk, rather than a rule accidentally routing spam back into view.
|
||||
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{
|
||||
"from": s.mailFrom, "to": rcpt, "subject": subject,
|
||||
"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"})
|
||||
continue
|
||||
}
|
||||
if action.AutoReply {
|
||||
s.sendAutoReply(mbox, action.AutoReplySubject, action.AutoReplyBody, messageID)
|
||||
}
|
||||
if action.Folder != "" {
|
||||
folder = action.Folder
|
||||
}
|
||||
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 {
|
||||
errCode, errMsg := "450", err.Error()
|
||||
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.Notify.Publish(mbox.ID, folder)
|
||||
serverResponse := "Delivered to local mailbox"
|
||||
if spamGated {
|
||||
serverResponse = "Quarantined to Junk folder"
|
||||
@@ -563,6 +699,78 @@ func (s *Session) deliverLocally(rcpts, types []string, signedContent, messageID
|
||||
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 {
|
||||
for _, v := range list {
|
||||
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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
"mailgoserver/internal/db"
|
||||
"mailgoserver/internal/dkim"
|
||||
"mailgoserver/internal/dnspublish"
|
||||
)
|
||||
|
||||
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().
|
||||
@@ -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"})
|
||||
return
|
||||
}
|
||||
ok, err := a.DKIM.GenerateDKIMKeypair(domain, selector, true)
|
||||
ok, publishErr, err := a.DKIM.GenerateAndPublish(domain, selector, true)
|
||||
if err != nil || !ok {
|
||||
writeJSON(w, http.StatusInternalServerError, M{"success": false, "message": "Failed to create DKIM key"})
|
||||
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.
|
||||
@@ -125,11 +143,15 @@ func (a *App) regenerateDKIM(w http.ResponseWriter, r *http.Request) {
|
||||
a.dkimActionFailed(w, r, "Failed to regenerate DKIM key")
|
||||
return
|
||||
}
|
||||
ok, err := a.DKIM.GenerateDKIMKeypair(dom.DomainName, selector, true)
|
||||
ok, publishErr, err := a.DKIM.GenerateAndPublish(dom.DomainName, selector, true)
|
||||
if err != nil || !ok {
|
||||
a.dkimActionFailed(w, r, "Failed to regenerate DKIM key")
|
||||
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) {
|
||||
publicIP := getPublicIP(a.Cfg)
|
||||
@@ -144,14 +166,18 @@ func (a *App) regenerateDKIM(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
newKey, _ := a.DB.GetActiveDKIMKeyByDomainID(domainID)
|
||||
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,
|
||||
"recommended_spf": generateSPFRecord(publicIP, existingSPF),
|
||||
"public_ip": publicIP, "domain": dom.DomainName,
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -170,7 +196,8 @@ func (a *App) editDKIMForm(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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_-]+$`)
|
||||
@@ -261,6 +288,135 @@ func (a *App) removeDKIM(w http.ResponseWriter, r *http.Request) {
|
||||
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().
|
||||
func (a *App) checkDKIMDNS(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
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.
|
||||
@@ -146,6 +151,43 @@ func (a *App) editDomain(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, Prefix+"/domains", http.StatusFound)
|
||||
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")
|
||||
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)
|
||||
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")
|
||||
} else {
|
||||
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 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
|
||||
|
||||
@@ -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