From 7fd2fbe5870fcd74a64ed43ae92f41507472d2ba Mon Sep 17 00:00:00 2001 From: nahakubuilder Date: Thu, 20 Aug 2026 15:55:40 +0100 Subject: [PATCH] polishing and DKIM,caldav,cardav --- .gitignore | 1 + go.mod | 32 +- go.sum | 46 +- internal/abuseguard/abuseguard.go | 82 ++- internal/abuseguard/abuseguard_test.go | 67 +- internal/backup/domain.go | 343 ++++++++++ internal/backup/domain_test.go | 148 +++++ internal/backup/mailbox_export.go | 99 +++ internal/backup/mailbox_export_test.go | 70 +++ internal/backup/scheduled.go | 165 +++++ internal/backup/scheduled_test.go | 96 +++ internal/backup/server.go | 345 ++++++++++ internal/backup/server_test.go | 187 ++++++ internal/config/config.go | 49 ++ internal/db/allowblock_junk_migration_test.go | 2 +- internal/db/crud_calendar_events.go | 177 ++++++ internal/db/crud_calendar_events_test.go | 133 ++++ .../crud_domain_dkim_message_actions_test.go | 122 ++++ internal/db/crud_domain_dns_credentials.go | 67 ++ .../db/crud_domain_dns_credentials_test.go | 69 ++ internal/db/crud_domains.go | 60 +- internal/db/crud_global_dkim_key.go | 43 ++ internal/db/crud_global_dkim_key_test.go | 52 ++ internal/db/crud_ip_blacklist.go | 2 +- internal/db/crud_mailbox_autoreply.go | 19 + internal/db/crud_mailbox_contacts.go | 71 ++- internal/db/crud_mailbox_lists.go | 36 +- internal/db/crud_mailbox_lists_test.go | 93 +++ internal/db/crud_mailbox_sessions.go | 52 +- internal/db/crud_mailbox_sessions_test.go | 86 +++ internal/db/crud_mailboxes.go | 41 +- internal/db/crud_monitoring.go | 165 +++++ internal/db/crud_monitoring_test.go | 131 ++++ internal/db/mailbox_models.go | 62 +- internal/db/models.go | 21 + internal/db/queries.go | 13 +- internal/db/schema.go | 224 ++++++- internal/dkim/dkim.go | 114 +++- internal/dkim/generate_and_publish_test.go | 166 +++++ internal/dkim/global_key_test.go | 124 ++++ internal/dnspublish/cloudflare.go | 95 +++ internal/dnspublish/cloudflare_test.go | 106 ++++ internal/dnspublish/digitalocean.go | 103 +++ internal/dnspublish/digitalocean_test.go | 98 +++ internal/dnspublish/dnspublish.go | 59 ++ internal/dnspublish/gcloud.go | 75 +++ internal/dnspublish/gcloud_test.go | 125 ++++ internal/dnspublish/route53.go | 76 +++ internal/dnspublish/route53_test.go | 106 ++++ internal/imapserver/backend.go | 3 + internal/imapserver/imapserver_test.go | 187 ++++++ internal/imapserver/session.go | 122 +++- internal/mailstore/clamav.go | 70 +++ internal/mailstore/clamav_test.go | 114 ++++ internal/mailstore/dmarc.go | 93 +++ internal/mailstore/dmarc_test.go | 59 ++ internal/mailstore/resolve.go | 61 +- internal/mailstore/resolve_test.go | 78 +++ internal/mailstore/rules.go | 20 + internal/mailstore/store.go | 26 +- internal/notify/notify.go | 77 +++ internal/notify/notify_test.go | 71 +++ internal/relay/mtasts.go | 49 ++ internal/relay/mtasts_test.go | 33 + internal/relay/relay.go | 79 ++- internal/relay/tls_test.go | 220 +++++++ internal/smtpserver/domain_ratelimit_test.go | 101 +++ internal/smtpserver/headers.go | 34 + internal/smtpserver/headers_test.go | 44 ++ internal/smtpserver/mailbox_autoreply_test.go | 102 +++ internal/smtpserver/mailbox_delivery_test.go | 12 + internal/smtpserver/mailbox_dmarc_test.go | 45 ++ internal/smtpserver/mailbox_forward_test.go | 125 ++++ internal/smtpserver/mailbox_rules_test.go | 6 +- internal/smtpserver/mailbox_spam_test.go | 105 ++++ internal/smtpserver/server.go | 13 + internal/smtpserver/server_test.go | 64 ++ internal/smtpserver/session.go | 244 +++++++- internal/smtpserver/virus_scan_test.go | 122 ++++ internal/webui/backups.go | 144 +++++ internal/webui/backups_test.go | 219 +++++++ internal/webui/caldav.go | 342 ++++++++++ internal/webui/caldav_test.go | 277 ++++++++ internal/webui/calendar_recurrence.go | 41 ++ internal/webui/calendar_recurrence_test.go | 57 ++ internal/webui/carddav.go | 310 +++++++++ internal/webui/carddav_test.go | 251 ++++++++ internal/webui/dav_enablement_test.go | 101 +++ internal/webui/dkim.go | 170 ++++- internal/webui/dkim_age_test.go | 41 ++ internal/webui/dkim_dns_automation_test.go | 117 ++++ internal/webui/dkim_global_test.go | 130 ++++ internal/webui/domain_backup.go | 78 +++ internal/webui/domain_backup_test.go | 163 +++++ internal/webui/domain_catchall_test.go | 88 +++ internal/webui/domains.go | 44 +- internal/webui/mail_stream_test.go | 78 +++ internal/webui/mailbox_lists.go | 4 +- internal/webui/mailbox_rules.go | 2 +- internal/webui/monitoring.go | 49 ++ internal/webui/monitoring_test.go | 74 +++ internal/webui/mta_sts.go | 39 ++ internal/webui/mta_sts_test.go | 66 ++ internal/webui/render.go | 43 +- internal/webui/static/img/favicon-webmail.ico | Bin 0 -> 4618 bytes .../webui/static/img/favicon-webserver.ico | Bin 0 -> 6058 bytes internal/webui/templates/backups.html | 92 +++ internal/webui/templates/base.html | 1 + internal/webui/templates/dkim.html | 32 +- internal/webui/templates/edit_dkim.html | 94 +++ internal/webui/templates/edit_domain.html | 87 +++ internal/webui/templates/login.html | 1 + internal/webui/templates/login_mfa.html | 1 + .../webui/templates/mfa_setup_required.html | 1 + internal/webui/templates/monitoring.html | 107 ++++ internal/webui/templates/settings.html | 89 +++ internal/webui/templates/sidebar_email.html | 12 + internal/webui/templates/totp_setup.html | 1 + internal/webui/templates/webmail_account.html | 87 +++ .../webui/templates/webmail_blocklist.html | 20 +- .../webui/templates/webmail_calendar.html | 589 ++++++++++++++++++ internal/webui/templates/webmail_certs.html | 2 + internal/webui/templates/webmail_compose.html | 1 + .../webui/templates/webmail_contacts.html | 19 +- .../templates/webmail_delete_account.html | 63 ++ internal/webui/templates/webmail_folder.html | 11 + internal/webui/templates/webmail_login.html | 1 + .../webui/templates/webmail_login_mfa.html | 1 + internal/webui/templates/webmail_message.html | 2 + .../templates/webmail_mfa_setup_required.html | 1 + internal/webui/templates/webmail_rules.html | 11 + .../templates/webmail_settings_chrome.html | 10 + .../webui/templates/webmail_signatures.html | 2 + .../webui/templates/webmail_totp_setup.html | 1 + internal/webui/virus_scan_settings_test.go | 60 ++ internal/webui/webmail_account.go | 79 ++- internal/webui/webmail_blocklist.go | 18 +- internal/webui/webmail_blocklist_test.go | 55 +- internal/webui/webmail_calendar.go | 232 +++++++ internal/webui/webmail_calendar_test.go | 333 ++++++++++ internal/webui/webmail_contacts.go | 9 +- internal/webui/webmail_contacts_test.go | 36 ++ internal/webui/webmail_forwarding_test.go | 56 ++ internal/webui/webmail_gdpr.go | 72 +++ internal/webui/webmail_gdpr_test.go | 114 ++++ internal/webui/webmail_login.go | 4 +- internal/webui/webmail_mail.go | 52 +- internal/webui/webmail_rules.go | 11 + internal/webui/webmail_rules_test.go | 44 ++ internal/webui/webmail_session_revoke_test.go | 79 +++ internal/webui/webmail_test.go | 2 +- internal/webui/webmail_webauthn.go | 2 +- internal/webui/webui.go | 58 ++ internal/webui/webui_test.go | 2 + main.go | 268 +++++++- 155 files changed, 12759 insertions(+), 191 deletions(-) create mode 100644 internal/backup/domain.go create mode 100644 internal/backup/domain_test.go create mode 100644 internal/backup/mailbox_export.go create mode 100644 internal/backup/mailbox_export_test.go create mode 100644 internal/backup/scheduled.go create mode 100644 internal/backup/scheduled_test.go create mode 100644 internal/backup/server.go create mode 100644 internal/backup/server_test.go create mode 100644 internal/db/crud_calendar_events.go create mode 100644 internal/db/crud_calendar_events_test.go create mode 100644 internal/db/crud_domain_dkim_message_actions_test.go create mode 100644 internal/db/crud_domain_dns_credentials.go create mode 100644 internal/db/crud_domain_dns_credentials_test.go create mode 100644 internal/db/crud_global_dkim_key.go create mode 100644 internal/db/crud_global_dkim_key_test.go create mode 100644 internal/db/crud_mailbox_autoreply.go create mode 100644 internal/db/crud_mailbox_lists_test.go create mode 100644 internal/db/crud_mailbox_sessions_test.go create mode 100644 internal/db/crud_monitoring.go create mode 100644 internal/db/crud_monitoring_test.go create mode 100644 internal/dkim/generate_and_publish_test.go create mode 100644 internal/dkim/global_key_test.go create mode 100644 internal/dnspublish/cloudflare.go create mode 100644 internal/dnspublish/cloudflare_test.go create mode 100644 internal/dnspublish/digitalocean.go create mode 100644 internal/dnspublish/digitalocean_test.go create mode 100644 internal/dnspublish/dnspublish.go create mode 100644 internal/dnspublish/gcloud.go create mode 100644 internal/dnspublish/gcloud_test.go create mode 100644 internal/dnspublish/route53.go create mode 100644 internal/dnspublish/route53_test.go create mode 100644 internal/mailstore/clamav.go create mode 100644 internal/mailstore/clamav_test.go create mode 100644 internal/mailstore/dmarc.go create mode 100644 internal/mailstore/dmarc_test.go create mode 100644 internal/mailstore/resolve_test.go create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/notify_test.go create mode 100644 internal/relay/mtasts.go create mode 100644 internal/relay/mtasts_test.go create mode 100644 internal/relay/tls_test.go create mode 100644 internal/smtpserver/domain_ratelimit_test.go create mode 100644 internal/smtpserver/mailbox_autoreply_test.go create mode 100644 internal/smtpserver/mailbox_dmarc_test.go create mode 100644 internal/smtpserver/mailbox_forward_test.go create mode 100644 internal/smtpserver/virus_scan_test.go create mode 100644 internal/webui/backups.go create mode 100644 internal/webui/backups_test.go create mode 100644 internal/webui/caldav.go create mode 100644 internal/webui/caldav_test.go create mode 100644 internal/webui/calendar_recurrence.go create mode 100644 internal/webui/calendar_recurrence_test.go create mode 100644 internal/webui/carddav.go create mode 100644 internal/webui/carddav_test.go create mode 100644 internal/webui/dav_enablement_test.go create mode 100644 internal/webui/dkim_age_test.go create mode 100644 internal/webui/dkim_dns_automation_test.go create mode 100644 internal/webui/dkim_global_test.go create mode 100644 internal/webui/domain_backup.go create mode 100644 internal/webui/domain_backup_test.go create mode 100644 internal/webui/domain_catchall_test.go create mode 100644 internal/webui/mail_stream_test.go create mode 100644 internal/webui/monitoring.go create mode 100644 internal/webui/monitoring_test.go create mode 100644 internal/webui/mta_sts.go create mode 100644 internal/webui/mta_sts_test.go create mode 100644 internal/webui/static/img/favicon-webmail.ico create mode 100644 internal/webui/static/img/favicon-webserver.ico create mode 100644 internal/webui/templates/backups.html create mode 100644 internal/webui/templates/monitoring.html create mode 100644 internal/webui/templates/webmail_calendar.html create mode 100644 internal/webui/templates/webmail_delete_account.html create mode 100644 internal/webui/virus_scan_settings_test.go create mode 100644 internal/webui/webmail_calendar.go create mode 100644 internal/webui/webmail_calendar_test.go create mode 100644 internal/webui/webmail_forwarding_test.go create mode 100644 internal/webui/webmail_gdpr.go create mode 100644 internal/webui/webmail_gdpr_test.go create mode 100644 internal/webui/webmail_session_revoke_test.go diff --git a/.gitignore b/.gitignore index fc5175b..d0af6e0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ settings.ini tests/ .claude/ +CLAUDE.md *.sum \ No newline at end of file diff --git a/go.mod b/go.mod index f47c99f..49d8d02 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 20c9175..9c1f4f1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/abuseguard/abuseguard.go b/internal/abuseguard/abuseguard.go index 1b39a6e..d7702cb 100644 --- a/internal/abuseguard/abuseguard.go +++ b/internal/abuseguard/abuseguard.go @@ -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() +} diff --git a/internal/abuseguard/abuseguard_test.go b/internal/abuseguard/abuseguard_test.go index f3eb715..1b35457 100644 --- a/internal/abuseguard/abuseguard_test.go +++ b/internal/abuseguard/abuseguard_test.go @@ -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 +} diff --git a/internal/backup/domain.go b/internal/backup/domain.go new file mode 100644 index 0000000..a0fabda --- /dev/null +++ b/internal/backup/domain.go @@ -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 +} diff --git a/internal/backup/domain_test.go b/internal/backup/domain_test.go new file mode 100644 index 0000000..b40b69b --- /dev/null +++ b/internal/backup/domain_test.go @@ -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"), "", "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", "

Best,
Me

"); 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") + } +} diff --git a/internal/backup/mailbox_export.go b/internal/backup/mailbox_export.go new file mode 100644 index 0000000..2a895f9 --- /dev/null +++ b/internal/backup/mailbox_export.go @@ -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) +} diff --git a/internal/backup/mailbox_export_test.go b/internal/backup/mailbox_export_test.go new file mode 100644 index 0000000..f70ab8a --- /dev/null +++ b/internal/backup/mailbox_export_test.go @@ -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") + } +} diff --git a/internal/backup/scheduled.go b/internal/backup/scheduled.go new file mode 100644 index 0000000..754d4b2 --- /dev/null +++ b/internal/backup/scheduled.go @@ -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 +} diff --git a/internal/backup/scheduled_test.go b/internal/backup/scheduled_test.go new file mode 100644 index 0000000..42ab343 --- /dev/null +++ b/internal/backup/scheduled_test.go @@ -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) + } +} diff --git a/internal/backup/server.go b/internal/backup/server.go new file mode 100644 index 0000000..9349671 --- /dev/null +++ b/internal/backup/server.go @@ -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 + } + } + } +} diff --git a/internal/backup/server_test.go b/internal/backup/server_test.go new file mode 100644 index 0000000..192e581 --- /dev/null +++ b/internal/backup/server_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a554aef..ddf1cdd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"}, diff --git a/internal/db/allowblock_junk_migration_test.go b/internal/db/allowblock_junk_migration_test.go index 76da7d1..28b1980 100644 --- a/internal/db/allowblock_junk_migration_test.go +++ b/internal/db/allowblock_junk_migration_test.go @@ -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) } diff --git a/internal/db/crud_calendar_events.go b/internal/db/crud_calendar_events.go new file mode 100644 index 0000000..7c86e33 --- /dev/null +++ b/internal/db/crud_calendar_events.go @@ -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) +} diff --git a/internal/db/crud_calendar_events_test.go b/internal/db/crud_calendar_events_test.go new file mode 100644 index 0000000..8616866 --- /dev/null +++ b/internal/db/crud_calendar_events_test.go @@ -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") + } +} diff --git a/internal/db/crud_domain_dkim_message_actions_test.go b/internal/db/crud_domain_dkim_message_actions_test.go new file mode 100644 index 0000000..5415312 --- /dev/null +++ b/internal/db/crud_domain_dkim_message_actions_test.go @@ -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) + } +} diff --git a/internal/db/crud_domain_dns_credentials.go b/internal/db/crud_domain_dns_credentials.go new file mode 100644 index 0000000..d921c0a --- /dev/null +++ b/internal/db/crud_domain_dns_credentials.go @@ -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 +} diff --git a/internal/db/crud_domain_dns_credentials_test.go b/internal/db/crud_domain_dns_credentials_test.go new file mode 100644 index 0000000..b690784 --- /dev/null +++ b/internal/db/crud_domain_dns_credentials_test.go @@ -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) + } +} diff --git a/internal/db/crud_domains.go b/internal/db/crud_domains.go index ca22edd..8e4a329 100644 --- a/internal/db/crud_domains.go +++ b/internal/db/crud_domains.go @@ -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 + +
+ +
+ + + + +
+
Scheduled backups
+
+

Automatically writes an unencrypted whole-server backup straight to a folder on this machine (not the browser) on the interval below, and deletes the oldest ones once more than "keep" exist. Manual (the default) means nothing runs automatically — use "Run backup now" or the download button above whenever you want one.

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
+ +
+
Backups on disk ({{.backup_dir}})
+
+ {{if .scheduled}} + + + + {{range .scheduled}} + + + + + + + {{end}} + +
FileSizeWritten
{{.Name}}{{filesize .Size}}{{strftime "%Y-%m-%d %H:%M:%S" .ModTime}}
+ {{else}} +

No backups written to this folder yet.

+ {{end}} +
+
+ +
+
Restoring
+
+

Restore is a command-line operation, not a button here — it replaces every file under server_data/ wholesale, including the database this running process has open, which isn't safe to do to a live server from inside its own web UI. Stop the server, then run:

+
./mailgoserver -restore-server -in backup.tar.gz [-restore-passphrase "..."] [-force]
+

-force is required if server_data/ isn't already empty. -restore-passphrase is only needed for an archive that was downloaded with a passphrase set.

+
+
+ +{{end}} diff --git a/internal/webui/templates/base.html b/internal/webui/templates/base.html index 874ec79..f6d64d3 100644 --- a/internal/webui/templates/base.html +++ b/internal/webui/templates/base.html @@ -15,6 +15,7 @@ + + + + {{template "csrf_script" .}} + + +
+ {{range .flashes}} + + {{end}} +
+ +
+
+
+ + + +
+
+
+
+ + + + + + + + +
+ + +
+
+ +
+
+ + + +
+ + + + + + {{template "compose_widget" .}} + + + + + + +{{end}} diff --git a/internal/webui/templates/webmail_certs.html b/internal/webui/templates/webmail_certs.html index f33f33a..229452d 100644 --- a/internal/webui/templates/webmail_certs.html +++ b/internal/webui/templates/webmail_certs.html @@ -7,6 +7,7 @@ Settings - Webmail + + + + {{template "csrf_script" .}} + + +
+ {{range .flashes}} + + {{end}} +
+ +
+
+
Delete Account
+
+
+ This permanently deletes {{.mailbox.Email}} — every message, contact, calendar event, filter rule, signature, app password, and passkey. This cannot be undone. Consider downloading your data first. +
+
+
+ + +
+
+ + +
+
+ Cancel + +
+
+
+
+
+ + + +{{end}} diff --git a/internal/webui/templates/webmail_folder.html b/internal/webui/templates/webmail_folder.html index 4aa1e28..473275e 100644 --- a/internal/webui/templates/webmail_folder.html +++ b/internal/webui/templates/webmail_folder.html @@ -7,6 +7,7 @@ {{if .search_query}}Search: {{.search_query}}{{else}}{{.active_folder}}{{end}} - Webmail +