From 2d3d726f058067bc0be94d8a7c9f8b0a6cbcdae5 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 25 Jan 2025 20:39:20 +0100 Subject: [PATCH] add config options to disable a domain and to disable logins for an account to facilitate migrations from/to other mail setups. a domain can be added in "disabled" mode (or can be disabled/enabled later on). you can configure a disabled domain, but incoming/outgoing messages involving the domain are rejected with temporary error codes (as this may occur during a migration, remote servers will try again, hopefully to the correct machine or after this machine has been configured correctly). also, no acme tls certs will be requested for disabled domains (the autoconfig/mta-sts dns records may still point to the current/previous machine). accounts with addresses at disabled domains can still login, unless logins are disabled for their accounts. an account now has an option to disable logins. you can specify an error message to show. this will be shown in smtp, imap and the web interfaces. it could contain a message about migrations, and possibly a URL to a page with information about how to migrate. incoming/outgoing email involving accounts with login disabled are still accepted/delivered as normal (unless the domain involved in the messages is disabled too). account operations by the admin, such as importing/exporting messages still works. in the admin web interface, listings of domains/accounts show if they are disabled. domains & accounts can be enabled/disabled through the config file, cli commands and admin web interface. for issue #175 by RobSlgm --- admin/admin.go | 6 +- backup.go | 2 +- config/config.go | 2 + config/doc.go | 19 +++++ ctl.go | 93 ++++++++++++++++++++--- ctl_test.go | 20 ++++- dmarcdb/eval.go | 2 +- doc.go | 51 ++++++++++++- gentestdata.go | 6 +- http/web.go | 4 +- imapserver/authenticate_test.go | 24 +++++- imapserver/fuzz_test.go | 2 +- imapserver/server.go | 47 ++++++++++-- imapserver/server_test.go | 2 +- import.go | 2 +- localserve.go | 2 +- main.go | 120 ++++++++++++++++++++++++++++-- metrics/auth.go | 2 +- mox-/config.go | 38 ++++++++++ mox-/dkimsign.go | 4 + mox-/lookup.go | 21 ++++-- queue/direct.go | 2 +- queue/dsn.go | 4 +- queue/hook_test.go | 6 +- queue/queue.go | 10 +++ queue/queue_test.go | 2 +- quickstart.go | 2 +- serve_unix.go | 2 +- smtp/codes.go | 2 + smtpserver/alias_test.go | 4 +- smtpserver/fuzz_test.go | 2 +- smtpserver/server.go | 61 +++++++++++---- smtpserver/server_test.go | 70 ++++++++++++++++- store/account.go | 48 ++++++++---- store/account_test.go | 12 +-- store/export_test.go | 2 +- store/session.go | 38 +++++----- store/threads_test.go | 4 +- testdata/httpaccount/domains.conf | 5 ++ testdata/imap/domains.conf | 5 ++ testdata/smtp/domains.conf | 8 ++ testdata/webapisrv/domains.conf | 8 ++ testdata/webmail/domains.conf | 8 ++ tlsrptsend/send.go | 4 +- webaccount/account.go | 6 +- webaccount/account.js | 2 +- webaccount/account_test.go | 17 ++++- webaccount/api.json | 7 ++ webaccount/api.ts | 3 +- webaccount/import.go | 2 +- webadmin/admin.go | 62 ++++++++++----- webadmin/admin.js | 100 ++++++++++++++++++------- webadmin/admin.ts | 86 +++++++++++++++++---- webadmin/api.json | 73 +++++++++++++++++- webadmin/api.ts | 48 ++++++++---- webapisrv/server.go | 21 ++++-- webapisrv/server_test.go | 22 +++++- webauth/accounts.go | 12 +-- webauth/admin.go | 8 +- webauth/webauth.go | 11 ++- webmail/api.go | 8 +- webmail/api_test.go | 27 ++++++- webmail/view.go | 4 +- webmail/view_test.go | 2 +- webmail/webmail.go | 6 +- webmail/webmail_test.go | 2 +- webops/export.go | 2 +- 67 files changed, 1078 insertions(+), 231 deletions(-) diff --git a/admin/admin.go b/admin/admin.go index 96746c4..207f8ef 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -420,11 +420,12 @@ func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) { // accountName is used for DMARC/TLS report and potentially for the postmaster address. // If the account does not exist, it is created with localpart. Localpart must be // set only if the account does not yet exist. -func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, localpart smtp.Localpart) (rerr error) { +func DomainAdd(ctx context.Context, disabled bool, domain dns.Domain, accountName string, localpart smtp.Localpart) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { log.Errorx("adding domain", rerr, + slog.Any("disabled", disabled), slog.Any("domain", domain), slog.String("account", accountName), slog.Any("localpart", localpart)) @@ -465,6 +466,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local log.Check(err, "cleaning up file after error", slog.String("path", f)) } }() + confDomain.Disabled = disabled if _, ok := c.Accounts[accountName]; ok && localpart != "" { return fmt.Errorf("%w: account already exists (leave localpart empty when using an existing account)", ErrRequest) @@ -491,7 +493,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %w", err) } - log.Info("domain added", slog.Any("domain", domain)) + log.Info("domain added", slog.Any("domain", domain), slog.Bool("disabled", disabled)) cleanupFiles = nil // All good, don't cleanup. return nil } diff --git a/backup.go b/backup.go index f4db8ec..8baa1ac 100644 --- a/backup.go +++ b/backup.go @@ -619,7 +619,7 @@ func backupctl(ctx context.Context, ctl *ctl) { // account directories when handling "all other files" below. accounts := map[string]struct{}{} for _, accName := range mox.Conf.Accounts() { - acc, err := store.OpenAccount(ctl.log, accName) + acc, err := store.OpenAccount(ctl.log, accName, false) if err != nil { xerrx("opening account for copying (will try to copy as regular files later)", err, slog.String("account", accName)) continue diff --git a/config/config.go b/config/config.go index aa6bc2a..6cee9a2 100644 --- a/config/config.go +++ b/config/config.go @@ -279,6 +279,7 @@ type TransportDirect struct { } type Domain struct { + Disabled bool `sconf:"optional" sconf-doc:"Disabled domains can be useful during/before migrations. Domains that are disabled can still be configured like normal, including adding addresses using the domain to accounts. However, disabled domains: 1. Do not try to fetch ACME certificates. TLS connections to host names involving the email domain will fail. A TLS certificate for the hostname (that wil be used as MX) itself will be requested. 2. Incoming deliveries over SMTP are rejected with a temporary error '450 4.2.1 recipient domain temporarily disabled'. 3. Submissions over SMTP using an (envelope) SMTP MAIL FROM address or message 'From' address of a disabled domain will be rejected with a temporary error '451 4.3.0 sender domain temporarily disabled'. Note that accounts with addresses at disabled domains can still log in and read email (unless the account itself is disabled)."` Description string `sconf:"optional" sconf-doc:"Free-form description of domain."` ClientSettingsDomain string `sconf:"optional" sconf-doc:"Hostname for client settings instead of the mail server hostname. E.g. mail.. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations. Unicode name."` LocalpartCatchallSeparator string `sconf:"optional" sconf-doc:"If not empty, only the string before the separator is used to for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com."` @@ -420,6 +421,7 @@ type Account struct { KeepRetiredMessagePeriod time.Duration `sconf:"optional" sconf-doc:"Period to keep messages retired from the queue (delivered or failed) around. Keeping retired messages is useful for maintaining the suppression list for transactional email, for matching incoming DSNs to sent messages, and for debugging. The time at which to clean up (remove) is calculated at retire time. E.g. 168h (1 week)."` KeepRetiredWebhookPeriod time.Duration `sconf:"optional" sconf-doc:"Period to keep webhooks retired from the queue (delivered or failed) around. Useful for debugging. The time at which to clean up (remove) is calculated at retire time. E.g. 168h (1 week)."` + LoginDisabled string `sconf:"optional" sconf-doc:"If non-empty, login attempts on all protocols (e.g. SMTP/IMAP, web interfaces) is rejected with this error message. Useful during migrations. Incoming deliveries for addresses of this account are still accepted as normal."` Domain string `sconf-doc:"Default domain for account. Deprecated behaviour: If a destination is not a full address but only a localpart, this domain is added to form a full address."` Description string `sconf:"optional" sconf-doc:"Free form description, e.g. full name or alternative contact info."` FullName string `sconf:"optional" sconf-doc:"Full name, to use in message From header when composing messages in webmail. Can be overridden per destination."` diff --git a/config/doc.go b/config/doc.go index d9eb03e..5bc5529 100644 --- a/config/doc.go +++ b/config/doc.go @@ -760,6 +760,19 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. Domains: x: + # Disabled domains can be useful during/before migrations. Domains that are + # disabled can still be configured like normal, including adding addresses using + # the domain to accounts. However, disabled domains: 1. Do not try to fetch ACME + # certificates. TLS connections to host names involving the email domain will + # fail. A TLS certificate for the hostname (that wil be used as MX) itself will be + # requested. 2. Incoming deliveries over SMTP are rejected with a temporary error + # '450 4.2.1 recipient domain temporarily disabled'. 3. Submissions over SMTP + # using an (envelope) SMTP MAIL FROM address or message 'From' address of a + # disabled domain will be rejected with a temporary error '451 4.3.0 sender domain + # temporarily disabled'. Note that accounts with addresses at disabled domains can + # still log in and read email (unless the account itself is disabled). (optional) + Disabled: false + # Free-form description of domain. (optional) Description: @@ -1028,6 +1041,12 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. # retire time. E.g. 168h (1 week). (optional) KeepRetiredWebhookPeriod: 0s + # If non-empty, login attempts on all protocols (e.g. SMTP/IMAP, web interfaces) + # is rejected with this error message. Useful during migrations. Incoming + # deliveries for addresses of this account are still accepted as normal. + # (optional) + LoginDisabled: + # Default domain for account. Deprecated behaviour: If a destination is not a full # address but only a localpart, this domain is added to form a full address. Domain: diff --git a/ctl.go b/ctl.go index 19ba7a8..9d0f747 100644 --- a/ctl.go +++ b/ctl.go @@ -328,7 +328,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { */ to := ctl.xread() - a, addr, err := store.OpenEmail(log, to) + a, addr, err := store.OpenEmail(log, to, false) ctl.xcheck(err, "lookup destination address") msgFile, err := store.CreateMessageTemp(log, "ctl-deliver") @@ -367,7 +367,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { account := ctl.xread() pw := ctl.xread() - acc, err := store.OpenAccount(log, account) + acc, err := store.OpenAccount(log, account, false) ctl.xcheck(err, "open account") defer func() { if acc != nil { @@ -965,17 +965,28 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { case "domainadd": /* protocol: > "domainadd" + > disabled as "true" or "false" > domain > account > localpart < "ok" or error */ + var disabled bool + switch s := ctl.xread(); s { + case "true": + disabled = true + case "false": + disabled = false + default: + ctl.xcheck(fmt.Errorf("invalid value %q", s), "parsing disabled boolean") + } + domain := ctl.xread() account := ctl.xread() localpart := ctl.xread() d, err := dns.ParseDomain(domain) ctl.xcheck(err, "parsing domain") - err = admin.DomainAdd(ctx, d, account, smtp.Localpart(localpart)) + err = admin.DomainAdd(ctx, disabled, d, account, smtp.Localpart(localpart)) ctl.xcheck(err, "adding domain") ctl.xwriteok() @@ -992,6 +1003,30 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { ctl.xcheck(err, "removing domain") ctl.xwriteok() + case "domaindisabled": + /* protocol: + > "domaindisabled" + > "true" or "false" + > domain + < "ok" or error + */ + domain := ctl.xread() + var disabled bool + switch s := ctl.xread(); s { + case "true": + disabled = true + case "false": + disabled = false + default: + ctl.xerror("bad boolean value") + } + err := admin.DomainSave(ctx, domain, func(d *config.Domain) error { + d.Disabled = disabled + return nil + }) + ctl.xcheck(err, "saving domain") + ctl.xwriteok() + case "accountadd": /* protocol: > "accountadd" @@ -1016,6 +1051,46 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { ctl.xcheck(err, "removing account") ctl.xwriteok() + case "accountdisabled": + /* protocol: + > "accountdisabled" + > account + > message (if empty, then enabled) + < "ok" or error + */ + account := ctl.xread() + message := ctl.xread() + + acc, err := store.OpenAccount(log, account, false) + ctl.xcheck(err, "open account") + defer func() { + err := acc.Close() + log.Check(err, "closing account") + }() + + err = admin.AccountSave(ctx, account, func(acc *config.Account) { + acc.LoginDisabled = message + }) + ctl.xcheck(err, "saving account") + + err = acc.SessionsClear(ctx, ctl.log) + ctl.xcheck(err, "clearing active web sessions") + + ctl.xwriteok() + + case "accountenable": + /* protocol: + > "accountenable" + > account + < "ok" or error + */ + account := ctl.xread() + err := admin.AccountSave(ctx, account, func(acc *config.Account) { + acc.LoginDisabled = "" + }) + ctl.xcheck(err, "enabling account") + ctl.xwriteok() + case "tlspubkeylist": /* protocol: > "tlspubkeylist" @@ -1079,7 +1154,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { if name != "" { tlspubkey.Name = name } - acc, _, err := store.OpenEmail(ctl.log, loginAddress) + acc, _, err := store.OpenEmail(ctl.log, loginAddress, false) ctl.xcheck(err, "open account for address") defer func() { err := acc.Close() @@ -1341,7 +1416,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { account := ctl.xread() xretrain := func(name string) { - acc, err := store.OpenAccount(log, name) + acc, err := store.OpenAccount(log, name, false) ctl.xcheck(err, "open account") defer func() { if acc != nil { @@ -1417,7 +1492,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { < stream */ account := ctl.xread() - acc, err := store.OpenAccount(log, account) + acc, err := store.OpenAccount(log, account, false) ctl.xcheck(err, "open account") defer func() { if acc != nil { @@ -1492,7 +1567,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { const batchSize = 10000 xfixmsgsize := func(accName string) { - acc, err := store.OpenAccount(log, accName) + acc, err := store.OpenAccount(log, accName, false) ctl.xcheck(err, "open account") defer func() { err := acc.Close() @@ -1627,7 +1702,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { const batchSize = 100 xreparseAccount := func(accName string) { - acc, err := store.OpenAccount(log, accName) + acc, err := store.OpenAccount(log, accName, false) ctl.xcheck(err, "open account") defer func() { err := acc.Close() @@ -1703,7 +1778,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) { w := ctl.writer() xreassignThreads := func(accName string) { - acc, err := store.OpenAccount(log, accName) + acc, err := store.OpenAccount(log, accName, false) ctl.xcheck(err, "open account") defer func() { err := acc.Close() diff --git a/ctl_test.go b/ctl_test.go index afb17a3..cd73f92 100644 --- a/ctl_test.go +++ b/ctl_test.go @@ -270,7 +270,7 @@ func TestCtl(t *testing.T) { // "domainadd" testctl(func(ctl *ctl) { - ctlcmdConfigDomainAdd(ctl, dns.Domain{ASCII: "mox2.example"}, "mjl", "") + ctlcmdConfigDomainAdd(ctl, false, dns.Domain{ASCII: "mox2.example"}, "mjl", "") }) // "accountadd" @@ -297,11 +297,27 @@ func TestCtl(t *testing.T) { ctlcmdConfigAddressRemove(ctl, "mjl3@mox2.example") }) + // "accountdisabled" + testctl(func(ctl *ctl) { + ctlcmdConfigAccountDisabled(ctl, "mjl2", "testing") + }) + testctl(func(ctl *ctl) { + ctlcmdConfigAccountDisabled(ctl, "mjl2", "") + }) + // "accountrm" testctl(func(ctl *ctl) { ctlcmdConfigAccountRemove(ctl, "mjl2") }) + // "domaindisabled" + testctl(func(ctl *ctl) { + ctlcmdConfigDomainDisabled(ctl, dns.Domain{ASCII: "mox2.example"}, true) + }) + testctl(func(ctl *ctl) { + ctlcmdConfigDomainDisabled(ctl, dns.Domain{ASCII: "mox2.example"}, false) + }) + // "domainrm" testctl(func(ctl *ctl) { ctlcmdConfigDomainRemove(ctl, dns.Domain{ASCII: "mox2.example"}) @@ -412,7 +428,7 @@ func TestCtl(t *testing.T) { ctlcmdFixmsgsize(ctl, "mjl") }) testctl(func(ctl *ctl) { - acc, err := store.OpenAccount(ctl.log, "mjl") + acc, err := store.OpenAccount(ctl.log, "mjl", false) tcheck(t, err, "open account") defer func() { acc.Close() diff --git a/dmarcdb/eval.go b/dmarcdb/eval.go index 1740f31..b199a88 100644 --- a/dmarcdb/eval.go +++ b/dmarcdb/eval.go @@ -1021,7 +1021,7 @@ func dkimSign(ctx context.Context, log mlog.Log, fromAddr smtp.Address, smtputf8 for fd != zerodom { confDom, ok := mox.Conf.Domain(fd) selectors := mox.DKIMSelectors(confDom.DKIM) - if len(selectors) > 0 { + if len(selectors) > 0 && !confDom.Disabled { dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Localpart, fd, selectors, smtputf8, mf) if err != nil { log.Errorx("dkim-signing dmarc report, continuing without signature", err) diff --git a/doc.go b/doc.go index 69b1156..274c273 100644 --- a/doc.go +++ b/doc.go @@ -65,10 +65,14 @@ any parameters. Followed by the help and usage information for each command. mox config describe-static >mox.conf mox config account add account address mox config account rm account + mox config account disable account message + mox config account enable account mox config address add address account mox config address rm address - mox config domain add domain account [localpart] + mox config domain add [-disabled] domain account [localpart] mox config domain rm domain + mox config domain disable domain + mox config domain enable domain mox config tlspubkey list [account] mox config tlspubkey get fingerprint mox config tlspubkey add address [name] < cert.pem @@ -967,6 +971,26 @@ All data for the account will be removed. usage: mox config account rm account +# mox config account disable + +Disable login for an account, showing message to users when they try to login. + +Incoming email will still be accepted for the account, and queued email from the +account will still be delivered. No new login sessions are possible. + +Message must be non-empty, ascii-only without control characters including +newline, and maximum 256 characters because it is used in SMTP/IMAP. + + usage: mox config account disable account message + +# mox config account enable + +Enable login again for an account. + +Login attempts by the user no long result in an error message. + + usage: mox config account enable account + # mox config address add Adds an address to an account and reloads the configuration. @@ -992,7 +1016,13 @@ The account is used for the postmaster mailboxes the domain, including as DMARC TLS reporting. Localpart is the "username" at the domain for this account. If must be set if and only if account does not yet exist. - usage: mox config domain add domain account [localpart] +The domain can be created in disabled mode, preventing automatically requesting +TLS certificates with ACME, and rejecting incoming/outgoing messages involving +the domain, but allowing further configuration of the domain. + + usage: mox config domain add [-disabled] domain account [localpart] + -disabled + disable the new domain # mox config domain rm @@ -1003,6 +1033,23 @@ rejected. usage: mox config domain rm domain +# mox config domain disable + +Disable a domain and reload the configuration. + +This is a dangerous operation. Incoming/outgoing messages involving this domain +will be rejected. + + usage: mox config domain disable domain + +# mox config domain enable + +Enable a domain and reload the configuration. + +Incoming/outgoing messages involving this domain will be accepted again. + + usage: mox config domain enable domain + # mox config tlspubkey list List TLS public keys for TLS client certificate authentication. diff --git a/gentestdata.go b/gentestdata.go index 16d65f7..f3af437 100644 --- a/gentestdata.go +++ b/gentestdata.go @@ -245,7 +245,7 @@ Accounts: // Create three accounts. // First account without messages. - accTest0, err := store.OpenAccount(c.log, "test0") + accTest0, err := store.OpenAccount(c.log, "test0", false) xcheckf(err, "open account test0") err = accTest0.ThreadingWait(c.log) xcheckf(err, "wait for threading to finish") @@ -253,7 +253,7 @@ Accounts: xcheckf(err, "close account") // Second account with one message. - accTest1, err := store.OpenAccount(c.log, "test1") + accTest1, err := store.OpenAccount(c.log, "test1", false) xcheckf(err, "open account test1") err = accTest1.ThreadingWait(c.log) xcheckf(err, "wait for threading to finish") @@ -313,7 +313,7 @@ Accounts: xcheckf(err, "close account") // Third account with two messages and junkfilter. - accTest2, err := store.OpenAccount(c.log, "test2") + accTest2, err := store.OpenAccount(c.log, "test2", false) xcheckf(err, "open account test2") err = accTest2.ThreadingWait(c.log) xcheckf(err, "wait for threading to finish") diff --git a/http/web.go b/http/web.go index ddc2e93..6eaba7d 100644 --- a/http/web.go +++ b/http/web.go @@ -870,8 +870,8 @@ func portServes(name string, l config.Listener) map[int]*serve { for _, name := range mox.Conf.Domains() { if dom, err := dns.ParseDomain(name); err != nil { pkglog.Errorx("parsing domain from config", err) - } else if d, _ := mox.Conf.Domain(dom); d.ReportsOnly { - // Do not gather autoconfig name if we aren't accepting email for this domain. + } else if d, _ := mox.Conf.Domain(dom); d.ReportsOnly || d.Disabled { + // Do not gather autoconfig name if we aren't accepting email for this domain or when it is disabled. continue } diff --git a/imapserver/authenticate_test.go b/imapserver/authenticate_test.go index 46658f4..0cf914d 100644 --- a/imapserver/authenticate_test.go +++ b/imapserver/authenticate_test.go @@ -81,6 +81,28 @@ func TestAuthenticatePlain(t *testing.T) { tc.readstatus("ok") } +func TestLoginDisabled(t *testing.T) { + tc := start(t) + defer tc.close() + + acc, err := store.OpenAccount(pkglog, "disabled", false) + tcheck(t, err, "open account") + err = acc.SetPassword(pkglog, "test1234") + tcheck(t, err, "set password") + err = acc.Close() + tcheck(t, err, "close account") + + tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000disabled@mox.example\u0000test1234"))) + tc.xcode("") + tc.transactf("no", "authenticate plain %s", base64.StdEncoding.EncodeToString([]byte("\u0000disabled@mox.example\u0000bogus"))) + tc.xcode("AUTHENTICATIONFAILED") + + tc.transactf("no", "login disabled@mox.example test1234") + tc.xcode("") + tc.transactf("no", "login disabled@mox.example bogus") + tc.xcode("AUTHENTICATIONFAILED") +} + func TestAuthenticateSCRAMSHA1(t *testing.T) { testAuthenticateSCRAM(t, false, "SCRAM-SHA-1", sha1.New) } @@ -269,7 +291,7 @@ func TestAuthenticateTLSClientCert(t *testing.T) { tc.close() // No preauth, other mechanism must be for same account. - acc, err := store.OpenAccount(pkglog, "other") + acc, err := store.OpenAccount(pkglog, "other", false) tcheck(t, err, "open account") err = acc.SetPassword(pkglog, "test1234") tcheck(t, err, "set password") diff --git a/imapserver/fuzz_test.go b/imapserver/fuzz_test.go index 6e3d84f..af9e152 100644 --- a/imapserver/fuzz_test.go +++ b/imapserver/fuzz_test.go @@ -65,7 +65,7 @@ func FuzzServer(f *testing.F) { mox.MustLoadConfig(true, false) dataDir := mox.ConfigDirPath(mox.Conf.Static.DataDir) os.RemoveAll(dataDir) - acc, err := store.OpenAccount(log, "mjl") + acc, err := store.OpenAccount(log, "mjl", false) if err != nil { f.Fatalf("open account: %v", err) } diff --git a/imapserver/server.go b/imapserver/server.go index d106993..45521a9 100644 --- a/imapserver/server.go +++ b/imapserver/server.go @@ -874,9 +874,14 @@ func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error { return fmt.Errorf("looking up tls public key with fingerprint %s: %v", fp, err) } - // Verify account exists and still matches address. - acc, _, err := store.OpenEmail(c.log, pubKey.LoginAddress) + // Verify account exists and still matches address. We don't check for account + // login being disabled if preauth is disabled. In that case, sasl external auth + // will be done before credentials can be used, and login disabled will be checked + // then, where it will result in a more helpful error message. + checkLoginDisabled := !pubKey.NoIMAPPreauth + acc, _, err := store.OpenEmail(c.log, pubKey.LoginAddress, checkLoginDisabled) if err != nil { + // note: we cannot send a more helpful error message to the client. return fmt.Errorf("opening account for address %s for public key %s: %w", pubKey.LoginAddress, fp, err) } defer func() { @@ -1801,7 +1806,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { } var err error - account, err = store.OpenEmailAuth(c.log, username, password) + account, err = store.OpenEmailAuth(c.log, username, password, false) if err != nil { if errors.Is(err, store.ErrUnknownCredentials) { authResult = "badcreds" @@ -1829,11 +1834,18 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { username = t[0] c.log.Debug("cram-md5 auth", slog.String("address", username)) var err error - account, _, err = store.OpenEmail(c.log, username) + account, _, err = store.OpenEmail(c.log, username, false) if err != nil { if errors.Is(err, store.ErrUnknownCredentials) { + authResult = "badcreds" c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") + } else if errors.Is(err, store.ErrLoginDisabled) { + authResult = "logindisabled" + c.log.Info("account login disabled", slog.String("username", username)) + // No error code, we don't want to cause prompt for new password + // (AUTHENTICATIONFAILED) and don't want to trigger message suppression with ALERT. + xuserErrorf("%s", err) } xserverErrorf("looking up address: %v", err) } @@ -1905,7 +1917,8 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { } username = ss.Authentication c.log.Debug("scram auth", slog.String("authentication", username)) - account, _, err = store.OpenEmail(c.log, username) + // We check for login being disabled when finishing. + account, _, err = store.OpenEmail(c.log, username, false) if err != nil { // todo: we could continue scram with a generated salt, deterministically generated // from the username. that way we don't have to store anything but attackers cannot @@ -1960,6 +1973,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", username), slog.Any("remote", c.remoteIP)) xusercodeErrorf("AUTHENTICATIONFAILED", "channel bindings do not match, potential mitm") } else if errors.Is(err, scram.ErrInvalidEncoding) { + authResult = "badprotocol" c.log.Infox("bad scram protocol message", err, slog.String("username", username), slog.Any("remote", c.remoteIP)) xuserErrorf("bad scram protocol message: %s", err) } @@ -1988,13 +2002,22 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { username = c.username } var err error - account, _, err = store.OpenEmail(c.log, username) + account, _, err = store.OpenEmail(c.log, username, false) xcheckf(err, "looking up username from tls client authentication") default: xuserErrorf("method not supported") } + if accConf, ok := account.Conf(); !ok { + xserverErrorf("cannot get account config") + } else if accConf.LoginDisabled != "" { + authResult = "logindisabled" + c.log.Info("account login disabled", slog.String("username", username)) + // No AUTHENTICATIONFAILED code, clients could prompt users for different password. + xuserErrorf("%w: %s", store.ErrLoginDisabled, accConf.LoginDisabled) + } + // We may already have TLS credentials. They won't have been enabled, or we could // get here due to the state machine that doesn't allow authentication while being // authenticated. But allow another SASL authentication, but it has to be for the @@ -2076,13 +2099,21 @@ func (c *conn) cmdLogin(tag, cmd string, p *parser) { } }() - account, err := store.OpenEmailAuth(c.log, username, password) + account, err := store.OpenEmailAuth(c.log, username, password, true) if err != nil { - authResult = "badcreds" var code string if errors.Is(err, store.ErrUnknownCredentials) { + authResult = "badcreds" code = "AUTHENTICATIONFAILED" c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) + } else if errors.Is(err, store.ErrLoginDisabled) { + authResult = "logindisabled" + c.log.Info("account login disabled", slog.String("username", username)) + // There is no specific code for "account disabled" in IMAP. AUTHORIZATIONFAILED is + // not a good idea, it will prompt users for a password. ALERT seems reasonable, + // but may cause email clients to suppress the message since we are not yet + // authenticated. So we don't send anything. ../rfc/9051:4940 + xuserErrorf("%s", err) } xusercodeErrorf(code, "login failed") } diff --git a/imapserver/server_test.go b/imapserver/server_test.go index 977145c..2f805db 100644 --- a/imapserver/server_test.go +++ b/imapserver/server_test.go @@ -359,7 +359,7 @@ func startArgsMore(t *testing.T, first, immediateTLS bool, serverConfig, clientC err := store.Init(ctxbg) tcheck(t, err, "store init") } - acc, err := store.OpenAccount(pkglog, accname) + acc, err := store.OpenAccount(pkglog, accname, false) tcheck(t, err, "open account") if setPassword { err = acc.SetPassword(pkglog, password0) diff --git a/import.go b/import.go index 6b57841..fc630f0 100644 --- a/import.go +++ b/import.go @@ -192,7 +192,7 @@ func importctl(ctx context.Context, ctl *ctl, mbox bool) { // Open account, creating a database file if it doesn't exist yet. It must be known // in the configuration file. - a, err := store.OpenAccount(ctl.log, account) + a, err := store.OpenAccount(ctl.log, account, false) ctl.xcheck(err, "opening account") defer func() { if a != nil { diff --git a/localserve.go b/localserve.go index 48f65f1..cb09381 100644 --- a/localserve.go +++ b/localserve.go @@ -482,7 +482,7 @@ func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) { loadLoglevel(log, "info") // Set password on account. - a, _, err := store.OpenEmail(log, "mox@localhost") + a, _, err := store.OpenEmail(log, "mox@localhost", false) xcheck(err, "opening account to set password") password := "moxmoxmox" err = a.SetPassword(log, password) diff --git a/main.go b/main.go index 54c65bc..b61760c 100644 --- a/main.go +++ b/main.go @@ -145,10 +145,14 @@ var commands = []struct { {"config describe-static", cmdConfigDescribeStatic}, {"config account add", cmdConfigAccountAdd}, {"config account rm", cmdConfigAccountRemove}, + {"config account disable", cmdConfigAccountDisable}, + {"config account enable", cmdConfigAccountEnable}, {"config address add", cmdConfigAddressAdd}, {"config address rm", cmdConfigAddressRemove}, {"config domain add", cmdConfigDomainAdd}, {"config domain rm", cmdConfigDomainRemove}, + {"config domain disable", cmdConfigDomainDisable}, + {"config domain enable", cmdConfigDomainEnable}, {"config tlspubkey list", cmdConfigTlspubkeyList}, {"config tlspubkey get", cmdConfigTlspubkeyGet}, {"config tlspubkey add", cmdConfigTlspubkeyAdd}, @@ -679,13 +683,19 @@ date version. } func cmdConfigDomainAdd(c *cmd) { - c.params = "domain account [localpart]" + c.params = "[-disabled] domain account [localpart]" c.help = `Adds a new domain to the configuration and reloads the configuration. The account is used for the postmaster mailboxes the domain, including as DMARC and TLS reporting. Localpart is the "username" at the domain for this account. If must be set if and only if account does not yet exist. + +The domain can be created in disabled mode, preventing automatically requesting +TLS certificates with ACME, and rejecting incoming/outgoing messages involving +the domain, but allowing further configuration of the domain. ` + var disabled bool + c.flag.BoolVar(&disabled, "disabled", false, "disable the new domain") args := c.Parse() if len(args) != 2 && len(args) != 3 { c.Usage() @@ -699,11 +709,16 @@ must be set if and only if account does not yet exist. localpart, err = smtp.ParseLocalpart(args[2]) xcheckf(err, "parsing localpart") } - ctlcmdConfigDomainAdd(xctl(), d, args[1], localpart) + ctlcmdConfigDomainAdd(xctl(), disabled, d, args[1], localpart) } -func ctlcmdConfigDomainAdd(ctl *ctl, domain dns.Domain, account string, localpart smtp.Localpart) { +func ctlcmdConfigDomainAdd(ctl *ctl, disabled bool, domain dns.Domain, account string, localpart smtp.Localpart) { ctl.xwrite("domainadd") + if disabled { + ctl.xwrite("true") + } else { + ctl.xwrite("false") + } ctl.xwrite(domain.Name()) ctl.xwrite(account) ctl.xwrite(string(localpart)) @@ -735,6 +750,51 @@ func ctlcmdConfigDomainRemove(ctl *ctl, d dns.Domain) { fmt.Printf("domain removed, remember to remove dns records for %s\n", d) } +func cmdConfigDomainDisable(c *cmd) { + c.params = "domain" + c.help = `Disable a domain and reload the configuration. + +This is a dangerous operation. Incoming/outgoing messages involving this domain +will be rejected. +` + args := c.Parse() + if len(args) != 1 { + c.Usage() + } + + d := xparseDomain(args[0], "domain") + mustLoadConfig() + ctlcmdConfigDomainDisabled(xctl(), d, true) + fmt.Printf("domain disabled") +} + +func cmdConfigDomainEnable(c *cmd) { + c.params = "domain" + c.help = `Enable a domain and reload the configuration. + +Incoming/outgoing messages involving this domain will be accepted again. +` + args := c.Parse() + if len(args) != 1 { + c.Usage() + } + + d := xparseDomain(args[0], "domain") + mustLoadConfig() + ctlcmdConfigDomainDisabled(xctl(), d, false) +} + +func ctlcmdConfigDomainDisabled(ctl *ctl, d dns.Domain, disabled bool) { + ctl.xwrite("domaindisabled") + ctl.xwrite(d.Name()) + if disabled { + ctl.xwrite("true") + } else { + ctl.xwrite("false") + } + ctl.xreadok() +} + func cmdConfigAliasList(c *cmd) { c.params = "domain" c.help = `Show aliases (lists) for domain.` @@ -930,6 +990,52 @@ func ctlcmdConfigAccountRemove(ctl *ctl, account string) { fmt.Println("account removed") } +func cmdConfigAccountDisable(c *cmd) { + c.params = "account message" + c.help = `Disable login for an account, showing message to users when they try to login. + +Incoming email will still be accepted for the account, and queued email from the +account will still be delivered. No new login sessions are possible. + +Message must be non-empty, ascii-only without control characters including +newline, and maximum 256 characters because it is used in SMTP/IMAP. +` + args := c.Parse() + if len(args) != 2 { + c.Usage() + } + if args[1] == "" { + log.Fatalf("message must be non-empty") + } + + mustLoadConfig() + ctlcmdConfigAccountDisabled(xctl(), args[0], args[1]) + fmt.Println("account disabled") +} + +func cmdConfigAccountEnable(c *cmd) { + c.params = "account" + c.help = `Enable login again for an account. + +Login attempts by the user no long result in an error message. +` + args := c.Parse() + if len(args) != 1 { + c.Usage() + } + + mustLoadConfig() + ctlcmdConfigAccountDisabled(xctl(), args[0], "") + fmt.Println("account enabled") +} + +func ctlcmdConfigAccountDisabled(ctl *ctl, account, loginDisabled string) { + ctl.xwrite("accountdisabled") + ctl.xwrite(account) + ctl.xwrite(loginDisabled) + ctl.xreadok() +} + func cmdConfigTlspubkeyList(c *cmd) { c.params = "[account]" c.help = `List TLS public keys for TLS client certificate authentication. @@ -3165,7 +3271,7 @@ open, or is not running. } mustLoadConfig() - a, err := store.OpenAccount(c.log, args[0]) + a, err := store.OpenAccount(c.log, args[0], false) xcheckf(err, "open account") defer func() { if err := a.Close(); err != nil { @@ -3223,7 +3329,7 @@ open, or is not running. } mustLoadConfig() - a, err := store.OpenAccount(c.log, args[0]) + a, err := store.OpenAccount(c.log, args[0], false) xcheckf(err, "open account") defer func() { if err := a.Close(); err != nil { @@ -3317,7 +3423,7 @@ open, or is not running. } mustLoadConfig() - a, err := store.OpenAccount(c.log, args[0]) + a, err := store.OpenAccount(c.log, args[0], false) xcheckf(err, "open account") defer func() { if err := a.Close(); err != nil { @@ -3438,7 +3544,7 @@ func cmdEnsureParsed(c *cmd) { } mustLoadConfig() - a, err := store.OpenAccount(c.log, args[0]) + a, err := store.OpenAccount(c.log, args[0], false) xcheckf(err, "open account") defer func() { if err := a.Close(); err != nil { diff --git a/metrics/auth.go b/metrics/auth.go index 97f22f5..bbc428a 100644 --- a/metrics/auth.go +++ b/metrics/auth.go @@ -16,7 +16,7 @@ var ( "kind", // submission, imap, webmail, webapi, webaccount, webadmin (formerly httpaccount, httpadmin) "variant", // login, plain, scram-sha-256, scram-sha-1, cram-md5, weblogin, websessionuse, httpbasic, tlsclientauth. // todo: we currently only use badcreds, but known baduser can be helpful - "result", // ok, baduser, badpassword, badcreds, badchanbind, error, aborted + "result", // ok, baduser, badpassword, badcreds, badchanbind, error, aborted, badprotocol, logindisabled }, ) diff --git a/mox-/config.go b/mox-/config.go index e359bf5..cac0dbd 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -214,6 +214,18 @@ func (c *Config) Accounts() (l []string) { return } +func (c *Config) AccountsDisabled() (all, disabled []string) { + c.withDynamicLock(func() { + for name, conf := range c.Dynamic.Accounts { + all = append(all, name) + if conf.LoginDisabled != "" { + disabled = append(disabled, name) + } + } + }) + return +} + // DomainLocalparts returns a mapping of encoded localparts to account names for a // domain, and encoded localparts to aliases. An empty localpart is a catchall // destination for a domain. @@ -247,6 +259,16 @@ func (c *Config) Domain(d dns.Domain) (dom config.Domain, ok bool) { return } +func (c *Config) DomainConfigs() (doms []config.Domain) { + c.withDynamicLock(func() { + doms = make([]config.Domain, 0, len(c.Dynamic.Domains)) + for _, d := range c.Dynamic.Domains { + doms = append(doms, d) + } + }) + return +} + func (c *Config) Account(name string) (acc config.Account, ok bool) { c.withDynamicLock(func() { acc, ok = c.Dynamic.Accounts[name] @@ -309,6 +331,12 @@ func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) { continue } + // Do not fetch TLS certs for disabled domains. The A/AAAA records may not be + // configured or still point to a previous machine before a migration. + if dom.Disabled { + continue + } + if l.AutoconfigHTTPS.Enabled && !l.AutoconfigHTTPS.NonTLS { if d, err := dns.ParseDomain("autoconfig." + dom.Domain.ASCII); err != nil { log.Errorx("parsing autoconfig domain", err, slog.Any("domain", dom.Domain)) @@ -1340,6 +1368,16 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, } checkMailboxNormf(acc.RejectsMailbox, "rejects mailbox", addErrorf) + if len(acc.LoginDisabled) > 256 { + addAccountErrorf("message for disabled login must be <256 characters") + } + for _, c := range acc.LoginDisabled { + // For IMAP and SMTP. IMAP only allows UTF8 after "ENABLE IMAPrev2". + if c < ' ' || c >= 0x7f { + addAccountErrorf("message cannot contain control characters including newlines, and must be ascii-only") + } + } + if acc.AutomaticJunkFlags.JunkMailboxRegexp != "" { r, err := regexp.Compile(acc.AutomaticJunkFlags.JunkMailboxRegexp) if err != nil { diff --git a/mox-/dkimsign.go b/mox-/dkimsign.go index 94f29b5..4bc665a 100644 --- a/mox-/dkimsign.go +++ b/mox-/dkimsign.go @@ -54,6 +54,10 @@ func DKIMSign(ctx context.Context, log mlog.Log, from smtp.Path, smtputf8 bool, continue } + if confDom.Disabled { + return "", ErrDomainDisabled + } + selectors := DKIMSelectors(confDom.DKIM) dkimHeaders, err := dkim.Sign(ctx, log.Logger, from.Localpart, fd, selectors, smtputf8, bytes.NewReader(data)) if err != nil { diff --git a/mox-/lookup.go b/mox-/lookup.go index 61866d9..9ceca6a 100644 --- a/mox-/lookup.go +++ b/mox-/lookup.go @@ -11,13 +11,15 @@ import ( var ( ErrDomainNotFound = errors.New("domain not found") + ErrDomainDisabled = errors.New("message/transaction involving temporarily disabled domain") ErrAddressNotFound = errors.New("address not found") ) // LookupAddress looks up the account for localpart and domain. // -// Can return ErrDomainNotFound and ErrAddressNotFound. -func LookupAddress(localpart smtp.Localpart, domain dns.Domain, allowPostmaster, allowAlias bool) (accountName string, alias *config.Alias, canonicalAddress string, dest config.Destination, rerr error) { +// Can return ErrDomainNotFound and ErrAddressNotFound. If checkDomainDisabled is +// set, returns ErrDomainDisabled if domain is disabled. +func LookupAddress(localpart smtp.Localpart, domain dns.Domain, allowPostmaster, allowAlias, checkDomainDisabled bool) (accountName string, alias *config.Alias, canonicalAddress string, dest config.Destination, rerr error) { if strings.EqualFold(string(localpart), "postmaster") { localpart = "postmaster" } @@ -58,6 +60,9 @@ func LookupAddress(localpart smtp.Localpart, domain dns.Domain, allowPostmaster, // considered local/authoritative during delivery. return "", nil, "", config.Destination{}, ErrDomainNotFound } + if d.Disabled && checkDomainDisabled { + return "", nil, "", config.Destination{}, ErrDomainDisabled + } localpart = CanonicalLocalpart(localpart, d) canonical := smtp.NewAddress(localpart, domain).String() @@ -97,18 +102,18 @@ func CanonicalLocalpart(localpart smtp.Localpart, d config.Domain) smtp.Localpar // AllowMsgFrom returns whether account is allowed to submit messages with address // as message From header, based on configured addresses and membership of aliases // that allow using its address. -func AllowMsgFrom(accountName string, msgFrom smtp.Address) bool { - accName, alias, _, _, err := LookupAddress(msgFrom.Localpart, msgFrom.Domain, false, true) +func AllowMsgFrom(accountName string, msgFrom smtp.Address) (ok, domainDisabled bool) { + accName, alias, _, _, err := LookupAddress(msgFrom.Localpart, msgFrom.Domain, false, true, true) if err != nil { - return false + return false, errors.Is(err, ErrDomainDisabled) } if alias != nil && alias.AllowMsgFrom { for _, aa := range alias.ParsedAddresses { if aa.AccountName == accountName { - return true + return true, false } } - return false + return false, false } - return accName == accountName + return accName == accountName, false } diff --git a/queue/direct.go b/queue/direct.go index 0bebaec..6072fd6 100644 --- a/queue/direct.go +++ b/queue/direct.go @@ -765,7 +765,7 @@ func deliverHost(log mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer, // Update (overwite) last known starttls/requiretls support for recipient domain. func updateRecipientDomainTLS(ctx context.Context, log mlog.Log, senderAccount string, rdt store.RecipientDomainTLS) error { - acc, err := store.OpenAccount(log, senderAccount) + acc, err := store.OpenAccount(log, senderAccount, false) if err != nil { return fmt.Errorf("open account: %w", err) } diff --git a/queue/dsn.go b/queue/dsn.go index d79068a..ef081e3 100644 --- a/queue/dsn.go +++ b/queue/dsn.go @@ -354,9 +354,9 @@ func deliverDSN(log mlog.Log, m Msg, remoteMTA dsn.NameIP, secodeOpt, errmsg str // senderAccount should already by postmaster, but doesn't hurt to ensure it. senderAccount = mox.Conf.Static.Postmaster.Account } - acc, err := store.OpenAccount(log, senderAccount) + acc, err := store.OpenAccount(log, senderAccount, false) if err != nil { - acc, err = store.OpenAccount(log, mox.Conf.Static.Postmaster.Account) + acc, err = store.OpenAccount(log, mox.Conf.Static.Postmaster.Account, false) if err != nil { qlog("looking up postmaster account after sender account was not found", err) return diff --git a/queue/hook_test.go b/queue/hook_test.go index b1190c5..6cd7ac7 100644 --- a/queue/hook_test.go +++ b/queue/hook_test.go @@ -25,7 +25,7 @@ func TestHookIncoming(t *testing.T) { acc, cleanup := setup(t) defer cleanup() - accret, err := store.OpenAccount(pkglog, "retired") + accret, err := store.OpenAccount(pkglog, "retired", false) tcheck(t, err, "open account for retired") defer func() { accret.Close() @@ -121,7 +121,7 @@ func TestFromIDIncomingDelivery(t *testing.T) { acc, cleanup := setup(t) defer cleanup() - accret, err := store.OpenAccount(pkglog, "retired") + accret, err := store.OpenAccount(pkglog, "retired", false) tcheck(t, err, "open account for retired") defer func() { accret.Close() @@ -129,7 +129,7 @@ func TestFromIDIncomingDelivery(t *testing.T) { }() // Account that only gets webhook calls, but no retired webhooks. - acchook, err := store.OpenAccount(pkglog, "hook") + acchook, err := store.OpenAccount(pkglog, "hook", false) tcheck(t, err, "open account for hook") defer func() { acchook.Close() diff --git a/queue/queue.go b/queue/queue.go index a8ce550..2fabc25 100644 --- a/queue/queue.go +++ b/queue/queue.go @@ -1391,6 +1391,16 @@ func deliver(log mlog.Log, resolver dns.Resolver, m0 Msg) { var remoteMTA dsn.NameIP // Zero value, will not be included in DSN. ../rfc/3464:1027 + // If domain of sender is currently disabled, fail the delivery attempt. + if domConf, _ := mox.Conf.Domain(m0.SenderDomain.Domain); domConf.Disabled { + failMsgsTx(qlog, xtx, []*Msg{&m0}, m0.DialedIPs, backoff, remoteMTA, fmt.Errorf("domain of sender temporarily disabled")) + err = xtx.Commit() + qlog.Check(err, "commit processing failure to deliver messages") + xtx = nil + kick() + return + } + // Check if recipient is on suppression list. If so, fail delivery. path := smtp.Path{Localpart: m0.RecipientLocalpart, IPDomain: m0.RecipientDomain} baseAddr := baseAddress(path).XString(true) diff --git a/queue/queue_test.go b/queue/queue_test.go index 974dea4..20baf65 100644 --- a/queue/queue_test.go +++ b/queue/queue_test.go @@ -67,7 +67,7 @@ func setup(t *testing.T) (*store.Account, func()) { tcheck(t, err, "mtastsdb init") err = tlsrptdb.Init() tcheck(t, err, "tlsrptdb init") - acc, err := store.OpenAccount(log, "mjl") + acc, err := store.OpenAccount(log, "mjl", false) tcheck(t, err, "open account") err = acc.SetPassword(log, "testtest") tcheck(t, err, "set password") diff --git a/quickstart.go b/quickstart.go index dd4a123..9b8660a 100644 --- a/quickstart.go +++ b/quickstart.go @@ -957,7 +957,7 @@ and check the admin page for the needed DNS records.`) fatalf("cannot find domain in new config") } - acc, _, err := store.OpenEmail(c.log, args[0]) + acc, _, err := store.OpenEmail(c.log, args[0], false) if err != nil { fatalf("open account: %s", err) } diff --git a/serve_unix.go b/serve_unix.go index 280e72e..31beffc 100644 --- a/serve_unix.go +++ b/serve_unix.go @@ -310,7 +310,7 @@ Only implemented on unix systems, not Windows. } cl += "----" - a, err := store.OpenAccount(log, mox.Conf.Static.Postmaster.Account) + a, err := store.OpenAccount(log, mox.Conf.Static.Postmaster.Account, false) if err != nil { log.Infox("open account for postmaster changelog delivery", err) return next diff --git a/smtp/codes.go b/smtp/codes.go index a244f36..6d93630 100644 --- a/smtp/codes.go +++ b/smtp/codes.go @@ -31,6 +31,8 @@ var ( C503BadCmdSeq = 503 C504ParamNotImpl = 504 C521HostNoMail = 521 // ../rfc/7504:179 + C523EncryptionNeeded = 523 // ../rfc/5248:361 + C525AccountDisabled = 525 // ../rfc/5248:401 C530SecurityRequired = 530 // ../rfc/3207:148 ../rfc/4954:623 C534AuthMechWeak = 534 // ../rfc/4954:593 C535AuthBadCreds = 535 // ../rfc/4954:600 diff --git a/smtpserver/alias_test.go b/smtpserver/alias_test.go index dad2a84..e0ebc38 100644 --- a/smtpserver/alias_test.go +++ b/smtpserver/alias_test.go @@ -84,12 +84,12 @@ func TestAliasSubmitMsgFromDenied(t *testing.T) { defer ts.close() // Trying to open account by alias should result in proper error. - _, _, err := store.OpenEmail(pkglog, "public@mox.example") + _, _, err := store.OpenEmail(pkglog, "public@mox.example", false) if err == nil || !errors.Is(err, store.ErrUnknownCredentials) { t.Fatalf("opening alias, got err %v, expected store.ErrUnknownCredentials", err) } - acc, err := store.OpenAccount(pkglog, "☺") + acc, err := store.OpenAccount(pkglog, "☺", false) tcheck(t, err, "open account") err = acc.SetPassword(pkglog, password0) tcheck(t, err, "set password") diff --git a/smtpserver/fuzz_test.go b/smtpserver/fuzz_test.go index 83bd3ed..c08e8b0 100644 --- a/smtpserver/fuzz_test.go +++ b/smtpserver/fuzz_test.go @@ -37,7 +37,7 @@ func FuzzServer(f *testing.F) { mox.MustLoadConfig(true, false) dataDir := mox.ConfigDirPath(mox.Conf.Static.DataDir) os.RemoveAll(dataDir) - acc, err := store.OpenAccount(log, "mjl") + acc, err := store.OpenAccount(log, "mjl", false) if err != nil { f.Fatalf("open account: %v", err) } diff --git a/smtpserver/server.go b/smtpserver/server.go index db9d29f..60873d7 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -492,8 +492,12 @@ func (c *conn) tlsClientAuthVerifyPeerCertParsed(cert *x509.Certificate) error { return fmt.Errorf("looking up tls public key with fingerprint %s: %v", fp, err) } - // Verify account exists and still matches address. - acc, _, err := store.OpenEmail(c.log, pubKey.LoginAddress) + // Verify account exists and still matches address. We don't check for account + // login being disabled if preauth is disabled. In that case, sasl external auth + // will be done before credentials can be used, and login disabled will be checked + // then, where it will result in a more helpful error message. + checkLoginDisabled := !pubKey.NoIMAPPreauth + acc, _, err := store.OpenEmail(c.log, pubKey.LoginAddress, checkLoginDisabled) if err != nil { return fmt.Errorf("opening account for address %s for public key %s: %w", pubKey.LoginAddress, fp, err) } @@ -1073,7 +1077,7 @@ func (c *conn) xneedTLSForDelivery(rcpt smtp.Path) { } func isTLSReportRecipient(rcpt smtp.Path) bool { - _, _, _, dest, err := mox.LookupAddress(rcpt.Localpart, rcpt.IPDomain.Domain, false, false) + _, _, _, dest, err := mox.LookupAddress(rcpt.Localpart, rcpt.IPDomain.Domain, false, false, false) return err == nil && (dest.HostTLSReports || dest.DomainTLSReports) } @@ -1351,7 +1355,7 @@ func (c *conn) cmdAuth(p *parser) { } var err error - account, err = store.OpenEmailAuth(c.log, username, password) + account, err = store.OpenEmailAuth(c.log, username, password, false) if err != nil && errors.Is(err, store.ErrUnknownCredentials) { // ../rfc/4954:274 authResult = "badcreds" @@ -1393,7 +1397,7 @@ func (c *conn) cmdAuth(p *parser) { c.xtrace(mlog.LevelTrace) // Restore. var err error - account, err = store.OpenEmailAuth(c.log, username, password) + account, err = store.OpenEmailAuth(c.log, username, password, false) if err != nil && errors.Is(err, store.ErrUnknownCredentials) { // ../rfc/4954:274 authResult = "badcreds" @@ -1419,8 +1423,9 @@ func (c *conn) cmdAuth(p *parser) { username = norm.NFC.String(t[0]) c.log.Debug("cram-md5 auth", slog.String("username", username)) var err error - account, _, err = store.OpenEmail(c.log, username) + account, _, err = store.OpenEmail(c.log, username, false) if err != nil && errors.Is(err, store.ErrUnknownCredentials) { + authResult = "badcreds" c.log.Info("failed authentication attempt", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass") } @@ -1494,7 +1499,7 @@ func (c *conn) cmdAuth(p *parser) { } username = norm.NFC.String(ss.Authentication) c.log.Debug("scram auth", slog.String("authentication", username)) - account, _, err = store.OpenEmail(c.log, username) + account, _, err = store.OpenEmail(c.log, username, false) if err != nil { // todo: we could continue scram with a generated salt, deterministically generated // from the username. that way we don't have to store anything but attackers cannot @@ -1551,6 +1556,7 @@ func (c *conn) cmdAuth(p *parser) { c.log.Warn("bad channel binding during authentication, potential mitm", slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7MsgIntegrity7, "channel bindings do not match, potential mitm") } else if errors.Is(err, scram.ErrInvalidEncoding) { + authResult = "badprotocol" c.log.Infox("bad scram protocol message", err, slog.String("username", username), slog.Any("remote", c.remoteIP)) xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7Other0, "bad scram protocol message") } @@ -1580,7 +1586,7 @@ func (c *conn) cmdAuth(p *parser) { username = c.username } var err error - account, _, err = store.OpenEmail(c.log, username) + account, _, err = store.OpenEmail(c.log, username, false) xcheckf(err, "looking up username from tls client authentication") default: @@ -1588,6 +1594,14 @@ func (c *conn) cmdAuth(p *parser) { xsmtpUserErrorf(smtp.C504ParamNotImpl, smtp.SeProto5BadParams4, "mechanism %s not supported", mech) } + if accConf, ok := account.Conf(); !ok { + xcheckf(errors.New("cannot find account"), "get account config") + } else if accConf.LoginDisabled != "" { + authResult = "logindisabled" + c.log.Info("account login disabled", slog.String("username", username)) + xsmtpUserErrorf(smtp.C525AccountDisabled, smtp.SePol7AccountDisabled13, "%w: %s", store.ErrLoginDisabled, accConf.LoginDisabled) + } + // We may already have TLS credentials. We allow an additional SASL authentication, // possibly with different username, but the account must be the same. if c.account != nil { @@ -1717,7 +1731,7 @@ func (c *conn) cmdMail(p *parser) { case "REQUIRETLS": // ../rfc/8689:155 if !c.tls { - xsmtpUserErrorf(smtp.C530SecurityRequired, smtp.SePol7EncNeeded10, "requiretls only allowed on tls-encrypted connections") + xsmtpUserErrorf(smtp.C523EncryptionNeeded, smtp.SePol7EncNeeded10, "requiretls only allowed on tls-encrypted connections") } else if !c.extRequireTLS { xsmtpUserErrorf(smtp.C555UnrecognizedAddrParams, smtp.SeSys3NotSupported3, "REQUIRETLS not allowed for this connection") } @@ -1773,14 +1787,16 @@ func (c *conn) cmdMail(p *parser) { // must have the rpath configured. We do a check again on rfc5322.from during DATA. // Mail clients may use the alias address as smtp mail from address, so we allow it // for such aliases. - rpathAllowed := func() bool { + rpathAllowed := func(disabled *bool) bool { // ../rfc/6409:349 if rpath.IsZero() { return true } from := smtp.NewAddress(rpath.Localpart, rpath.IPDomain.Domain) - return mox.AllowMsgFrom(c.account.Name, from) + ok, dis := mox.AllowMsgFrom(c.account.Name, from) + *disabled = dis + return ok } if !c.submission && !rpath.IPDomain.Domain.IsZero() { @@ -1800,7 +1816,13 @@ func (c *conn) cmdMail(p *parser) { } } - if c.submission && (len(rpath.IPDomain.IP) > 0 || !rpathAllowed()) { + var disabled bool + if c.submission && (len(rpath.IPDomain.IP) > 0 || !rpathAllowed(&disabled)) { + if disabled { + c.log.Info("submission with smtp mail from of disabled domain", slog.Any("domain", rpath.IPDomain.Domain)) + xsmtpServerErrorf(codes{smtp.C451LocalErr, smtp.SeSys3Other0}, "domain of smtp mail from is temporarily disabled") + } + // ../rfc/6409:522 c.log.Info("submission with unconfigured mailfrom", slog.String("user", c.username), slog.String("mailfrom", rpath.String())) xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SePol7DeliveryUnauth1, "must match authenticated user") @@ -1919,7 +1941,7 @@ func (c *conn) cmdRcpt(p *parser) { xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for ip") } c.recipients = append(c.recipients, recipient{fpath, nil, nil}) - } else if accountName, alias, canonical, dest, err := mox.LookupAddress(fpath.Localpart, fpath.IPDomain.Domain, true, true); err == nil { + } else if accountName, alias, canonical, dest, err := mox.LookupAddress(fpath.Localpart, fpath.IPDomain.Domain, true, true, true); err == nil { // note: a bare postmaster, without domain, is handled by LookupAddress. ../rfc/5321:735 if alias != nil { c.recipients = append(c.recipients, recipient{fpath, nil, &rcptAlias{*alias, canonical}}) @@ -1937,6 +1959,9 @@ func (c *conn) cmdRcpt(p *parser) { acc, _ := mox.Conf.Account("mox") dest := acc.Destinations["mox@localhost"] c.recipients = append(c.recipients, recipient{fpath, &rcptAccount{"mox", dest, "mox@localhost"}, nil}) + } else if errors.Is(err, mox.ErrDomainDisabled) { + c.log.Info("smtp recipient for temporarily disabled domain", slog.Any("domain", fpath.IPDomain.Domain)) + xsmtpUserErrorf(smtp.C450MailboxUnavail, smtp.SeMailbox2Disabled1, "recipient domain temporarily disabled") } else if errors.Is(err, mox.ErrDomainNotFound) { if !c.submission { xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeAddr1UnknownDestMailbox1, "not accepting email for domain") @@ -2248,7 +2273,10 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr c.log.Infox("parsing message From address", err, slog.String("user", c.username)) xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeMsg6Other0, "cannot parse header or From address: %v", err) } - if !mox.AllowMsgFrom(c.account.Name, msgFrom) { + if ok, disabled := mox.AllowMsgFrom(c.account.Name, msgFrom); disabled { + c.log.Info("submission with message from address of disabled domain", slog.Any("domain", msgFrom.Domain)) + xsmtpServerErrorf(codes{smtp.C451LocalErr, smtp.SeSys3Other0}, "domain of message from header is temporarily disabled") + } else if !ok { // ../rfc/6409:522 metricSubmission.WithLabelValues("badfrom").Inc() c.log.Infox("verifying message from address", mox.ErrAddressNotFound, slog.String("user", c.username), slog.Any("msgfrom", msgFrom)) @@ -2330,6 +2358,9 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr if !ok { c.log.Error("domain disappeared", slog.Any("domain", msgFrom.Domain)) xsmtpServerErrorf(codes{smtp.C451LocalErr, smtp.SeSys3Other0}, "internal error") + } else if confDom.Disabled { + c.log.Info("submission with message from address of disabled domain", slog.Any("domain", msgFrom.Domain)) + xsmtpServerErrorf(codes{smtp.C451LocalErr, smtp.SeSys3Other0}, "domain of message from header is temporarily disabled") } selectors := mox.DKIMSelectors(confDom.DKIM) @@ -2947,7 +2978,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW // We call this for all alias destinations, also when we already delivered to that // recipient: It may be the only recipient that would allow the message. messageAnalyze := func(log mlog.Log, smtpRcptTo, deliverTo smtp.Path, accountName string, destination config.Destination, canonicalAddr string) (a *analysis, rerr error) { - acc, err := store.OpenAccount(log, accountName) + acc, err := store.OpenAccount(log, accountName, false) if err != nil { log.Errorx("open account", err, slog.Any("account", accountName)) metricDelivery.WithLabelValues("accounterror", "").Inc() diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go index ab9f4c4..35cf151 100644 --- a/smtpserver/server_test.go +++ b/smtpserver/server_test.go @@ -137,7 +137,7 @@ func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *test err = store.Init(ctxbg) tcheck(t, err, "store init") - ts.acc, err = store.OpenAccount(log, "mjl") + ts.acc, err = store.OpenAccount(log, "mjl", false) tcheck(t, err, "open account") err = ts.acc.SetPassword(log, password0) tcheck(t, err, "set password") @@ -332,6 +332,13 @@ func TestSubmission(t *testing.T) { }) } + acc, err := store.OpenAccount(pkglog, "disabled", false) + tcheck(t, err, "open account") + err = acc.SetPassword(pkglog, "test1234") + tcheck(t, err, "set password") + err = acc.Close() + tcheck(t, err, "close account") + ts.submission = true testAuth(nil, "", "", &smtpclient.Error{Permanent: true, Code: smtp.C530SecurityRequired, Secode: smtp.SePol7Other0}) authfns := []func(user, pass string, cs *tls.ConnectionState) sasl.Client{ @@ -360,6 +367,8 @@ func TestSubmission(t *testing.T) { testAuth(fn, "móx@mox.example", password1, nil) testAuth(fn, "mo\u0301x@mox.example", password0, nil) testAuth(fn, "mo\u0301x@mox.example", password1, nil) + testAuth(fn, "disabled@mox.example", "test1234", &smtpclient.Error{Code: smtp.C525AccountDisabled, Secode: smtp.SePol7AccountDisabled13}) + testAuth(fn, "disabled@mox.example", "bogus", &smtpclient.Error{Code: smtp.C535AuthBadCreds, Secode: smtp.SePol7AuthBadCreds8}) } // Create a certificate, register its public key with account, and make a tls @@ -465,6 +474,50 @@ func TestSubmission(t *testing.T) { } } +func TestDomainDisabled(t *testing.T) { + ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), dns.MockResolver{}) + defer ts.close() + + ts.submission = true + ts.user = "mjl@mox.example" + ts.pass = password0 + + // Submission with SMTP MAIL FROM of disabled domain must fail. + ts.run(func(err error, client *smtpclient.Client) { + mailFrom := "mjl@disabled.example" // Disabled. + rcptTo := "remote@example.org" + if err == nil { + err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(submitMessage)), strings.NewReader(submitMessage), false, false, false) + } + var cerr smtpclient.Error + if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { + t.Fatalf("got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr) + } + checkEvaluationCount(t, 0) + }) + + // Message From-address has disabled domain, must fail. + var submitMessage2 = strings.ReplaceAll(`From: +To: +Subject: test +Message-Id: + +test email +`, "\n", "\r\n") + ts.run(func(err error, client *smtpclient.Client) { + mailFrom := "mjl@mox.example" + rcptTo := "remote@example.org" + if err == nil { + err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(submitMessage2)), strings.NewReader(submitMessage2), false, false, false) + } + var cerr smtpclient.Error + if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { + t.Fatalf("got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr) + } + checkEvaluationCount(t, 0) + }) +} + // Test delivery from external MTA. func TestDelivery(t *testing.T) { resolver := dns.MockResolver{ @@ -541,6 +594,19 @@ func TestDelivery(t *testing.T) { } }) + // Deliveries to disabled domain are rejected with temporary error. + ts.run(func(err error, client *smtpclient.Client) { + mailFrom := "remote@example.org" + rcptTo := "mjl@disabled.example" + if err == nil { + err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false) + } + var cerr smtpclient.Error + if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C450MailboxUnavail { + t.Fatalf("deliver to disabled domain, got err %v, expected smtpclient.Error with code %d", err, smtp.C450MailboxUnavail) + } + }) + ts.run(func(err error, client *smtpclient.Client) { recipients := []string{ "mjl@mox.example", @@ -1514,7 +1580,7 @@ func TestCatchall(t *testing.T) { tcheck(t, err, "checking delivered messages") tcompare(t, n, 3) - acc, err := store.OpenAccount(pkglog, "catchall") + acc, err := store.OpenAccount(pkglog, "catchall", false) tcheck(t, err, "open account") defer func() { acc.Close() diff --git a/store/account.go b/store/account.go index 46dc57a..c0cd9c9 100644 --- a/store/account.go +++ b/store/account.go @@ -73,6 +73,7 @@ var ( ErrUnknownCredentials = errors.New("credentials not found") ErrAccountUnknown = errors.New("no such account") ErrOverQuota = errors.New("account over quota") + ErrLoginDisabled = errors.New("login disabled for account") ) var DefaultInitialMailboxes = config.InitialMailboxes{ @@ -876,7 +877,7 @@ func closeAccount(acc *Account) (rerr error) { // // No additional data path prefix or ".db" suffix should be added to the name. // A single shared account exists per name. -func OpenAccount(log mlog.Log, name string) (*Account, error) { +func OpenAccount(log mlog.Log, name string, checkLoginDisabled bool) (*Account, error) { openAccounts.Lock() defer openAccounts.Unlock() if acc, ok := openAccounts.names[name]; ok { @@ -884,8 +885,10 @@ func OpenAccount(log mlog.Log, name string) (*Account, error) { return acc, nil } - if _, ok := mox.Conf.Account(name); !ok { + if a, ok := mox.Conf.Account(name); !ok { return nil, ErrAccountUnknown + } else if checkLoginDisabled && a.LoginDisabled != "" { + return nil, fmt.Errorf("%w: %s", ErrLoginDisabled, a.LoginDisabled) } acc, err := openAccount(log, name) @@ -1657,6 +1660,13 @@ func (a *Account) SetPassword(log mlog.Log, password string) error { return err } +// SessionsClear invalidates all (web) login sessions for the account. +func (a *Account) SessionsClear(ctx context.Context, log mlog.Log) error { + return a.DB.Write(ctx, func(tx *bstore.Tx) error { + return sessionRemoveAll(ctx, log, tx, a.Name) + }) +} + // Subjectpass returns the signing key for use with subjectpass for the given // email address with canonical localpart. func (a *Account) Subjectpass(email string) (key string, err error) { @@ -2211,13 +2221,15 @@ func manageAuthCache() { // OpenEmailAuth opens an account given an email address and password. // // The email address may contain a catchall separator. -func OpenEmailAuth(log mlog.Log, email string, password string) (acc *Account, rerr error) { +func OpenEmailAuth(log mlog.Log, email string, password string, checkLoginDisabled bool) (acc *Account, rerr error) { password, err := precis.OpaqueString.String(password) if err != nil { return nil, ErrUnknownCredentials } - acc, _, rerr = OpenEmail(log, email) + // We check for LoginDisabled after verifying the password. Otherwise users can get + // messages about the account being disabled without knowing the password. + acc, _, rerr = OpenEmail(log, email, false) if rerr != nil { return } @@ -2240,34 +2252,40 @@ func OpenEmailAuth(log mlog.Log, email string, password string) (acc *Account, r authCache.Lock() ok := len(password) >= 8 && authCache.success[authKey{email, pw.Hash}] == password authCache.Unlock() - if ok { - return + if !ok { + if err := bcrypt.CompareHashAndPassword([]byte(pw.Hash), []byte(password)); err != nil { + return acc, ErrUnknownCredentials + } } - if err := bcrypt.CompareHashAndPassword([]byte(pw.Hash), []byte(password)); err != nil { - rerr = ErrUnknownCredentials - } else { - authCache.Lock() - authCache.success[authKey{email, pw.Hash}] = password - authCache.Unlock() + if checkLoginDisabled { + conf, aok := acc.Conf() + if !aok { + return acc, fmt.Errorf("cannot find config for account") + } else if conf.LoginDisabled != "" { + return acc, fmt.Errorf("%w: %s", ErrLoginDisabled, conf.LoginDisabled) + } } + authCache.Lock() + authCache.success[authKey{email, pw.Hash}] = password + authCache.Unlock() return } // OpenEmail opens an account given an email address. // // The email address may contain a catchall separator. -func OpenEmail(log mlog.Log, email string) (*Account, config.Destination, error) { +func OpenEmail(log mlog.Log, email string, checkLoginDisabled bool) (*Account, config.Destination, error) { addr, err := smtp.ParseAddress(email) if err != nil { return nil, config.Destination{}, fmt.Errorf("%w: %v", ErrUnknownCredentials, err) } - accountName, _, _, dest, err := mox.LookupAddress(addr.Localpart, addr.Domain, false, false) + accountName, _, _, dest, err := mox.LookupAddress(addr.Localpart, addr.Domain, false, false, false) if err != nil && (errors.Is(err, mox.ErrAddressNotFound) || errors.Is(err, mox.ErrDomainNotFound)) { return nil, config.Destination{}, ErrUnknownCredentials } else if err != nil { return nil, config.Destination{}, fmt.Errorf("looking up address: %v", err) } - acc, err := OpenAccount(log, accountName) + acc, err := OpenAccount(log, accountName, checkLoginDisabled) if err != nil { return nil, config.Destination{}, err } diff --git a/store/account_test.go b/store/account_test.go index 732c6d4..09b0aed 100644 --- a/store/account_test.go +++ b/store/account_test.go @@ -33,7 +33,7 @@ func TestMailbox(t *testing.T) { os.RemoveAll("../testdata/store/data") mox.ConfigStaticPath = filepath.FromSlash("../testdata/store/mox.conf") mox.MustLoadConfig(true, false) - acc, err := OpenAccount(log, "mjl") + acc, err := OpenAccount(log, "mjl", false) tcheck(t, err, "open account") defer func() { err = acc.Close() @@ -224,30 +224,30 @@ func TestMailbox(t *testing.T) { // Run the auth tests twice for possible cache effects. for i := 0; i < 2; i++ { - _, err := OpenEmailAuth(log, "mjl@mox.example", "bogus") + _, err := OpenEmailAuth(log, "mjl@mox.example", "bogus", false) if err != ErrUnknownCredentials { t.Fatalf("got %v, expected ErrUnknownCredentials", err) } } for i := 0; i < 2; i++ { - acc2, err := OpenEmailAuth(log, "mjl@mox.example", "testtest") + acc2, err := OpenEmailAuth(log, "mjl@mox.example", "testtest", false) tcheck(t, err, "open for email with auth") err = acc2.Close() tcheck(t, err, "close account") } - acc2, err := OpenEmailAuth(log, "other@mox.example", "testtest") + acc2, err := OpenEmailAuth(log, "other@mox.example", "testtest", false) tcheck(t, err, "open for email with auth") err = acc2.Close() tcheck(t, err, "close account") - _, err = OpenEmailAuth(log, "bogus@mox.example", "testtest") + _, err = OpenEmailAuth(log, "bogus@mox.example", "testtest", false) if err != ErrUnknownCredentials { t.Fatalf("got %v, expected ErrUnknownCredentials", err) } - _, err = OpenEmailAuth(log, "mjl@test.example", "testtest") + _, err = OpenEmailAuth(log, "mjl@test.example", "testtest", false) if err != ErrUnknownCredentials { t.Fatalf("got %v, expected ErrUnknownCredentials", err) } diff --git a/store/export_test.go b/store/export_test.go index 87bfeae..14d0432 100644 --- a/store/export_test.go +++ b/store/export_test.go @@ -24,7 +24,7 @@ func TestExport(t *testing.T) { os.RemoveAll("../testdata/store/data") mox.ConfigStaticPath = filepath.FromSlash("../testdata/store/mox.conf") mox.MustLoadConfig(true, false) - acc, err := OpenAccount(pkglog, "mjl") + acc, err := OpenAccount(pkglog, "mjl", false) tcheck(t, err, "open account") defer func() { err := acc.Close() diff --git a/store/session.go b/store/session.go index c94b6ea..a4eeb2e 100644 --- a/store/session.go +++ b/store/session.go @@ -38,17 +38,23 @@ var sessions = struct { pendingFlushes: map[string]map[SessionToken]struct{}{}, } -// Ensure sessions for account are initialized from database. If the sessions were -// initialized from the database, or when alwaysOpenAccount is true, an open -// account is returned (assuming no error occurred). +// Ensure sessions for account are initialized from database. If openAccount is +// set, an account is returned on success. // // must be called with sessions lock held. -func ensureAccountSessions(ctx context.Context, log mlog.Log, accountName string, alwaysOpenAccount bool) (*Account, error) { - var acc *Account +func ensureAccountSessions(ctx context.Context, log mlog.Log, accountName string, openAccount bool) (acc *Account, rerr error) { + defer func() { + if !openAccount && acc != nil { + if err := acc.Close(); err != nil && rerr == nil { + rerr = fmt.Errorf("closing account: %v", err) + } + acc = nil + } + }() accSessions := sessions.accounts[accountName] if accSessions == nil { var err error - acc, err = OpenAccount(log, accountName) + acc, err = OpenAccount(log, accountName, openAccount) if err != nil { return nil, err } @@ -70,10 +76,10 @@ func ensureAccountSessions(ctx context.Context, log mlog.Log, accountName string sessions.accounts[accountName] = accSessions } - if acc == nil && alwaysOpenAccount { - return OpenAccount(log, accountName) + if acc == nil && openAccount { + acc, rerr = OpenAccount(log, accountName, true) } - return acc, nil + return } // SessionUse checks if a session is valid. If csrfToken is the empty string, no @@ -83,13 +89,8 @@ func SessionUse(ctx context.Context, log mlog.Log, accountName string, sessionTo sessions.Lock() defer sessions.Unlock() - acc, err := ensureAccountSessions(ctx, log, accountName, false) - if err != nil { + if _, err := ensureAccountSessions(ctx, log, accountName, false); err != nil { return LoginSession{}, err - } else if acc != nil { - if err := acc.Close(); err != nil { - return LoginSession{}, fmt.Errorf("closing account: %w", err) - } } return sessionUse(ctx, log, accountName, sessionToken, csrfToken) @@ -149,7 +150,7 @@ func sessionsDelayedFlush(log mlog.Log, accountName string) { return } - acc, err := OpenAccount(log, accountName) + acc, err := OpenAccount(log, accountName, false) if err != nil && errors.Is(err, ErrAccountUnknown) { // Account may have been removed. Nothing to flush. log.Infox("flushing sessions for account", err, slog.String("account", accountName)) @@ -282,7 +283,10 @@ func SessionRemove(ctx context.Context, log mlog.Log, accountName string, sessio if err != nil { return err } - defer acc.Close() + defer func() { + err := acc.Close() + log.Check(err, "closing account") + }() ls, ok := sessions.accounts[accountName][sessionToken] if !ok { diff --git a/store/threads_test.go b/store/threads_test.go index 8c1f9ce..c0127af 100644 --- a/store/threads_test.go +++ b/store/threads_test.go @@ -19,7 +19,7 @@ func TestThreadingUpgrade(t *testing.T) { os.RemoveAll("../testdata/store/data") mox.ConfigStaticPath = filepath.FromSlash("../testdata/store/mox.conf") mox.MustLoadConfig(true, false) - acc, err := OpenAccount(log, "mjl") + acc, err := OpenAccount(log, "mjl", true) tcheck(t, err, "open account") defer func() { err = acc.Close() @@ -143,7 +143,7 @@ func TestThreadingUpgrade(t *testing.T) { tcheck(t, err, "closing db") // Open the account again, that should get the account upgraded. Wait for upgrade to finish. - acc, err = OpenAccount(log, "mjl") + acc, err = OpenAccount(log, "mjl", true) tcheck(t, err, "open account") err = acc.ThreadingWait(log) tcheck(t, err, "wait for threading") diff --git a/testdata/httpaccount/domains.conf b/testdata/httpaccount/domains.conf index 1af37b3..a24f9f8 100644 --- a/testdata/httpaccount/domains.conf +++ b/testdata/httpaccount/domains.conf @@ -7,6 +7,11 @@ Domains: - mjl☺@mox.example AllowMsgFrom: true Accounts: + disabled: + LoginDisabled: testing + Domain: mox.example + Destinations: + disabled@mox.example: nil mjl☺: Domain: mox.example FullName: mjl diff --git a/testdata/imap/domains.conf b/testdata/imap/domains.conf index b04545d..1c4b09c 100644 --- a/testdata/imap/domains.conf +++ b/testdata/imap/domains.conf @@ -24,3 +24,8 @@ Accounts: Destinations: limit@mox.example: nil QuotaMessageSize: 1 + disabled: + Domain: mox.example + LoginDisabled: testing + Destinations: + disabled@mox.example: nil diff --git a/testdata/smtp/domains.conf b/testdata/smtp/domains.conf index df282f6..d69ea05 100644 --- a/testdata/smtp/domains.conf +++ b/testdata/smtp/domains.conf @@ -12,6 +12,8 @@ Domains: - mjl@mox.example - móx@mox.example mox2.example: nil + disabled.example: + Disabled: true Accounts: mjl: Domain: mox.example @@ -26,6 +28,7 @@ Accounts: móx@mox.example: nil blocked@mox.example: SMTPError: 550 no more messages + mjl@disabled.example: nil JunkFilter: Threshold: 0.9 Params: @@ -39,3 +42,8 @@ Accounts: Domain: mox.example Destinations: ☺@mox.example: nil + disabled: + Domain: mox.example + LoginDisabled: testing + Destinations: + disabled@mox.example: nil diff --git a/testdata/webapisrv/domains.conf b/testdata/webapisrv/domains.conf index 221b6a7..05410af 100644 --- a/testdata/webapisrv/domains.conf +++ b/testdata/webapisrv/domains.conf @@ -7,6 +7,8 @@ Domains: PrivateKeyFile: testsel.rsakey.pkcs8.pem Sign: - testsel + disabled.example: + Disabled: true Accounts: other: Domain: mox.example @@ -22,6 +24,7 @@ Accounts: mjl@mox.example: nil møx@mox.example: nil móx@mox.example: nil + mjl@disabled.example: nil RejectsMailbox: Rejects JunkFilter: Threshold: 0.95 @@ -30,3 +33,8 @@ Accounts: MaxPower: 0.1 TopWords: 10 IgnoreWords: 0.1 + disabled: + Domain: mox.example + LoginDisabled: testing + Destinations: + disabled@mox.example: nil diff --git a/testdata/webmail/domains.conf b/testdata/webmail/domains.conf index e4aad0f..228c9c9 100644 --- a/testdata/webmail/domains.conf +++ b/testdata/webmail/domains.conf @@ -1,4 +1,6 @@ Domains: + disabled.example: + Disabled: true mox.example: DKIM: Selectors: @@ -8,9 +10,15 @@ Domains: - testsel other.example: nil Accounts: + disabled: + LoginDisabled: testing + Domain: mox.example + Destinations: + disabled@mox.example: nil mjl: Domain: mox.example Destinations: + mjl@disabled.example: nil mjl@mox.example: nil mox@other.example: nil móx@mox.example: nil diff --git a/tlsrptsend/send.go b/tlsrptsend/send.go index c7c34a6..33ede81 100644 --- a/tlsrptsend/send.go +++ b/tlsrptsend/send.go @@ -294,7 +294,9 @@ func sendReportDomain(ctx context.Context, log mlog.Log, resolver dns.Resolver, var confDKIM config.DKIM for { confDom, ok := mox.Conf.Domain(fromDom) - if len(confDom.DKIM.Sign) > 0 { + if confDom.Disabled { + return true, fmt.Errorf("domain is temporarily disabled") + } else if len(confDom.DKIM.Sign) > 0 { confDKIM = confDom.DKIM break } else if ok { diff --git a/webaccount/account.go b/webaccount/account.go index c8e7a07..c04f7ff 100644 --- a/webaccount/account.go +++ b/webaccount/account.go @@ -378,7 +378,7 @@ func (Account) SetPassword(ctx context.Context, password string) { } reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - acc, err := store.OpenAccount(log, reqInfo.AccountName) + acc, err := store.OpenAccount(log, reqInfo.AccountName, false) xcheckf(ctx, err, "open account") defer func() { err := acc.Close() @@ -404,7 +404,7 @@ func (Account) Account(ctx context.Context) (account config.Account, storageUsed log := pkglog.WithContext(ctx) reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) - acc, err := store.OpenAccount(log, reqInfo.AccountName) + acc, err := store.OpenAccount(log, reqInfo.AccountName, false) xcheckf(ctx, err, "open account") defer func() { err := acc.Close() @@ -731,7 +731,7 @@ func (Account) TLSPublicKeyUpdate(ctx context.Context, pubKey store.TLSPublicKey reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo) tpk := xtlspublickey(ctx, reqInfo.AccountName, pubKey.Fingerprint) log := pkglog.WithContext(ctx) - acc, _, err := store.OpenEmail(log, pubKey.LoginAddress) + acc, _, err := store.OpenEmail(log, pubKey.LoginAddress, false) if err == nil && acc.Name != reqInfo.AccountName { err = store.ErrUnknownCredentials } diff --git a/webaccount/account.js b/webaccount/account.js index 99e9609..0dc6d3d 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -259,7 +259,7 @@ var api; api.stringsTypes = { "CSRFToken": true, "Localpart": true, "OutgoingEvent": true }; api.intsTypes = {}; api.types = { - "Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] }, + "Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginDisabled", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] }, "OutgoingWebhook": { "Name": "OutgoingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }, { "Name": "Events", "Docs": "", "Typewords": ["[]", "string"] }] }, "IncomingWebhook": { "Name": "IncomingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }] }, "Destination": { "Name": "Destination", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Rulesets", "Docs": "", "Typewords": ["[]", "Ruleset"] }, { "Name": "SMTPError", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }] }, diff --git a/webaccount/account_test.go b/webaccount/account_test.go index d4ce9a1..684b210 100644 --- a/webaccount/account_test.go +++ b/webaccount/account_test.go @@ -98,7 +98,7 @@ func TestAccount(t *testing.T) { mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf") mox.MustLoadConfig(true, false) log := mlog.New("webaccount", nil) - acc, err := store.OpenAccount(log, "mjl☺") + acc, err := store.OpenAccount(log, "mjl☺", false) tcheck(t, err, "open account") err = acc.SetPassword(log, "test1234") tcheck(t, err, "set password") @@ -145,6 +145,21 @@ func TestAccount(t *testing.T) { tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@mox.example", "badauth") }) tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@baddomain.example", "badauth") }) + acc2, err := store.OpenAccount(log, "disabled", false) + tcheck(t, err, "open account") + err = acc2.SetPassword(log, "test1234") + tcheck(t, err, "set password") + acc2.Close() + tcheck(t, err, "close account") + + loginReqInfo2 := requestInfo{"", "", "", httptest.NewRecorder(), &http.Request{RemoteAddr: "1.1.1.1:1234"}} + loginctx2 := context.WithValue(ctxbg, requestInfoCtxKey, loginReqInfo2) + loginCookie2 := &http.Cookie{Name: "webaccountlogin"} + loginCookie2.Value = api.LoginPrep(loginctx2) + loginReqInfo2.Request.Header = http.Header{"Cookie": []string{loginCookie2.String()}} + tneedErrorCode(t, "user:loginFailed", func() { api.Login(loginctx2, loginCookie2.Value, "disabled@mox.example", "test1234") }) + tneedErrorCode(t, "user:loginFailed", func() { api.Login(loginctx2, loginCookie2.Value, "disabled@mox.example", "bogus") }) + type httpHeaders [][2]string ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"} diff --git a/webaccount/api.json b/webaccount/api.json index 646bf8d..39d98a2 100644 --- a/webaccount/api.json +++ b/webaccount/api.json @@ -574,6 +574,13 @@ "int64" ] }, + { + "Name": "LoginDisabled", + "Docs": "", + "Typewords": [ + "string" + ] + }, { "Name": "Domain", "Docs": "", diff --git a/webaccount/api.ts b/webaccount/api.ts index 390f9d6..cf9f1e0 100644 --- a/webaccount/api.ts +++ b/webaccount/api.ts @@ -8,6 +8,7 @@ export interface Account { FromIDLoginAddresses?: string[] | null KeepRetiredMessagePeriod: number KeepRetiredWebhookPeriod: number + LoginDisabled: string Domain: string Description: string FullName: string @@ -258,7 +259,7 @@ export const structTypes: {[typename: string]: boolean} = {"Account":true,"Addre export const stringsTypes: {[typename: string]: boolean} = {"CSRFToken":true,"Localpart":true,"OutgoingEvent":true} export const intsTypes: {[typename: string]: boolean} = {} export const types: TypenameMap = { - "Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]}, + "Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"LoginDisabled","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]}, "OutgoingWebhook": {"Name":"OutgoingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]},{"Name":"Events","Docs":"","Typewords":["[]","string"]}]}, "IncomingWebhook": {"Name":"IncomingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]}]}, "Destination": {"Name":"Destination","Docs":"","Fields":[{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Rulesets","Docs":"","Typewords":["[]","Ruleset"]},{"Name":"SMTPError","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]}]}, diff --git a/webaccount/import.go b/webaccount/import.go index d523a08..664017b 100644 --- a/webaccount/import.go +++ b/webaccount/import.go @@ -256,7 +256,7 @@ func importStart(log mlog.Log, accName string, f *os.File, skipMailboxPrefix str tr = tar.NewReader(gzr) } - acc, err := store.OpenAccount(log, accName) + acc, err := store.OpenAccount(log, accName, false) if err != nil { return "", false, fmt.Errorf("open acount: %v", err) } diff --git a/webadmin/admin.go b/webadmin/admin.go index 9cb5276..44ce4b7 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -1532,14 +1532,9 @@ When enabling MTA-STS, or updating a policy, always update the policy first (thr return } -// Domains returns all configured domain names, in UTF-8 for IDNA domains. -func (Admin) Domains(ctx context.Context) []dns.Domain { - l := []dns.Domain{} - for _, s := range mox.Conf.Domains() { - d, _ := dns.ParseDomain(s) - l = append(l, d) - } - return l +// Domains returns all configured domain names. +func (Admin) Domains(ctx context.Context) []config.Domain { + return mox.Conf.DomainConfigs() } // Domain returns the dns domain for a (potentially unicode as IDNA) domain name. @@ -1582,20 +1577,20 @@ func (Admin) DomainLocalparts(ctx context.Context, domain string) (localpartAcco return mox.Conf.DomainLocalparts(d) } -// Accounts returns the names of all configured accounts. -func (Admin) Accounts(ctx context.Context) []string { - l := mox.Conf.Accounts() - sort.Slice(l, func(i, j int) bool { - return l[i] < l[j] +// Accounts returns the names of all configured and all disabled accounts. +func (Admin) Accounts(ctx context.Context) (all, disabled []string) { + all, disabled = mox.Conf.AccountsDisabled() + sort.Slice(all, func(i, j int) bool { + return all[i] < all[j] }) - return l + return } // Account returns the parsed configuration of an account. func (Admin) Account(ctx context.Context, account string) (accountConfig config.Account, diskUsage int64) { log := pkglog.WithContext(ctx) - acc, err := store.OpenAccount(log, account) + acc, err := store.OpenAccount(log, account, false) if err != nil && errors.Is(err, store.ErrAccountUnknown) { xcheckuserf(ctx, err, "looking up account") } @@ -1951,11 +1946,11 @@ func DomainRecords(ctx context.Context, log mlog.Log, domain string) []string { } // DomainAdd adds a new domain and reloads the configuration. -func (Admin) DomainAdd(ctx context.Context, domain, accountName, localpart string) { +func (Admin) DomainAdd(ctx context.Context, disabled bool, domain, accountName, localpart string) { d, err := dns.ParseDomain(domain) xcheckuserf(ctx, err, "parsing domain") - err = admin.DomainAdd(ctx, d, accountName, smtp.Localpart(norm.NFC.String(localpart))) + err = admin.DomainAdd(ctx, disabled, d, accountName, smtp.Localpart(norm.NFC.String(localpart))) xcheckf(ctx, err, "adding domain") } @@ -2001,7 +1996,7 @@ func (Admin) SetPassword(ctx context.Context, accountName, password string) { if len(password) < 8 { xusererrorf(ctx, "message must be at least 8 characters") } - acc, err := store.OpenAccount(log, accountName) + acc, err := store.OpenAccount(log, accountName, false) xcheckf(ctx, err, "open account") defer func() { err := acc.Close() @@ -2022,6 +2017,26 @@ func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOut xcheckf(ctx, err, "saving account settings") } +// AccountLoginDisabledSave saves the LoginDisabled field of an account. +func (Admin) AccountLoginDisabledSave(ctx context.Context, accountName string, loginDisabled string) { + log := pkglog.WithContext(ctx) + + acc, err := store.OpenAccount(log, accountName, false) + xcheckf(ctx, err, "open account") + defer func() { + err := acc.Close() + log.Check(err, "closing account") + }() + + err = admin.AccountSave(ctx, accountName, func(acc *config.Account) { + acc.LoginDisabled = loginDisabled + }) + xcheckf(ctx, err, "saving login disabled account") + + err = acc.SessionsClear(ctx, log) + xcheckf(ctx, err, "removing current sessions") +} + // ClientConfigsDomain returns configurations for email clients, IMAP and // Submission (SMTP) for the domain. func (Admin) ClientConfigsDomain(ctx context.Context, domain string) admin.ClientConfigs { @@ -2640,6 +2655,17 @@ func (Admin) DomainDKIMSave(ctx context.Context, domainName string, selectors ma xcheckf(ctx, err, "saving dkim selector for domain") } +// DomainDisabledSave saves the Disabled field of a domain. A disabled domain +// rejects incoming/outgoing messages involving the domain and does not request new +// TLS certificats with ACME. +func (Admin) DomainDisabledSave(ctx context.Context, domainName string, disabled bool) { + err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error { + d.Disabled = disabled + return nil + }) + xcheckf(ctx, err, "saving disabled setting for domain") +} + func xparseAddress(ctx context.Context, lp, domain string) smtp.Address { xlp, err := smtp.ParseLocalpart(lp) xcheckuserf(ctx, err, "parsing localpart") diff --git a/webadmin/admin.js b/webadmin/admin.js index a72eeef..92e9a7e 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -285,7 +285,7 @@ var api; "AutoconfCheckResult": { "Name": "AutoconfCheckResult", "Docs": "", "Fields": [{ "Name": "ClientSettingsDomainIPs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "IPs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, "AutodiscoverCheckResult": { "Name": "AutodiscoverCheckResult", "Docs": "", "Fields": [{ "Name": "Records", "Docs": "", "Typewords": ["[]", "AutodiscoverSRV"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, "AutodiscoverSRV": { "Name": "AutodiscoverSRV", "Docs": "", "Fields": [{ "Name": "Target", "Docs": "", "Typewords": ["string"] }, { "Name": "Port", "Docs": "", "Typewords": ["uint16"] }, { "Name": "Priority", "Docs": "", "Typewords": ["uint16"] }, { "Name": "Weight", "Docs": "", "Typewords": ["uint16"] }, { "Name": "IPs", "Docs": "", "Typewords": ["[]", "string"] }] }, - "ConfigDomain": { "Name": "ConfigDomain", "Docs": "", "Fields": [{ "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "ClientSettingsDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }, { "Name": "DKIM", "Docs": "", "Typewords": ["DKIM"] }, { "Name": "DMARC", "Docs": "", "Typewords": ["nullable", "DMARC"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["nullable", "MTASTS"] }, { "Name": "TLSRPT", "Docs": "", "Typewords": ["nullable", "TLSRPT"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["{}", "Alias"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, + "ConfigDomain": { "Name": "ConfigDomain", "Docs": "", "Fields": [{ "Name": "Disabled", "Docs": "", "Typewords": ["bool"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "ClientSettingsDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }, { "Name": "DKIM", "Docs": "", "Typewords": ["DKIM"] }, { "Name": "DMARC", "Docs": "", "Typewords": ["nullable", "DMARC"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["nullable", "MTASTS"] }, { "Name": "TLSRPT", "Docs": "", "Typewords": ["nullable", "TLSRPT"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["{}", "Alias"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "DKIM": { "Name": "DKIM", "Docs": "", "Fields": [{ "Name": "Selectors", "Docs": "", "Typewords": ["{}", "Selector"] }, { "Name": "Sign", "Docs": "", "Typewords": ["[]", "string"] }] }, "Selector": { "Name": "Selector", "Docs": "", "Fields": [{ "Name": "Hash", "Docs": "", "Typewords": ["string"] }, { "Name": "HashEffective", "Docs": "", "Typewords": ["string"] }, { "Name": "Canonicalization", "Docs": "", "Typewords": ["Canonicalization"] }, { "Name": "Headers", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HeadersEffective", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "DontSealHeaders", "Docs": "", "Typewords": ["bool"] }, { "Name": "Expiration", "Docs": "", "Typewords": ["string"] }, { "Name": "PrivateKeyFile", "Docs": "", "Typewords": ["string"] }, { "Name": "Algorithm", "Docs": "", "Typewords": ["string"] }] }, "Canonicalization": { "Name": "Canonicalization", "Docs": "", "Fields": [{ "Name": "HeaderRelaxed", "Docs": "", "Typewords": ["bool"] }, { "Name": "BodyRelaxed", "Docs": "", "Typewords": ["bool"] }] }, @@ -298,7 +298,7 @@ var api; "Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Localpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "Destination": { "Name": "Destination", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Rulesets", "Docs": "", "Typewords": ["[]", "Ruleset"] }, { "Name": "SMTPError", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }] }, "Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "HeadersRegexp", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListAllowDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "AcceptRejectsToMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, - "Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] }, + "Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginDisabled", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] }, "OutgoingWebhook": { "Name": "OutgoingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }, { "Name": "Events", "Docs": "", "Typewords": ["[]", "string"] }] }, "IncomingWebhook": { "Name": "IncomingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }] }, "SubjectPass": { "Name": "SubjectPass", "Docs": "", "Fields": [{ "Name": "Period", "Docs": "", "Typewords": ["int64"] }] }, @@ -551,11 +551,11 @@ var api; const params = [domainName]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } - // Domains returns all configured domain names, in UTF-8 for IDNA domains. + // Domains returns all configured domain names. async Domains() { const fn = "Domains"; const paramTypes = []; - const returnTypes = [["[]", "Domain"]]; + const returnTypes = [["[]", "ConfigDomain"]]; const params = []; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } @@ -591,11 +591,11 @@ var api; const params = [domain]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } - // Accounts returns the names of all configured accounts. + // Accounts returns the names of all configured and all disabled accounts. async Accounts() { const fn = "Accounts"; const paramTypes = []; - const returnTypes = [["[]", "string"]]; + const returnTypes = [["[]", "string"], ["[]", "string"]]; const params = []; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } @@ -718,11 +718,11 @@ var api; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } // DomainAdd adds a new domain and reloads the configuration. - async DomainAdd(domain, accountName, localpart) { + async DomainAdd(disabled, domain, accountName, localpart) { const fn = "DomainAdd"; - const paramTypes = [["string"], ["string"], ["string"]]; + const paramTypes = [["bool"], ["string"], ["string"], ["string"]]; const returnTypes = []; - const params = [domain, accountName, localpart]; + const params = [disabled, domain, accountName, localpart]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } // DomainRemove removes an existing domain and reloads the configuration. @@ -784,6 +784,14 @@ var api; const params = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize, firstTimeSenderDelay]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + // AccountLoginDisabledSave saves the LoginDisabled field of an account. + async AccountLoginDisabledSave(accountName, loginDisabled) { + const fn = "AccountLoginDisabledSave"; + const paramTypes = [["string"], ["string"]]; + const returnTypes = []; + const params = [accountName, loginDisabled]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } // ClientConfigsDomain returns configurations for email clients, IMAP and // Submission (SMTP) for the domain. async ClientConfigsDomain(domain) { @@ -1258,6 +1266,16 @@ var api; const params = [domainName, selectors, sign]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + // DomainDisabledSave saves the Disabled field of a domain. A disabled domain + // rejects incoming/outgoing messages involving the domain and does not request new + // TLS certificats with ACME. + async DomainDisabledSave(domainName, disabled) { + const fn = "DomainDisabledSave"; + const paramTypes = [["string"], ["bool"]]; + const returnTypes = []; + const params = [domainName, disabled]; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } async AliasAdd(aliaslp, domainName, alias) { const fn = "AliasAdd"; const paramTypes = [["string"], ["string"], ["Alias"]]; @@ -1916,7 +1934,7 @@ const formatSize = (n) => { return n + ' bytes'; }; const index = async () => { - const [domains, queueSize, hooksQueueSize, checkUpdatesEnabled, accounts] = await Promise.all([ + const [domains, queueSize, hooksQueueSize, checkUpdatesEnabled, [accounts, accountsDisabled]] = await Promise.all([ client.Domains(), client.QueueSize(), client.HookQueueSize(), @@ -1924,6 +1942,7 @@ const index = async () => { client.Accounts(), ]); let fieldset; + let disabled; let domain; let account; let localpart; @@ -1931,12 +1950,12 @@ const index = async () => { let recvID; let cidElem; return dom.div(crumbs('Mox Admin'), checkUpdatesEnabled ? [] : dom.p(box(yellow, 'Warning: Checking for updates has not been enabled in mox.conf (CheckUpdates: true).', dom.br(), 'Make sure you stay up to date through another mechanism!', dom.br(), 'You have a responsibility to keep the internet-connected software you run up to date and secure!', dom.br(), 'See ', link('https://updates.xmox.nl/changelog'))), dom.p(dom.a('Accounts', attr.href('#accounts')), dom.br(), dom.a('Queue', attr.href('#queue')), ' (' + queueSize + ')', dom.br(), dom.a('Webhook queue', attr.href('#webhookqueue')), ' (' + hooksQueueSize + ')', dom.br()), dom.h2('Domains'), (domains || []).length === 0 ? box(red, 'No domains') : - dom.ul((domains || []).map(d => dom.li(dom.a(attr.href('#domains/' + domainName(d)), domainString(d))))), dom.br(), dom.h2('Add domain'), dom.form(async function submit(e) { + dom.ul((domains || []).map(d => dom.li(dom.a(attr.href('#domains/' + domainName(d.Domain)), domainString(d.Domain)), d.Disabled ? ' (disabled)' : []))), dom.br(), dom.h2('Add domain'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); - await check(fieldset, client.DomainAdd(domain.value, account.value, localpart.value)); + await check(fieldset, client.DomainAdd(disabled.checked, domain.value, account.value, localpart.value)); window.location.hash = '#domains/' + domain.value; - }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Domain', attr.title('Domain for incoming/outgoing email to add to mox. Can also be a subdomain of a domain already configured.')), dom.br(), domain = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Postmaster/reporting account', attr.title('Account that is considered the owner of this domain. If the account does not yet exist, it will be created and a a localpart is required for the initial email address.')), dom.br(), account = dom.input(attr.required(''), attr.list('accountList')), dom.datalist(attr.id('accountList'), (accounts || []).map(a => dom.option(a)))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Localpart (if new account)', attr.title('Must be set if and only if account does not yet exist. A localpart is the part before the "@"-sign of an email address. An account requires an email address, so creating a new account for a domain requires a localpart to form an initial email address.')), dom.br(), localpart = dom.input()), ' ', dom.submitbutton('Add domain', attr.title('Domain will be added and the config reloaded. Add the required DNS records after adding the domain.')))), dom.br(), dom.h2('Reports'), dom.div(dom.a('DMARC', attr.href('#dmarc/reports'))), dom.div(dom.a('TLS', attr.href('#tlsrpt/reports'))), dom.br(), dom.h2('Operations'), dom.div(dom.a('MTA-STS policies', attr.href('#mtasts'))), dom.div(dom.a('DMARC evaluations', attr.href('#dmarc/evaluations'))), dom.div(dom.a('TLS connection results', attr.href('#tlsrpt/results'))), dom.div(dom.a('DNSBL', attr.href('#dnsbl'))), dom.div(style({ marginTop: '.5ex' }), dom.form(async function submit(e) { + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Domain', attr.title('Domain for incoming/outgoing email to add to mox. Can also be a subdomain of a domain already configured.')), dom.br(), domain = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Postmaster/reporting account', attr.title('Account that is considered the owner of this domain. If the account does not yet exist, it will be created and a a localpart is required for the initial email address.')), dom.br(), account = dom.input(attr.required(''), attr.list('accountList')), dom.datalist(attr.id('accountList'), (accounts || []).map(a => dom.option(attr.value(a), a + (accountsDisabled?.includes(a) ? ' (disabled)' : ''))))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Localpart (if new account)', attr.title('Must be set if and only if account does not yet exist. A localpart is the part before the "@"-sign of an email address. An account requires an email address, so creating a new account for a domain requires a localpart to form an initial email address.')), dom.br(), localpart = dom.input()), ' ', dom.label(disabled = dom.input(attr.type('checkbox')), ' Disabled', attr.title('Disabled domains do fetch new certificates with ACME and do not accept incoming or outgoing messages involving the domain. Accounts and addresses referencing a disabled domain can be created. USeful during/before migrations.')), ' ', dom.submitbutton('Add domain', attr.title('Domain will be added and the config reloaded. Add the required DNS records after adding the domain.')))), dom.br(), dom.h2('Reports'), dom.div(dom.a('DMARC', attr.href('#dmarc/reports'))), dom.div(dom.a('TLS', attr.href('#tlsrpt/reports'))), dom.br(), dom.h2('Operations'), dom.div(dom.a('MTA-STS policies', attr.href('#mtasts'))), dom.div(dom.a('DMARC evaluations', attr.href('#dmarc/evaluations'))), dom.div(dom.a('TLS connection results', attr.href('#tlsrpt/results'))), dom.div(dom.a('DNSBL', attr.href('#dnsbl'))), dom.div(style({ marginTop: '.5ex' }), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); dom._kids(cidElem); @@ -2000,7 +2019,7 @@ const inlineBox = (color, ...l) => dom.span(style({ borderRadius: '3px', }), l); const accounts = async () => { - const [accounts, domains] = await Promise.all([ + const [[accounts, accountsDisabled], domains] = await Promise.all([ client.Accounts(), client.Domains(), ]); @@ -2010,7 +2029,7 @@ const accounts = async () => { let account; let accountModified = false; return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Accounts'), dom.h2('Accounts'), (accounts || []).length === 0 ? dom.p('No accounts') : - dom.ul((accounts || []).map(s => dom.li(dom.a(s, attr.href('#accounts/' + s))))), dom.br(), dom.h2('Add account'), dom.form(async function submit(e) { + dom.ul((accounts || []).map(s => dom.li(dom.a(attr.href('#accounts/' + s), s), accountsDisabled?.includes(s) ? ' (disabled)' : ''))), dom.br(), dom.h2('Add account'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); await check(fieldset, client.AccountAdd(account.value, localpart.value + '@' + domain.value)); @@ -2019,7 +2038,7 @@ const accounts = async () => { if (!accountModified) { account.value = localpart.value; } - })), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain', attr.title('The domain of the email address, after the "@".')), dom.br(), domain = dom.select(attr.required(''), (domains || []).map(d => dom.option(domainName(d))))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account name', attr.title('An account has a password, and email address(es) (possibly at different domains). Its messages and the message index database are are stored in the file system in a directory with the name of the account. An account name is not an email address. Use a name like a unix user name, or the localpart (the part before the "@") of the initial address.')), dom.br(), account = dom.input(attr.required(''), function change() { + })), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain', attr.title('The domain of the email address, after the "@".')), dom.br(), domain = dom.select(attr.required(''), (domains || []).map(d => dom.option(domainName(d.Domain))))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account name', attr.title('An account has a password, and email address(es) (possibly at different domains). Its messages and the message index database are are stored in the file system in a directory with the name of the account. An account name is not an email address. Use a name like a unix user name, or the localpart (the part before the "@") of the initial address.')), dom.br(), account = dom.input(attr.required(''), function change() { accountModified = true; })), ' ', dom.submitbutton('Add account', attr.title('The account will be added and the config reloaded.'))))); }; @@ -2144,7 +2163,7 @@ const account = async (name) => { } return v * mult; }; - return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), name), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Action'))), dom.tbody(Object.keys(config.Destinations || {}).length === 0 ? dom.tr(dom.td(attr.colspan('2'), '(None, login disabled)')) : [], Object.keys(config.Destinations || {}).map(k => { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), name), config.LoginDisabled ? dom.p(box(yellow, 'Warning: Login for this account is disabled with message: ' + config.LoginDisabled)) : [], dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Action'))), dom.tbody(Object.keys(config.Destinations || {}).length === 0 ? dom.tr(dom.td(attr.colspan('2'), '(None, login disabled)')) : [], Object.keys(config.Destinations || {}).map(k => { let v = k; const t = k.split('@'); if (t.length > 1) { @@ -2175,7 +2194,7 @@ const account = async (name) => { await check(fieldset, client.AddressAdd(address, name)); form.reset(); window.location.reload(); // todo: only reload the destinations - }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an email address. If empty, a catchall address is configured for the domain.')), dom.br(), localpart = dom.input()), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain'), dom.br(), domain = dom.select((domains || []).map(d => dom.option(domainName(d), domainName(d) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Alias (list) membership'), dom.table(dom.thead(dom.tr(dom.th('Alias address', attr.title('Messages sent to this address will be delivered to all members of the alias/list. A member does not receive a message if their address is in the message From header.')), dom.th('Subscription address'), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')))), (config.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('6'), 'None')) : [], (config.Aliases || []).sort((a, b) => a.Alias.LocalpartStr < b.Alias.LocalpartStr ? -1 : (domainName(a.Alias.Domain) < domainName(b.Alias.Domain) ? -1 : 1)).map(a => dom.tr(dom.td(dom.a(prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)), attr.href('#domains/' + domainName(a.Alias.Domain) + '/alias/' + encodeURIComponent(a.Alias.LocalpartStr)))), dom.td(prewrap(a.SubscriptionAddress)), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td(a.Alias.ListMembers ? 'Yes' : 'No'), dom.td(dom.clickbutton('Remove', async function click(e) { + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an email address. If empty, a catchall address is configured for the domain.')), dom.br(), localpart = dom.input()), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain'), dom.br(), domain = dom.select((domains || []).map(d => dom.option(domainName(d.Domain), domainName(d.Domain) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Alias (list) membership'), dom.table(dom.thead(dom.tr(dom.th('Alias address', attr.title('Messages sent to this address will be delivered to all members of the alias/list. A member does not receive a message if their address is in the message From header.')), dom.th('Subscription address'), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')))), (config.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('6'), 'None')) : [], (config.Aliases || []).sort((a, b) => a.Alias.LocalpartStr < b.Alias.LocalpartStr ? -1 : (domainName(a.Alias.Domain) < domainName(b.Alias.Domain) ? -1 : 1)).map(a => dom.tr(dom.td(dom.a(prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)), attr.href('#domains/' + domainName(a.Alias.Domain) + '/alias/' + encodeURIComponent(a.Alias.LocalpartStr)))), dom.td(prewrap(a.SubscriptionAddress)), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td(a.Alias.ListMembers ? 'Yes' : 'No'), dom.td(dom.clickbutton('Remove', async function click(e) { await check(e.target, client.AliasAddressesRemove(a.Alias.LocalpartStr, domainName(a.Alias.Domain), [a.SubscriptionAddress])); window.location.reload(); // todo: reload less }))))), dom.br(), dom.h2('Settings'), dom.form(fieldsetSettings = dom.fieldset(dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum outgoing messages per day', attr.title('Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000. MaxOutgoingMessagesPerDay in configuration file.')), dom.br(), maxOutgoingMessagesPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxOutgoingMessagesPerDay || 1000)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum first-time recipients per day', attr.title('Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200. MaxFirstTimeRecipientsPerDay in configuration file.')), dom.br(), maxFirstTimeRecipientsPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxFirstTimeRecipientsPerDay || 200)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage. Use units "k" for kilobytes, or "m", "g", "t".')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize))), ' Current usage is ', formatQuotaSize(Math.floor(diskUsage / (1024 * 1024)) * 1024 * 1024), '.'), dom.div(style({ display: 'block', marginBottom: '.5ex' }), dom.label(firstTimeSenderDelay = dom.input(attr.type('checkbox'), config.NoFirstTimeSenderDelay ? [] : attr.checked('')), ' ', dom.span('Delay deliveries from first-time senders.', attr.title('To slow down potential spammers, when the message is misclassified as non-junk. Turning off the delay can be useful when the account processes messages automatically and needs fast responses.')))), dom.submitbutton('Save')), async function submit(e) { @@ -2207,7 +2226,25 @@ const account = async (name) => { }), dom.br(), dom.h2('TLS public keys', attr.title('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.')), dom.table(dom.thead(dom.tr(dom.th('Login address'), dom.th('Name'), dom.th('Type'), dom.th('No IMAP "preauth"', attr.title('New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.')), dom.th('Fingerprint'))), dom.tbody(tlspubkeys?.length ? [] : dom.tr(dom.td(attr.colspan('5'), 'None')), (tlspubkeys || []).map(tpk => { const row = dom.tr(dom.td(tpk.LoginAddress), dom.td(tpk.Name), dom.td(tpk.Type), dom.td(tpk.NoIMAPPreauth ? 'Enabled' : ''), dom.td(tpk.Fingerprint)); return row; - }))), dom.br(), RoutesEditor('account-specific', transports, config.Routes || [], async (routes) => await client.AccountRoutesSave(name, routes)), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove account', async function click(e) { + }))), dom.br(), RoutesEditor('account-specific', transports, config.Routes || [], async (routes) => await client.AccountRoutesSave(name, routes)), dom.br(), dom.h2('Danger'), dom.div(config.LoginDisabled ? [ + box(yellow, 'Account login is currently disabled.'), + dom.clickbutton('Enable account login', async function click(e) { + if (window.confirm('Are you sure you want to enable login to this account?')) { + await check(e.target, client.AccountLoginDisabledSave(name, '')); + window.location.reload(); // todo: update account and rerender. + } + }) + ] : dom.clickbutton('Disable account login', function click() { + let fieldset; + let loginDisabled; + const close = popup(dom.h1('Disable account login'), dom.form(async function submit(e) { + e.preventDefault(); + e.stopPropagation(); + await check(fieldset, client.AccountLoginDisabledSave(name, loginDisabled.value)); + close(); + window.location.reload(); // todo: update account and rerender. + }, fieldset = dom.fieldset(dom.label(dom.div('Message to user'), loginDisabled = dom.input(attr.required(''), style({ width: '100%' })), dom.p(style({ fontStyle: 'italic' }), 'Will be shown to user on login attempts. Single line, no special and maximum 256 characters since message is used in IMAP/SMTP.')), dom.div(dom.submitbutton('Disable login'))))); + })), dom.br(), dom.clickbutton('Remove account', async function click(e) { e.preventDefault(); if (!window.confirm('Are you sure you want to remove this account? All account data, including messages will be removed.')) { return; @@ -2272,16 +2309,16 @@ const formatDuration = (v, goDuration) => { const domain = async (d) => { const end = new Date(); const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); - const [dmarcSummaries, tlsrptSummaries, [localpartAccounts, localpartAliases], dnsdomain, clientConfigs, accounts, domainConfig, transports] = await Promise.all([ + const [dmarcSummaries, tlsrptSummaries, [localpartAccounts, localpartAliases], clientConfigs, [accounts, accountsDisabled], domainConfig, transports] = await Promise.all([ client.DMARCSummaries(start, end, d), client.TLSRPTSummaries(start, end, d), client.DomainLocalparts(d), - client.ParseDomain(d), client.ClientConfigsDomain(d), client.Accounts(), client.DomainConfig(d), client.Transports(), ]); + const dnsdomain = domainConfig.Domain; let addrForm; let addrFieldset; let addrLocalpart; @@ -2352,7 +2389,7 @@ const domain = async (d) => { window.location.reload(); // todo: reload only dkim section }, fieldset = dom.fieldset(dom.div(style({ display: 'flex', gap: '1em' }), dom.div(dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Selector', attr.title('Used in the DKIM-Signature header, and used to form a DNS record under ._domainkey..'), dom.div(selector = dom.input(attr.required(''), attr.value(defaultSelector())))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Algorithm', attr.title('For signing messages. RSA is common at the time of writing, not all mail servers recognize ed25519 signature.'), dom.div(algorithm = dom.select(dom.option('rsa'), dom.option('ed25519')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Hash', attr.title("Used in signing messages. Don't use sha1 unless you understand the consequences."), dom.div(hash = dom.select(dom.option('sha256')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Canonicalization - header', attr.title('Canonicalization processes the message headers before signing. Relaxed allows more whitespace changes, making it more likely for DKIM signatures to validate after transit through servers that make whitespace modifications. Simple is more strict.'), dom.div(canonHeader = dom.select(dom.option('relaxed'), dom.option('simple')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Canonicalization - body', attr.title('Like canonicalization for headers, but for the bodies.'), dom.div(canonBody = dom.select(dom.option('relaxed'), dom.option('simple')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Signature lifetime', attr.title('How long a signature remains valid. Should be as long as a message may take to be delivered. The signature must be valid at the time a message is being delivered to the final destination.'), dom.div(lifetime = dom.input(attr.value('3d'), attr.required('')))), dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Seal headers', attr.title("DKIM-signatures cover headers. If headers are not sealed, additional message headers can be added with the same key without invalidating the signature. This may confuse software about which headers are trustworthy. Sealing is the safer option."), dom.div(seal = dom.input(attr.type('checkbox'), attr.checked(''))))), dom.div(dom.label(style({ display: 'block', marginBottom: '1ex' }), 'Headers (optional)', attr.title('Headers to sign. If left empty, a set of standard headers are signed. The (standard set of) headers are most easily edited after creating the selector/key.'), dom.div(headers = dom.textarea(attr.rows('15')))))), dom.div(dom.submitbutton('Add'))))); }; - return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Domain ' + domainString(dnsdomain)), dom.ul(dom.li(dom.a('Required DNS records', attr.href('#domains/' + d + '/dnsrecords'))), dom.li(dom.a('Check current actual DNS records and domain configuration', attr.href('#domains/' + d + '/dnscheck')))), dom.br(), dom.h2('Client configuration'), dom.p('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password. ', dom.span('Explicitly configure', attr.title('To prevent authentication mechanism downgrade attempts that may result in clients sending plain text passwords to a MitM.')), ' the first supported authentication mechanism: SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS, SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5.'), dom.table(dom.thead(dom.tr(dom.th('Protocol'), dom.th('Host'), dom.th('Port'), dom.th('Listener'), dom.th('Note'))), dom.tbody((clientConfigs.Entries || []).map(e => dom.tr(dom.td(e.Protocol), dom.td(domainString(e.Host)), dom.td('' + e.Port), dom.td('' + e.Listener), dom.td('' + e.Note))))), dom.br(), dom.h2('DMARC aggregate reports summary'), renderDMARCSummaries(dmarcSummaries || []), dom.br(), dom.h2('TLS reports summary'), renderTLSRPTSummaries(tlsrptSummaries || []), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th('Action'))), dom.tbody(Object.entries(localpartAccounts).map(t => dom.tr(dom.td(prewrap(t[0]) || '(catchall)'), dom.td(dom.a(t[1], attr.href('#accounts/' + t[1]))), dom.td(dom.clickbutton('Remove', async function click(e) { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Domain ' + domainString(dnsdomain)), domainConfig.Disabled ? dom.p(box(yellow, 'Warning: Domain is disabled. Incoming/outgoing messages involving this domain are rejected and ACME for new TLS certificates is disabled.')) : [], dom.ul(dom.li(dom.a('Required DNS records', attr.href('#domains/' + d + '/dnsrecords'))), dom.li(dom.a('Check current actual DNS records and domain configuration', attr.href('#domains/' + d + '/dnscheck')))), dom.br(), dom.h2('Client configuration'), dom.p('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password. ', dom.span('Explicitly configure', attr.title('To prevent authentication mechanism downgrade attempts that may result in clients sending plain text passwords to a MitM.')), ' the first supported authentication mechanism: SCRAM-SHA-256-PLUS, SCRAM-SHA-1-PLUS, SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5.'), dom.table(dom.thead(dom.tr(dom.th('Protocol'), dom.th('Host'), dom.th('Port'), dom.th('Listener'), dom.th('Note'))), dom.tbody((clientConfigs.Entries || []).map(e => dom.tr(dom.td(e.Protocol), dom.td(domainString(e.Host)), dom.td('' + e.Port), dom.td('' + e.Listener), dom.td('' + e.Note))))), dom.br(), dom.h2('DMARC aggregate reports summary'), renderDMARCSummaries(dmarcSummaries || []), dom.br(), dom.h2('TLS reports summary'), renderTLSRPTSummaries(tlsrptSummaries || []), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th('Action'))), dom.tbody(Object.entries(localpartAccounts).map(t => dom.tr(dom.td(prewrap(t[0]) || '(catchall)'), dom.td(dom.a(t[1], attr.href('#accounts/' + t[1]))), dom.td(dom.clickbutton('Remove', async function click(e) { e.preventDefault(); if (!window.confirm('Are you sure you want to remove this address? If it is a member of an alias, it will be removed from the alias.')) { return; @@ -2365,7 +2402,7 @@ const domain = async (d) => { await check(addrFieldset, client.AddressAdd(addrLocalpart.value + '@' + d, addrAccount.value)); addrForm.reset(); window.location.reload(); // todo: only reload the addresses - }, addrFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an address. An empty localpart is the catchall destination/address for the domain.')), dom.br(), addrLocalpart = dom.input()), '@', domainName(dnsdomain), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account', attr.title('Account to assign the address to.')), dom.br(), addrAccount = dom.select(attr.required(''), (accounts || []).map(a => dom.option(a)))), ' ', dom.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')))), dom.br(), dom.h2('Aliases (lists)'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')))), Object.values(localpartAliases).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'None')) : [], Object.values(localpartAliases).sort((a, b) => a.LocalpartStr < b.LocalpartStr ? -1 : 1).map(a => { + }, addrFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an address. An empty localpart is the catchall destination/address for the domain.')), dom.br(), addrLocalpart = dom.input()), '@', domainName(dnsdomain), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account', attr.title('Account to assign the address to.')), dom.br(), addrAccount = dom.select(attr.required(''), (accounts || []).map(a => dom.option(attr.value(a), a + (accountsDisabled?.includes(a) ? ' (disabled)' : ''))))), ' ', dom.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')))), dom.br(), dom.h2('Aliases (lists)'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')))), Object.values(localpartAliases).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'None')) : [], Object.values(localpartAliases).sort((a, b) => a.LocalpartStr < b.LocalpartStr ? -1 : 1).map(a => { return dom.tr(dom.td(dom.a(prewrap(a.LocalpartStr), attr.href('#domains/' + d + '/alias/' + encodeURIComponent(a.LocalpartStr)))), dom.td(a.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.AllowMsgFrom ? 'Yes' : 'No'), dom.td(a.ListMembers ? 'Yes' : 'No')); })), dom.br(), dom.h2('Add alias (list)'), dom.form(async function submit(e) { e.preventDefault(); @@ -2415,7 +2452,7 @@ const domain = async (d) => { domainConfig.DMARC = null; } } - }, dmarcFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('Address-part before the @ that accepts DMARC reports. Must be non-internationalized. Recommended value: dmarc-reports.'), dom.div('Localpart'), dmarcLocalpart = dom.input(attr.value(domainConfig.DMARC?.Localpart || ''))), dom.label(attr.title("Alternative domain for reporting address, for incoming reports. Typically empty, causing this domain to be used. Can be used to receive reports for domains that aren't fully hosted on this server. Configure such a domain as a hosted domain without making all the DNS changes, and configure this field with a domain that is fully hosted on this server, so the localpart and the domain of this field form a reporting address. Then only update the DMARC DNS record for the hosted domain, ensuring the reporting address is specified in its \"rua\" field as shown in the DNS settings for this domain. Unicode name."), dom.div('Alternative domain (optional)'), dmarcDomain = dom.input(attr.value(domainConfig.DMARC?.Domain || ''))), dom.label(attr.title('Account to deliver to.'), dom.div('Account'), dmarcAccount = dom.select(dom.option(''), (accounts || []).map(s => dom.option(s, s === domainConfig.DMARC?.Account ? attr.selected('') : [])))), dom.label(attr.title('Mailbox to deliver to, e.g. DMARC.'), dom.div('Mailbox'), dmarcMailbox = dom.input(attr.value(domainConfig.DMARC?.Mailbox || ''))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.br(), dom.h2('TLS reporting address'), dom.form(style({ marginTop: '1ex' }), async function submit(e) { + }, dmarcFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('Address-part before the @ that accepts DMARC reports. Must be non-internationalized. Recommended value: dmarc-reports.'), dom.div('Localpart'), dmarcLocalpart = dom.input(attr.value(domainConfig.DMARC?.Localpart || ''))), dom.label(attr.title("Alternative domain for reporting address, for incoming reports. Typically empty, causing this domain to be used. Can be used to receive reports for domains that aren't fully hosted on this server. Configure such a domain as a hosted domain without making all the DNS changes, and configure this field with a domain that is fully hosted on this server, so the localpart and the domain of this field form a reporting address. Then only update the DMARC DNS record for the hosted domain, ensuring the reporting address is specified in its \"rua\" field as shown in the DNS settings for this domain. Unicode name."), dom.div('Alternative domain (optional)'), dmarcDomain = dom.input(attr.value(domainConfig.DMARC?.Domain || ''))), dom.label(attr.title('Account to deliver to.'), dom.div('Account'), dmarcAccount = dom.select(dom.option(''), (accounts || []).map(s => dom.option(attr.value(s), s + (accountsDisabled?.includes(s) ? ' (disabled)' : ''), s === domainConfig.DMARC?.Account ? attr.selected('') : [])))), dom.label(attr.title('Mailbox to deliver to, e.g. DMARC.'), dom.div('Mailbox'), dmarcMailbox = dom.input(attr.value(domainConfig.DMARC?.Mailbox || ''))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.br(), dom.h2('TLS reporting address'), dom.form(style({ marginTop: '1ex' }), async function submit(e) { e.preventDefault(); e.stopPropagation(); if (!tlsrptLocalpart.value) { @@ -2434,7 +2471,7 @@ const domain = async (d) => { domainConfig.TLSRPT = null; } } - }, tlsrptFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('Address-part before the @ that accepts TLSRPT reports. Must be non-internationalized. Recommended value: tlsrpt-reports.'), dom.div('Localpart'), tlsrptLocalpart = dom.input(attr.value(domainConfig.TLSRPT?.Localpart || ''))), dom.label(attr.title("Alternative domain for reporting address, for incoming reports. Typically empty, causing the domain wherein this config exists to be used. Can be used to receive reports for domains that aren't fully hosted on this server. Configure such a domain as a hosted domain without making all the DNS changes, and configure this field with a domain that is fully hosted on this server, so the localpart and the domain of this field form a reporting address. Then only update the TLSRPT DNS record for the not fully hosted domain, ensuring the reporting address is specified in its \"rua\" field as shown in the suggested DNS settings. Unicode name."), dom.div('Alternative domain (optional)'), tlsrptDomain = dom.input(attr.value(domainConfig.TLSRPT?.Domain || ''))), dom.label(attr.title('Account to deliver to.'), dom.div('Account'), tlsrptAccount = dom.select(dom.option(''), (accounts || []).map(s => dom.option(s, s === domainConfig.TLSRPT?.Account ? attr.selected('') : [])))), dom.label(attr.title('Mailbox to deliver to, e.g. TLSRPT.'), dom.div('Mailbox'), tlsrptMailbox = dom.input(attr.value(domainConfig.TLSRPT?.Mailbox || ''))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.br(), dom.h2('MTA-STS policy', attr.title("MTA-STS is a mechanism that allows publishing a policy with requirements for WebPKI-verified SMTP STARTTLS connections for email delivered to a domain. Existence of a policy is announced in a DNS TXT record (often unprotected/unverified, MTA-STS's weak spot). If a policy exists, it is fetched with a WebPKI-verified HTTPS request. The policy can indicate that WebPKI-verified SMTP STARTTLS is required, and which MX hosts (optionally with a wildcard pattern) are allowd. MX hosts to deliver to are still taken from DNS (again, not necessarily protected/verified), but messages will only be delivered to domains matching the MX hosts from the published policy. Mail servers look up the MTA-STS policy when first delivering to a domain, then keep a cached copy, periodically checking the DNS record if a new policy is available, and fetching and caching it if so. To update a policy, first serve a new policy with an updated policy ID, then update the DNS record (not the other way around). To remove an enforced policy, publish an updated policy with mode \"none\" for a long enough period so all cached policies have been refreshed (taking DNS TTL and policy max age into account), then remove the policy from DNS, wait for TTL to expire, and stop serving the policy.")), dom.form(style({ marginTop: '1ex' }), async function submit(e) { + }, tlsrptFieldset = dom.fieldset(style({ display: 'flex', gap: '1em' }), dom.label(attr.title('Address-part before the @ that accepts TLSRPT reports. Must be non-internationalized. Recommended value: tlsrpt-reports.'), dom.div('Localpart'), tlsrptLocalpart = dom.input(attr.value(domainConfig.TLSRPT?.Localpart || ''))), dom.label(attr.title("Alternative domain for reporting address, for incoming reports. Typically empty, causing the domain wherein this config exists to be used. Can be used to receive reports for domains that aren't fully hosted on this server. Configure such a domain as a hosted domain without making all the DNS changes, and configure this field with a domain that is fully hosted on this server, so the localpart and the domain of this field form a reporting address. Then only update the TLSRPT DNS record for the not fully hosted domain, ensuring the reporting address is specified in its \"rua\" field as shown in the suggested DNS settings. Unicode name."), dom.div('Alternative domain (optional)'), tlsrptDomain = dom.input(attr.value(domainConfig.TLSRPT?.Domain || ''))), dom.label(attr.title('Account to deliver to.'), dom.div('Account'), tlsrptAccount = dom.select(dom.option(''), (accounts || []).map(s => dom.option(attr.value(s), s + (accountsDisabled?.includes(s) ? ' (disabled)' : ''), s === domainConfig.TLSRPT?.Account ? attr.selected('') : [])))), dom.label(attr.title('Mailbox to deliver to, e.g. TLSRPT.'), dom.div('Mailbox'), tlsrptMailbox = dom.input(attr.value(domainConfig.TLSRPT?.Mailbox || ''))), dom.div(dom.span('\u00a0'), dom.div(dom.submitbutton('Save'))))), dom.br(), dom.h2('MTA-STS policy', attr.title("MTA-STS is a mechanism that allows publishing a policy with requirements for WebPKI-verified SMTP STARTTLS connections for email delivered to a domain. Existence of a policy is announced in a DNS TXT record (often unprotected/unverified, MTA-STS's weak spot). If a policy exists, it is fetched with a WebPKI-verified HTTPS request. The policy can indicate that WebPKI-verified SMTP STARTTLS is required, and which MX hosts (optionally with a wildcard pattern) are allowd. MX hosts to deliver to are still taken from DNS (again, not necessarily protected/verified), but messages will only be delivered to domains matching the MX hosts from the published policy. Mail servers look up the MTA-STS policy when first delivering to a domain, then keep a cached copy, periodically checking the DNS record if a new policy is available, and fetching and caching it if so. To update a policy, first serve a new policy with an updated policy ID, then update the DNS record (not the other way around). To remove an enforced policy, publish an updated policy with mode \"none\" for a long enough period so all cached policies have been refreshed (taking DNS TTL and policy max age into account), then remove the policy from DNS, wait for TTL to expire, and stop serving the policy.")), dom.form(style({ marginTop: '1ex' }), async function submit(e) { e.preventDefault(); e.stopPropagation(); let mx = []; @@ -2545,7 +2582,18 @@ const domain = async (d) => { })), dom.tfoot(dom.tr(dom.td(attr.colspan('9'), dom.submitbutton('Save'), ' ', dom.clickbutton('Add key/selector', function click() { popupDKIMAdd(); }))))))); - })(), dom.br(), dom.h2('External checks'), dom.ul(dom.li(link('https://internet.nl/mail/' + dnsdomain.ASCII + '/', 'Check configuration at internet.nl'))), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove domain', async function click(e) { + })(), dom.br(), dom.h2('External checks'), dom.ul(dom.li(link('https://internet.nl/mail/' + dnsdomain.ASCII + '/', 'Check configuration at internet.nl'))), dom.br(), dom.h2('Danger'), dom.div(domainConfig.Disabled ? [ + box(yellow, 'Domain is currently disabled.'), + dom.clickbutton('Enable domain', async function click(e) { + if (window.confirm('Are you sure you want to enable this domain? Incoming/outgoing messages involving this domain will be accepted, and ACME for new TLS certificates will be enabled.')) { + check(e.target, client.DomainDisabledSave(d, false)); + } + }) + ] : dom.clickbutton('Disable domain', async function click(e) { + if (window.confirm('Are you sure you want to disable this domain? Incoming/outgoing messages involving this domain will be rejected with a temporary error code, and ACME for new TLS certificates will be disabled.')) { + check(e.target, client.DomainDisabledSave(d, true)); + } + })), dom.br(), dom.clickbutton('Remove domain', async function click(e) { e.preventDefault(); if (!window.confirm('Are you sure you want to remove this domain?')) { return; diff --git a/webadmin/admin.ts b/webadmin/admin.ts index 52cd6d3..4879fa1 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -331,7 +331,7 @@ const formatSize = (n: number) => { } const index = async () => { - const [domains, queueSize, hooksQueueSize, checkUpdatesEnabled, accounts] = await Promise.all([ + const [domains, queueSize, hooksQueueSize, checkUpdatesEnabled, [accounts, accountsDisabled]] = await Promise.all([ client.Domains(), client.QueueSize(), client.HookQueueSize(), @@ -340,6 +340,7 @@ const index = async () => { ]) let fieldset: HTMLFieldSetElement + let disabled: HTMLInputElement let domain: HTMLInputElement let account: HTMLInputElement let localpart: HTMLInputElement @@ -359,7 +360,7 @@ const index = async () => { dom.h2('Domains'), (domains || []).length === 0 ? box(red, 'No domains') : dom.ul( - (domains || []).map(d => dom.li(dom.a(attr.href('#domains/'+domainName(d)), domainString(d)))), + (domains || []).map(d => dom.li(dom.a(attr.href('#domains/'+domainName(d.Domain)), domainString(d.Domain)), d.Disabled ? ' (disabled)' : [])), ), dom.br(), dom.h2('Add domain'), @@ -367,7 +368,7 @@ const index = async () => { async function submit(e: SubmitEvent) { e.preventDefault() e.stopPropagation() - await check(fieldset, client.DomainAdd(domain.value, account.value, localpart.value)) + await check(fieldset, client.DomainAdd(disabled.checked, domain.value, account.value, localpart.value)) window.location.hash = '#domains/' + domain.value }, fieldset=dom.fieldset( @@ -383,7 +384,7 @@ const index = async () => { dom.span('Postmaster/reporting account', attr.title('Account that is considered the owner of this domain. If the account does not yet exist, it will be created and a a localpart is required for the initial email address.')), dom.br(), account=dom.input(attr.required(''), attr.list('accountList')), - dom.datalist(attr.id('accountList'), (accounts || []).map(a => dom.option(a))), + dom.datalist(attr.id('accountList'), (accounts || []).map(a => dom.option(attr.value(a), a + (accountsDisabled?.includes(a) ? ' (disabled)' : '')))), ), ' ', dom.label( @@ -393,6 +394,12 @@ const index = async () => { localpart=dom.input(), ), ' ', + dom.label( + disabled=dom.input(attr.type('checkbox')), + ' Disabled', + attr.title('Disabled domains do fetch new certificates with ACME and do not accept incoming or outgoing messages involving the domain. Accounts and addresses referencing a disabled domain can be created. USeful during/before migrations.'), + ), + ' ', dom.submitbutton('Add domain', attr.title('Domain will be added and the config reloaded. Add the required DNS records after adding the domain.')), ), ), @@ -575,7 +582,7 @@ const inlineBox = (color: string, ...l: ElemArg[]) => ) const accounts = async () => { - const [accounts, domains] = await Promise.all([ + const [[accounts, accountsDisabled], domains] = await Promise.all([ client.Accounts(), client.Domains(), ]) @@ -594,7 +601,7 @@ const accounts = async () => { dom.h2('Accounts'), (accounts || []).length === 0 ? dom.p('No accounts') : dom.ul( - (accounts || []).map(s => dom.li(dom.a(s, attr.href('#accounts/'+s)))), + (accounts || []).map(s => dom.li(dom.a(attr.href('#accounts/'+s), s), accountsDisabled?.includes(s) ? ' (disabled)' : '')), ), dom.br(), dom.h2('Add account'), @@ -622,7 +629,7 @@ const accounts = async () => { style({display: 'inline-block'}), dom.span('Domain', attr.title('The domain of the email address, after the "@".')), dom.br(), - domain=dom.select(attr.required(''), (domains || []).map(d => dom.option(domainName(d)))), + domain=dom.select(attr.required(''), (domains || []).map(d => dom.option(domainName(d.Domain)))), ), ' ', dom.label( @@ -812,6 +819,7 @@ const account = async (name: string) => { crumblink('Accounts', '#accounts'), name, ), + config.LoginDisabled ? dom.p(box(yellow, 'Warning: Login for this account is disabled with message: '+config.LoginDisabled)) : [], dom.h2('Addresses'), dom.table( dom.thead( @@ -876,7 +884,7 @@ const account = async (name: string) => { style({display: 'inline-block'}), dom.span('Domain'), dom.br(), - domain=dom.select((domains || []).map(d => dom.option(domainName(d), domainName(d) === config.Domain ? attr.selected('') : []))), + domain=dom.select((domains || []).map(d => dom.option(domainName(d.Domain), domainName(d.Domain) === config.Domain ? attr.selected('') : []))), ), ' ', dom.submitbutton('Add address'), @@ -1027,6 +1035,42 @@ const account = async (name: string) => { dom.br(), dom.h2('Danger'), + dom.div( + config.LoginDisabled ? [ + box(yellow, 'Account login is currently disabled.'), + dom.clickbutton('Enable account login', async function click(e: {target: HTMLButtonElement}) { + if (window.confirm('Are you sure you want to enable login to this account?')) { + await check(e.target, client.AccountLoginDisabledSave(name, '')) + window.location.reload() // todo: update account and rerender. + } + }) + ] : dom.clickbutton('Disable account login', function click() { + let fieldset: HTMLFieldSetElement + let loginDisabled: HTMLInputElement + + const close = popup( + dom.h1('Disable account login'), + dom.form( + async function submit(e: SubmitEvent) { + e.preventDefault() + e.stopPropagation() + await check(fieldset, client.AccountLoginDisabledSave(name, loginDisabled.value)) + close() + window.location.reload() // todo: update account and rerender. + }, + fieldset=dom.fieldset( + dom.label( + dom.div('Message to user'), + loginDisabled=dom.input(attr.required(''), style({width: '100%'})), + dom.p(style({fontStyle: 'italic'}), 'Will be shown to user on login attempts. Single line, no special and maximum 256 characters since message is used in IMAP/SMTP.'), + ), + dom.div(dom.submitbutton('Disable login')), + ), + ), + ) + }), + ), + dom.br(), dom.clickbutton('Remove account', async function click(e: MouseEvent) { e.preventDefault() if (!window.confirm('Are you sure you want to remove this account? All account data, including messages will be removed.')) { @@ -1075,16 +1119,16 @@ const formatDuration = (v: number, goDuration?: boolean) => { const domain = async (d: string) => { const end = new Date() const start = new Date(new Date().getTime() - 30*24*3600*1000) - const [dmarcSummaries, tlsrptSummaries, [localpartAccounts, localpartAliases], dnsdomain, clientConfigs, accounts, domainConfig, transports] = await Promise.all([ + const [dmarcSummaries, tlsrptSummaries, [localpartAccounts, localpartAliases], clientConfigs, [accounts, accountsDisabled], domainConfig, transports] = await Promise.all([ client.DMARCSummaries(start, end, d), client.TLSRPTSummaries(start, end, d), client.DomainLocalparts(d), - client.ParseDomain(d), client.ClientConfigsDomain(d), client.Accounts(), client.DomainConfig(d), client.Transports(), ]) + const dnsdomain = domainConfig.Domain let addrForm: HTMLFormElement let addrFieldset: HTMLFieldSetElement @@ -1255,6 +1299,7 @@ const domain = async (d: string) => { crumblink('Mox Admin', '#'), 'Domain ' + domainString(dnsdomain), ), + domainConfig.Disabled ? dom.p(box(yellow, 'Warning: Domain is disabled. Incoming/outgoing messages involving this domain are rejected and ACME for new TLS certificates is disabled.')) : [], dom.ul( dom.li(dom.a('Required DNS records', attr.href('#domains/' + d + '/dnsrecords'))), dom.li(dom.a('Check current actual DNS records and domain configuration', attr.href('#domains/' + d + '/dnscheck'))), @@ -1340,7 +1385,7 @@ const domain = async (d: string) => { style({display: 'inline-block'}), dom.span('Account', attr.title('Account to assign the address to.')), dom.br(), - addrAccount=dom.select(attr.required(''), (accounts || []).map(a => dom.option(a))), + addrAccount=dom.select(attr.required(''), (accounts || []).map(a => dom.option(attr.value(a), a + (accountsDisabled?.includes(a) ? ' (disabled)' : '')))), ), ' ', dom.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')), @@ -1510,7 +1555,7 @@ const domain = async (d: string) => { dom.div('Account'), dmarcAccount=dom.select( dom.option(''), - (accounts || []).map(s => dom.option(s, s === domainConfig.DMARC?.Account ? attr.selected('') : [])), + (accounts || []).map(s => dom.option(attr.value(s), s + (accountsDisabled?.includes(s) ? ' (disabled)' : ''), s === domainConfig.DMARC?.Account ? attr.selected('') : [])), ), ), dom.label( @@ -1562,7 +1607,7 @@ const domain = async (d: string) => { dom.div('Account'), tlsrptAccount=dom.select( dom.option(''), - (accounts || []).map(s => dom.option(s, s === domainConfig.TLSRPT?.Account ? attr.selected('') : [])), + (accounts || []).map(s => dom.option(attr.value(s), s + (accountsDisabled?.includes(s) ? ' (disabled)' : ''), s === domainConfig.TLSRPT?.Account ? attr.selected('') : [])), ), ), dom.label( @@ -1801,6 +1846,21 @@ const domain = async (d: string) => { dom.br(), dom.h2('Danger'), + dom.div( + domainConfig.Disabled ? [ + box(yellow, 'Domain is currently disabled.'), + dom.clickbutton('Enable domain', async function click(e: {target: HTMLButtonElement}) { + if (window.confirm('Are you sure you want to enable this domain? Incoming/outgoing messages involving this domain will be accepted, and ACME for new TLS certificates will be enabled.')) { + check(e.target, client.DomainDisabledSave(d, false)) + } + }) + ] : dom.clickbutton('Disable domain', async function click(e: {target: HTMLButtonElement}) { + if (window.confirm('Are you sure you want to disable this domain? Incoming/outgoing messages involving this domain will be rejected with a temporary error code, and ACME for new TLS certificates will be disabled.')) { + check(e.target, client.DomainDisabledSave(d, true)) + } + }), + ), + dom.br(), dom.clickbutton('Remove domain', async function click(e: MouseEvent) { e.preventDefault() if (!window.confirm('Are you sure you want to remove this domain?')) { diff --git a/webadmin/api.json b/webadmin/api.json index c4257eb..2f4983c 100644 --- a/webadmin/api.json +++ b/webadmin/api.json @@ -69,14 +69,14 @@ }, { "Name": "Domains", - "Docs": "Domains returns all configured domain names, in UTF-8 for IDNA domains.", + "Docs": "Domains returns all configured domain names.", "Params": [], "Returns": [ { "Name": "r0", "Typewords": [ "[]", - "Domain" + "ConfigDomain" ] } ] @@ -171,11 +171,18 @@ }, { "Name": "Accounts", - "Docs": "Accounts returns the names of all configured accounts.", + "Docs": "Accounts returns the names of all configured and all disabled accounts.", "Params": [], "Returns": [ { - "Name": "r0", + "Name": "all", + "Typewords": [ + "[]", + "string" + ] + }, + { + "Name": "disabled", "Typewords": [ "[]", "string" @@ -525,6 +532,12 @@ "Name": "DomainAdd", "Docs": "DomainAdd adds a new domain and reloads the configuration.", "Params": [ + { + "Name": "disabled", + "Typewords": [ + "bool" + ] + }, { "Name": "domain", "Typewords": [ @@ -679,6 +692,25 @@ ], "Returns": [] }, + { + "Name": "AccountLoginDisabledSave", + "Docs": "AccountLoginDisabledSave saves the LoginDisabled field of an account.", + "Params": [ + { + "Name": "accountName", + "Typewords": [ + "string" + ] + }, + { + "Name": "loginDisabled", + "Typewords": [ + "string" + ] + } + ], + "Returns": [] + }, { "Name": "ClientConfigsDomain", "Docs": "ClientConfigsDomain returns configurations for email clients, IMAP and\nSubmission (SMTP) for the domain.", @@ -1906,6 +1938,25 @@ ], "Returns": [] }, + { + "Name": "DomainDisabledSave", + "Docs": "DomainDisabledSave saves the Disabled field of a domain. A disabled domain\nrejects incoming/outgoing messages involving the domain and does not request new\nTLS certificats with ACME.", + "Params": [ + { + "Name": "domainName", + "Typewords": [ + "string" + ] + }, + { + "Name": "disabled", + "Typewords": [ + "bool" + ] + } + ], + "Returns": [] + }, { "Name": "AliasAdd", "Docs": "", @@ -3329,6 +3380,13 @@ "Name": "ConfigDomain", "Docs": "", "Fields": [ + { + "Name": "Disabled", + "Docs": "", + "Typewords": [ + "bool" + ] + }, { "Name": "Description", "Docs": "", @@ -3974,6 +4032,13 @@ "int64" ] }, + { + "Name": "LoginDisabled", + "Docs": "", + "Typewords": [ + "string" + ] + }, { "Name": "Domain", "Docs": "", diff --git a/webadmin/api.ts b/webadmin/api.ts index 9c877a2..b88afbb 100644 --- a/webadmin/api.ts +++ b/webadmin/api.ts @@ -266,6 +266,7 @@ export interface AutodiscoverSRV { } export interface ConfigDomain { + Disabled: boolean Description: string ClientSettingsDomain: string LocalpartCatchallSeparator: string @@ -384,6 +385,7 @@ export interface Account { FromIDLoginAddresses?: string[] | null KeepRetiredMessagePeriod: number KeepRetiredWebhookPeriod: number + LoginDisabled: string Domain: string Description: string FullName: string @@ -1145,7 +1147,7 @@ export const types: TypenameMap = { "AutoconfCheckResult": {"Name":"AutoconfCheckResult","Docs":"","Fields":[{"Name":"ClientSettingsDomainIPs","Docs":"","Typewords":["[]","string"]},{"Name":"IPs","Docs":"","Typewords":["[]","string"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, "AutodiscoverCheckResult": {"Name":"AutodiscoverCheckResult","Docs":"","Fields":[{"Name":"Records","Docs":"","Typewords":["[]","AutodiscoverSRV"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, "AutodiscoverSRV": {"Name":"AutodiscoverSRV","Docs":"","Fields":[{"Name":"Target","Docs":"","Typewords":["string"]},{"Name":"Port","Docs":"","Typewords":["uint16"]},{"Name":"Priority","Docs":"","Typewords":["uint16"]},{"Name":"Weight","Docs":"","Typewords":["uint16"]},{"Name":"IPs","Docs":"","Typewords":["[]","string"]}]}, - "ConfigDomain": {"Name":"ConfigDomain","Docs":"","Fields":[{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"ClientSettingsDomain","Docs":"","Typewords":["string"]},{"Name":"LocalpartCatchallSeparator","Docs":"","Typewords":["string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]},{"Name":"DKIM","Docs":"","Typewords":["DKIM"]},{"Name":"DMARC","Docs":"","Typewords":["nullable","DMARC"]},{"Name":"MTASTS","Docs":"","Typewords":["nullable","MTASTS"]},{"Name":"TLSRPT","Docs":"","Typewords":["nullable","TLSRPT"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"Aliases","Docs":"","Typewords":["{}","Alias"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]}, + "ConfigDomain": {"Name":"ConfigDomain","Docs":"","Fields":[{"Name":"Disabled","Docs":"","Typewords":["bool"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"ClientSettingsDomain","Docs":"","Typewords":["string"]},{"Name":"LocalpartCatchallSeparator","Docs":"","Typewords":["string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]},{"Name":"DKIM","Docs":"","Typewords":["DKIM"]},{"Name":"DMARC","Docs":"","Typewords":["nullable","DMARC"]},{"Name":"MTASTS","Docs":"","Typewords":["nullable","MTASTS"]},{"Name":"TLSRPT","Docs":"","Typewords":["nullable","TLSRPT"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"Aliases","Docs":"","Typewords":["{}","Alias"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]}, "DKIM": {"Name":"DKIM","Docs":"","Fields":[{"Name":"Selectors","Docs":"","Typewords":["{}","Selector"]},{"Name":"Sign","Docs":"","Typewords":["[]","string"]}]}, "Selector": {"Name":"Selector","Docs":"","Fields":[{"Name":"Hash","Docs":"","Typewords":["string"]},{"Name":"HashEffective","Docs":"","Typewords":["string"]},{"Name":"Canonicalization","Docs":"","Typewords":["Canonicalization"]},{"Name":"Headers","Docs":"","Typewords":["[]","string"]},{"Name":"HeadersEffective","Docs":"","Typewords":["[]","string"]},{"Name":"DontSealHeaders","Docs":"","Typewords":["bool"]},{"Name":"Expiration","Docs":"","Typewords":["string"]},{"Name":"PrivateKeyFile","Docs":"","Typewords":["string"]},{"Name":"Algorithm","Docs":"","Typewords":["string"]}]}, "Canonicalization": {"Name":"Canonicalization","Docs":"","Fields":[{"Name":"HeaderRelaxed","Docs":"","Typewords":["bool"]},{"Name":"BodyRelaxed","Docs":"","Typewords":["bool"]}]}, @@ -1158,7 +1160,7 @@ export const types: TypenameMap = { "Address": {"Name":"Address","Docs":"","Fields":[{"Name":"Localpart","Docs":"","Typewords":["Localpart"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]}, "Destination": {"Name":"Destination","Docs":"","Fields":[{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Rulesets","Docs":"","Typewords":["[]","Ruleset"]},{"Name":"SMTPError","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]}]}, "Ruleset": {"Name":"Ruleset","Docs":"","Fields":[{"Name":"SMTPMailFromRegexp","Docs":"","Typewords":["string"]},{"Name":"MsgFromRegexp","Docs":"","Typewords":["string"]},{"Name":"VerifiedDomain","Docs":"","Typewords":["string"]},{"Name":"HeadersRegexp","Docs":"","Typewords":["{}","string"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ListAllowDomain","Docs":"","Typewords":["string"]},{"Name":"AcceptRejectsToMailbox","Docs":"","Typewords":["string"]},{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Comment","Docs":"","Typewords":["string"]},{"Name":"VerifiedDNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"ListAllowDNSDomain","Docs":"","Typewords":["Domain"]}]}, - "Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]}, + "Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"LoginDisabled","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]}, "OutgoingWebhook": {"Name":"OutgoingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]},{"Name":"Events","Docs":"","Typewords":["[]","string"]}]}, "IncomingWebhook": {"Name":"IncomingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]}]}, "SubjectPass": {"Name":"SubjectPass","Docs":"","Fields":[{"Name":"Period","Docs":"","Typewords":["int64"]}]}, @@ -1422,13 +1424,13 @@ export class Client { return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as CheckResult } - // Domains returns all configured domain names, in UTF-8 for IDNA domains. - async Domains(): Promise { + // Domains returns all configured domain names. + async Domains(): Promise { const fn: string = "Domains" const paramTypes: string[][] = [] - const returnTypes: string[][] = [["[]","Domain"]] + const returnTypes: string[][] = [["[]","ConfigDomain"]] const params: any[] = [] - return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as Domain[] | null + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as ConfigDomain[] | null } // Domain returns the dns domain for a (potentially unicode as IDNA) domain name. @@ -1467,13 +1469,13 @@ export class Client { return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [{ [key: string]: string }, { [key: string]: Alias }] } - // Accounts returns the names of all configured accounts. - async Accounts(): Promise { + // Accounts returns the names of all configured and all disabled accounts. + async Accounts(): Promise<[string[] | null, string[] | null]> { const fn: string = "Accounts" const paramTypes: string[][] = [] - const returnTypes: string[][] = [["[]","string"]] + const returnTypes: string[][] = [["[]","string"],["[]","string"]] const params: any[] = [] - return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as string[] | null + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [string[] | null, string[] | null] } // Account returns the parsed configuration of an account. @@ -1608,11 +1610,11 @@ export class Client { } // DomainAdd adds a new domain and reloads the configuration. - async DomainAdd(domain: string, accountName: string, localpart: string): Promise { + async DomainAdd(disabled: boolean, domain: string, accountName: string, localpart: string): Promise { const fn: string = "DomainAdd" - const paramTypes: string[][] = [["string"],["string"],["string"]] + const paramTypes: string[][] = [["bool"],["string"],["string"],["string"]] const returnTypes: string[][] = [] - const params: any[] = [domain, accountName, localpart] + const params: any[] = [disabled, domain, accountName, localpart] return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void } @@ -1682,6 +1684,15 @@ export class Client { return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void } + // AccountLoginDisabledSave saves the LoginDisabled field of an account. + async AccountLoginDisabledSave(accountName: string, loginDisabled: string): Promise { + const fn: string = "AccountLoginDisabledSave" + const paramTypes: string[][] = [["string"],["string"]] + const returnTypes: string[][] = [] + const params: any[] = [accountName, loginDisabled] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + // ClientConfigsDomain returns configurations for email clients, IMAP and // Submission (SMTP) for the domain. async ClientConfigsDomain(domain: string): Promise { @@ -2212,6 +2223,17 @@ export class Client { return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void } + // DomainDisabledSave saves the Disabled field of a domain. A disabled domain + // rejects incoming/outgoing messages involving the domain and does not request new + // TLS certificats with ACME. + async DomainDisabledSave(domainName: string, disabled: boolean): Promise { + const fn: string = "DomainDisabledSave" + const paramTypes: string[][] = [["string"],["bool"]] + const returnTypes: string[][] = [] + const params: any[] = [domainName, disabled] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + async AliasAdd(aliaslp: string, domainName: string, alias: Alias): Promise { const fn: string = "AliasAdd" const paramTypes: string[][] = [["string"],["string"],["Alias"]] diff --git a/webapisrv/server.go b/webapisrv/server.go index 8e53198..627b460 100644 --- a/webapisrv/server.go +++ b/webapisrv/server.go @@ -54,7 +54,7 @@ var ( metricSubmission = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "mox_webapi_submission_total", - Help: "Webapi message submission results, known values (those ending with error are server errors): ok, badfrom, messagelimiterror, recipientlimiterror, queueerror, storesenterror.", + Help: "Webapi message submission results, known values (those ending with error are server errors): ok, badfrom, messagelimiterror, recipientlimiterror, queueerror, storesenterror, domaindisabled.", }, []string{ "result", @@ -431,15 +431,20 @@ func (s server) ServeHTTP(w http.ResponseWriter, r *http.Request) { }() var err error - acc, err = store.OpenEmailAuth(log, email, password) + acc, err = store.OpenEmailAuth(log, email, password, true) if err != nil { mox.LimiterFailedAuth.Add(remoteIP, t0, 1) - if errors.Is(err, mox.ErrDomainNotFound) || errors.Is(err, mox.ErrAddressNotFound) || errors.Is(err, store.ErrUnknownCredentials) { + if errors.Is(err, mox.ErrDomainNotFound) || errors.Is(err, mox.ErrAddressNotFound) || errors.Is(err, store.ErrUnknownCredentials) || errors.Is(err, store.ErrLoginDisabled) { log.Debug("bad http basic authentication credentials") metricResults.WithLabelValues(fn, "badauth").Inc() authResult = "badcreds" + msg := "use http basic auth with email address as username" + if errors.Is(err, store.ErrLoginDisabled) { + authResult = "logindisabled" + msg = "login is disabled for this account" + } w.Header().Set("WWW-Authenticate", "Basic realm=webapi") - http.Error(w, "401 - unauthorized - use http basic auth with email address as username", http.StatusUnauthorized) + http.Error(w, "401 - unauthorized - "+msg, http.StatusUnauthorized) return } writeError(webapi.Error{Code: "server", Message: "error verifying credentials"}) @@ -624,7 +629,10 @@ func (s server) Send(ctx context.Context, req webapi.SendRequest) (resp webapi.S addresses := append(append(m.To, m.CC...), m.BCC...) // Check if from address is allowed for account. - if !mox.AllowMsgFrom(acc.Name, from.Address) { + if ok, disabled := mox.AllowMsgFrom(acc.Name, from.Address); disabled { + metricSubmission.WithLabelValues("domaindisabled").Inc() + return resp, webapi.Error{Code: "domainDisabled", Message: "domain of from-address is temporarily disabled"} + } else if !ok { metricSubmission.WithLabelValues("badfrom").Inc() return resp, webapi.Error{Code: "badFrom", Message: "from-address not configured for account"} } @@ -961,6 +969,9 @@ func (s server) Send(ctx context.Context, req webapi.SendRequest) (resp webapi.S var msgPrefix string fd := from.Address.Domain confDom, _ := mox.Conf.Domain(fd) + if confDom.Disabled { + xcheckuserf(mox.ErrDomainDisabled, "checking domain") + } selectors := mox.DKIMSelectors(confDom.DKIM) if len(selectors) > 0 { dkimHeaders, err := dkim.Sign(ctx, log.Logger, from.Address.Localpart, fd, selectors, smtputf8, dataFile) diff --git a/webapisrv/server_test.go b/webapisrv/server_test.go index 3e507c5..e85ae80 100644 --- a/webapisrv/server_test.go +++ b/webapisrv/server_test.go @@ -67,7 +67,7 @@ func TestServer(t *testing.T) { defer queue.Shutdown() log := mlog.New("webapisrv", nil) - acc, err := store.OpenAccount(log, "mjl") + acc, err := store.OpenAccount(log, "mjl", false) tcheckf(t, err, "open account") const pw0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces. const pw1 = "tést " // PRECIS normalized, with NFC. @@ -141,6 +141,16 @@ func TestServer(t *testing.T) { } mox.LimitersInit() + // Cannot login to disabled account. + acc2, err := store.OpenAccount(log, "disabled", false) + tcheckf(t, err, "open account") + err = acc2.SetPassword(log, "test1234") + tcheckf(t, err, "set password") + acc2.Close() + tcheckf(t, err, "close account") + testHTTPHdrsBody(s, "POST", "/v0/Send", map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("disabled@mox.example:test1234"))}, "", http.StatusUnauthorized, false, "", "") + testHTTPHdrsBody(s, "POST", "/v0/Send", map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("disabled@mox.example:bogus"))}, "", http.StatusUnauthorized, false, "", "") + // Request with missing X-Forwarded-For. sfwd := NewServer(100*1024, "/webapi/", true).(server) testHTTPHdrsBody(sfwd, "POST", "/v0/Send", map[string]string{"Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("mjl@mox.example:badpassword"))}, "", http.StatusInternalServerError, false, "", "") @@ -331,6 +341,16 @@ func TestServer(t *testing.T) { }) terrcode(t, err, "malformedMessageID") + _, err = client.Send(ctxbg, webapi.SendRequest{ + Message: webapi.Message{ + From: []webapi.NameAddress{{Address: "mjl@disabled.example"}}, + To: []webapi.NameAddress{{Address: "mjl@mox.example"}}, + Subject: "test", + Text: "hi", + }, + }) + terrcode(t, err, "domainDisabled") + // todo: messageLimitReached, recipientLimitReached // SuppressionList diff --git a/webauth/accounts.go b/webauth/accounts.go index 3d8c6ca..03e123b 100644 --- a/webauth/accounts.go +++ b/webauth/accounts.go @@ -14,18 +14,20 @@ var Accounts SessionAuth = accountSessionAuth{} type accountSessionAuth struct{} -func (accountSessionAuth) login(ctx context.Context, log mlog.Log, username, password string) (bool, string, error) { - acc, err := store.OpenEmailAuth(log, username, password) +func (accountSessionAuth) login(ctx context.Context, log mlog.Log, username, password string) (valid, disabled bool, accName string, rerr error) { + acc, err := store.OpenEmailAuth(log, username, password, true) if err != nil && errors.Is(err, store.ErrUnknownCredentials) { - return false, "", nil + return false, false, "", nil + } else if err != nil && errors.Is(err, store.ErrLoginDisabled) { + return false, true, "", err // Returning error, for its message. } else if err != nil { - return false, "", err + return false, false, "", err } defer func() { err := acc.Close() log.Check(err, "closing account") }() - return true, acc.Name, nil + return true, false, acc.Name, nil } func (accountSessionAuth) add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error) { diff --git a/webauth/admin.go b/webauth/admin.go index b0387ed..0f28c50 100644 --- a/webauth/admin.go +++ b/webauth/admin.go @@ -39,14 +39,14 @@ type adminSessionAuth struct { sessions map[store.SessionToken]adminSession } -func (a *adminSessionAuth) login(ctx context.Context, log mlog.Log, username, password string) (bool, string, error) { +func (a *adminSessionAuth) login(ctx context.Context, log mlog.Log, username, password string) (valid, disabled bool, name string, rerr error) { a.Lock() defer a.Unlock() p := mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile) buf, err := os.ReadFile(p) if err != nil { - return false, "", fmt.Errorf("reading password file: %v", err) + return false, false, "", fmt.Errorf("reading password file: %v", err) } passwordhash := strings.TrimSpace(string(buf)) // Transform with precis, if valid. ../rfc/8265:679 @@ -55,10 +55,10 @@ func (a *adminSessionAuth) login(ctx context.Context, log mlog.Log, username, pa password = pw } if err := bcrypt.CompareHashAndPassword([]byte(passwordhash), []byte(password)); err != nil { - return false, "", nil + return false, false, "", nil } - return true, "", nil + return true, false, "", nil } func (a *adminSessionAuth) add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error) { diff --git a/webauth/webauth.go b/webauth/webauth.go index c38fc1c..c1d217f 100644 --- a/webauth/webauth.go +++ b/webauth/webauth.go @@ -59,7 +59,9 @@ var BadAuthDelay = time.Second // SessionAuth handles login and session storage, used for both account and // admin authentication. type SessionAuth interface { - login(ctx context.Context, log mlog.Log, username, password string) (valid bool, accountName string, rerr error) + // Login verifies the password. Valid indicates the attempt was successful. If + // disabled is true, the error must be non-nil and contain details. + login(ctx context.Context, log mlog.Log, username, password string) (valid bool, disabled bool, accountName string, rerr error) // Add a new session for account and login address. add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error) @@ -244,12 +246,15 @@ func Login(ctx context.Context, log mlog.Log, sessionAuth SessionAuth, kind, coo return "", &sherpa.Error{Code: "user:error", Message: "too many authentication attempts"} } - valid, accountName, err := sessionAuth.login(ctx, log, username, password) + valid, disabled, accountName, err := sessionAuth.login(ctx, log, username, password) var authResult string defer func() { metrics.AuthenticationInc(kind, "weblogin", authResult) }() - if err != nil { + if disabled { + authResult = "logindisabled" + return "", &sherpa.Error{Code: "user:loginFailed", Message: err.Error()} + } else if err != nil { authResult = "error" return "", fmt.Errorf("evaluating login attempt: %v", err) } else if !valid { diff --git a/webmail/api.go b/webmail/api.go index 7500d62..598fa02 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -657,7 +657,10 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) { } // Check if from address is allowed for account. - if !mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address) { + if ok, disabled := mox.AllowMsgFrom(reqInfo.Account.Name, fromAddr.Address); disabled { + metricSubmission.WithLabelValues("domaindisabled").Inc() + xcheckuserf(ctx, mox.ErrDomainDisabled, `looking up "from" address for account`) + } else if !ok { metricSubmission.WithLabelValues("badfrom").Inc() xcheckuserf(ctx, errors.New("address not found"), `looking up "from" address for account`) } @@ -941,6 +944,9 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) { var msgPrefix string fd := fromAddr.Address.Domain confDom, _ := mox.Conf.Domain(fd) + if confDom.Disabled { + xcheckuserf(ctx, mox.ErrDomainDisabled, "checking domain") + } selectors := mox.DKIMSelectors(confDom.DKIM) if len(selectors) > 0 { dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Address.Localpart, fd, selectors, smtputf8, dataFile) diff --git a/webmail/api_test.go b/webmail/api_test.go index 1b1ead1..2b28f28 100644 --- a/webmail/api_test.go +++ b/webmail/api_test.go @@ -61,7 +61,7 @@ func TestAPI(t *testing.T) { log := mlog.New("webmail", nil) err := mtastsdb.Init(false) tcheck(t, err, "mtastsdb init") - acc, err := store.OpenAccount(log, "mjl") + acc, err := store.OpenAccount(log, "mjl", false) tcheck(t, err, "open account") const pw0 = "te\u0301st \u00a0\u2002\u200a" // NFD and various unicode spaces. const pw1 = "tést " // PRECIS normalized, with NFC. @@ -142,6 +142,22 @@ func TestAPI(t *testing.T) { } testLogin("bad@bad.example", pw0, "user:error") + acc2, err := store.OpenAccount(log, "disabled", false) + tcheck(t, err, "open account") + err = acc2.SetPassword(log, "test1234") + tcheck(t, err, "set password") + acc2.Close() + tcheck(t, err, "close account") + + mox.LimitersInit() + loginReqInfo2 := requestInfo{log, "disabled@mox.example", nil, "", httptest.NewRecorder(), &http.Request{RemoteAddr: "1.1.1.1:1234"}} + loginctx2 := context.WithValue(ctxbg, requestInfoCtxKey, loginReqInfo2) + loginCookie2 := &http.Cookie{Name: "webmaillogin"} + loginCookie2.Value = api.LoginPrep(loginctx2) + loginReqInfo2.Request.Header = http.Header{"Cookie": []string{loginCookie2.String()}} + tneedErrorCode(t, "user:loginFailed", func() { api.Login(loginctx2, loginCookie2.Value, "disabled@mox.example", "test1234") }) + tneedErrorCode(t, "user:loginFailed", func() { api.Login(loginctx2, loginCookie2.Value, "disabled@mox.example", "bogus") }) + // Context with different IP, for clear rate limit history. reqInfo := requestInfo{log, "mjl@mox.example", acc, "", nil, &http.Request{RemoteAddr: "127.0.0.1:1234"}} ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo) @@ -426,6 +442,15 @@ func TestAPI(t *testing.T) { }) }) + // Message from disabled domain. + tneedError(t, func() { + api.MessageSubmit(ctx, SubmitMessage{ + From: "mjl@disabled.example", + To: []string{"mjl@mox.example"}, + TextBody: "test", + }) + }) + api.maxMessageSize = 1 tneedError(t, func() { api.MessageSubmit(ctx, SubmitMessage{ diff --git a/webmail/view.go b/webmail/view.go index 22c4b41..04ff598 100644 --- a/webmail/view.go +++ b/webmail/view.go @@ -626,7 +626,7 @@ func serveEvents(ctx context.Context, log mlog.Log, accountPath string, w http.R defer writer.close() // Fetch initial data. - acc, err := store.OpenAccount(log, accName) + acc, err := store.OpenAccount(log, accName, true) xcheckf(ctx, err, "open account") defer func() { err := acc.Close() @@ -639,7 +639,7 @@ func serveEvents(ctx context.Context, log mlog.Log, accountPath string, w http.R accConf, _ := acc.Conf() loginAddr, err := smtp.ParseAddress(address) xcheckf(ctx, err, "parsing login address") - _, _, _, dest, err := mox.LookupAddress(loginAddr.Localpart, loginAddr.Domain, false, false) + _, _, _, dest, err := mox.LookupAddress(loginAddr.Localpart, loginAddr.Domain, false, false, false) xcheckf(ctx, err, "looking up destination for login address") loginName := accConf.FullName if dest.FullName != "" { diff --git a/webmail/view_test.go b/webmail/view_test.go index c861f52..bdeb520 100644 --- a/webmail/view_test.go +++ b/webmail/view_test.go @@ -32,7 +32,7 @@ func TestView(t *testing.T) { defer store.Switchboard()() log := mlog.New("webmail", nil) - acc, err := store.OpenAccount(log, "mjl") + acc, err := store.OpenAccount(log, "mjl", false) tcheck(t, err, "open account") err = acc.SetPassword(log, "test1234") tcheck(t, err, "set password") diff --git a/webmail/webmail.go b/webmail/webmail.go index 954a87f..45fca7a 100644 --- a/webmail/webmail.go +++ b/webmail/webmail.go @@ -85,7 +85,7 @@ var ( metricSubmission = promauto.NewCounterVec( prometheus.CounterOpts{ Name: "mox_webmail_submission_total", - Help: "Webmail message submission results, known values (those ending with error are server errors): ok, badfrom, messagelimiterror, recipientlimiterror, queueerror, storesenterror.", + Help: "Webmail message submission results, known values (those ending with error are server errors): ok, badfrom, messagelimiterror, recipientlimiterror, queueerror, storesenterror, domaindisabled.", }, []string{ "result", @@ -328,7 +328,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt if accName != "" { log = log.With(slog.String("account", accName)) var err error - acc, err = store.OpenAccount(log, accName) + acc, err = store.OpenAccount(log, accName, true) if err != nil { log.Errorx("open account", err) http.Error(w, "500 - internal server error - error opening account", http.StatusInternalServerError) @@ -400,7 +400,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt var err error - acc, err = store.OpenAccount(log, accName) + acc, err = store.OpenAccount(log, accName, false) xcheckf(ctx, err, "open account") m = store.Message{ID: id} diff --git a/webmail/webmail_test.go b/webmail/webmail_test.go index 0349c61..5211f95 100644 --- a/webmail/webmail_test.go +++ b/webmail/webmail_test.go @@ -309,7 +309,7 @@ func TestWebmail(t *testing.T) { defer store.Switchboard()() log := mlog.New("webmail", nil) - acc, err := store.OpenAccount(pkglog, "mjl") + acc, err := store.OpenAccount(pkglog, "mjl", false) tcheck(t, err, "open account") err = acc.SetPassword(pkglog, "test1234") tcheck(t, err, "set password") diff --git a/webops/export.go b/webops/export.go index af82725..c69f659 100644 --- a/webops/export.go +++ b/webops/export.go @@ -44,7 +44,7 @@ func Export(log mlog.Log, accName string, w http.ResponseWriter, r *http.Request return } - acc, err := store.OpenAccount(log, accName) + acc, err := store.OpenAccount(log, accName, false) if err != nil { log.Errorx("open account for export", err) http.Error(w, "500 - internal server error", http.StatusInternalServerError)