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
This commit is contained in:
Mechiel Lukkien 2025-01-25 20:39:20 +01:00
parent 132efdd9fb
commit 2d3d726f05
No known key found for this signature in database
67 changed files with 1078 additions and 231 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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.<domain>. 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."`

View file

@ -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:

93
ctl.go
View file

@ -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()

View file

@ -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()

View file

@ -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)

51
doc.go
View file

@ -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.

View file

@ -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")

View file

@ -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
}

View file

@ -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")

View file

@ -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)
}

View file

@ -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")
}

View file

@ -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)

View file

@ -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 {

View file

@ -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)

120
main.go
View file

@ -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 {

View file

@ -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
},
)

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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")

View file

@ -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)
}

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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)
}

View file

@ -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()

View file

@ -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: <mjl@disabled.example>
To: <remote@example.org>
Subject: test
Message-Id: <test@mox.example>
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()

View file

@ -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
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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 {

View file

@ -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")

View file

@ -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

View file

@ -24,3 +24,8 @@ Accounts:
Destinations:
limit@mox.example: nil
QuotaMessageSize: 1
disabled:
Domain: mox.example
LoginDisabled: testing
Destinations:
disabled@mox.example: nil

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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
}

View file

@ -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"] }] },

View file

@ -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"}

View file

@ -574,6 +574,13 @@
"int64"
]
},
{
"Name": "LoginDisabled",
"Docs": "",
"Typewords": [
"string"
]
},
{
"Name": "Domain",
"Docs": "",

View file

@ -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"]}]},

View file

@ -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)
}

View file

@ -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")

View file

@ -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.<domain>.'), 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;

View file

@ -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?')) {

View file

@ -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": "",

View file

@ -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<Domain[] | null> {
// Domains returns all configured domain names.
async Domains(): Promise<ConfigDomain[] | null> {
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<string[] | null> {
// 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<void> {
async DomainAdd(disabled: boolean, domain: string, accountName: string, localpart: string): Promise<void> {
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<void> {
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<ClientConfigs> {
@ -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<void> {
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<void> {
const fn: string = "AliasAdd"
const paramTypes: string[][] = [["string"],["string"],["Alias"]]

View file

@ -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)

View file

@ -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

View file

@ -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) {

View file

@ -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) {

View file

@ -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 {

View file

@ -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)

View file

@ -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{

View file

@ -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 != "" {

View file

@ -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")

View file

@ -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}

View file

@ -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")

View file

@ -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)