polishing and DKIM,caldav,cardav

This commit is contained in:
2026-08-20 15:55:40 +01:00
parent f02b344512
commit 7fd2fbe587
155 changed files with 12759 additions and 191 deletions
+1
View File
@@ -5,5 +5,6 @@ settings.ini
tests/
.claude/
CLAUDE.md
*.sum
+18 -14
View File
@@ -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
)
+34 -12
View File
@@ -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=
+67 -15
View File
@@ -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()
}
+66 -1
View File
@@ -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
}
+343
View File
@@ -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
}
+148
View File
@@ -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")
}
}
+99
View File
@@ -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)
}
+70
View File
@@ -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")
}
}
+165
View File
@@ -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
}
+96
View File
@@ -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)
}
}
+345
View File
@@ -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
}
}
}
}
+187
View File
@@ -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)
}
}
+49
View File
@@ -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)
}
+177
View File
@@ -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)
}
+133
View File
@@ -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)
}
}
+59 -1
View File
@@ -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) {
+43
View File
@@ -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()
}
+52
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+19
View File
@@ -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
}
+65 -6
View File
@@ -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
}
+31 -5
View File
@@ -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).
+93
View File
@@ -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)
}
}
}
+47 -5
View File
@@ -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) {
+86
View File
@@ -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))
}
}
+36 -5
View File
@@ -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
+165
View File
@@ -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()
}
+131
View File
@@ -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)
}
}
+56 -6
View File
@@ -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
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+166
View File
@@ -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)
}
}
+124
View File
@@ -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")
}
}
+95
View File
@@ -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
}
+106
View File
@@ -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")
}
}
+103
View File
@@ -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
}
+98
View File
@@ -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)
}
}
+59
View File
@@ -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)
}
}
+75
View File
@@ -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
}
+125
View File
@@ -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)
}
}
+76
View File
@@ -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
}
+106
View File
@@ -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")
}
}
+3
View File
@@ -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 {
+187
View File
@@ -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
View File
@@ -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
+70
View File
@@ -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
}
}
+114
View File
@@ -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])
}
}
}
+93
View File
@@ -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
}
+59
View File
@@ -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)
}
}
+55 -6
View File
@@ -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
}
+78
View File
@@ -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)
}
}
+20
View File
@@ -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
+22 -4
View File
@@ -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
+77
View File
@@ -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:
}
}
}
+71
View File
@@ -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")
}
}
+49
View File
@@ -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
}
+33
View File
@@ -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
View File
@@ -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
}
}
+220
View File
@@ -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)
}
}
+34
View File
@@ -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 {
+44
View File
@@ -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)
+45
View File
@@ -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)
}
}
+125
View File
@@ -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)
}
}
+3 -3
View File
@@ -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)
+105
View File
@@ -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")
}
+13
View File
@@ -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
}
+64
View File
@@ -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
View File
@@ -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 {
+122
View File
@@ -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)
}
}
+144
View File
@@ -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.
}
}
+219
View File
@@ -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")
}
}
+342
View File
@@ -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))
}
+277
View File
@@ -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)
}
}
+41
View File
@@ -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)
}
}
+310
View File
@@ -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))
})
}
+251
View File
@@ -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
}
+101
View File
@@ -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
View File
@@ -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")
+41
View File
@@ -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())
}
}
+117
View File
@@ -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)
}
}
+130
View File
@@ -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")
}
}
+78
View File
@@ -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)
}
+163
View File
@@ -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)
}
}
+88
View File
@@ -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)
}
}
+43 -1
View File
@@ -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)
}
+78
View File
@@ -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")
}
}
+3 -1
View File
@@ -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")
+1 -1
View File
@@ -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
+49
View File
@@ -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