diff --git a/config/config.go b/config/config.go index 796425c..910be52 100644 --- a/config/config.go +++ b/config/config.go @@ -368,6 +368,7 @@ type Account struct { JunkFilter *JunkFilter `sconf:"optional" sconf-doc:"Content-based filtering, using the junk-status of individual messages to rank words in such messages as spam or ham. It is recommended you always set the applicable (non)-junk status on messages, and that you do not empty your Trash because those messages contain valuable ham/spam training information."` // todo: sane defaults for junkfilter MaxOutgoingMessagesPerDay int `sconf:"optional" sconf-doc:"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."` MaxFirstTimeRecipientsPerDay int `sconf:"optional" sconf-doc:"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."` + NoFirstTimeSenderDelay bool `sconf:"optional" sconf-doc:"Do not apply a delay to SMTP connections before accepting an incoming message from a first-time sender. Can be useful for accounts that sends automated responses and want instant replies."` Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates these account routes, domain routes and finally global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."` DNSDomain dns.Domain `sconf:"-"` // Parsed form of Domain. diff --git a/config/doc.go b/config/doc.go index 93e0c13..d15a454 100644 --- a/config/doc.go +++ b/config/doc.go @@ -1032,6 +1032,11 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. # this mail server in case of account compromise. Default 200. (optional) MaxFirstTimeRecipientsPerDay: 0 + # Do not apply a delay to SMTP connections before accepting an incoming message + # from a first-time sender. Can be useful for accounts that sends automated + # responses and want instant replies. (optional) + NoFirstTimeSenderDelay: false + # Routes for delivering outgoing messages through the queue. Each delivery attempt # evaluates these account routes, domain routes and finally global routes. The # transport of the first matching route is used in the delivery attempt. If no diff --git a/mox-/admin.go b/mox-/admin.go index 406420c..3f71213 100644 --- a/mox-/admin.go +++ b/mox-/admin.go @@ -1032,12 +1032,12 @@ func DestinationSave(ctx context.Context, account, destName string, newDest conf return nil } -// AccountLimitsSave saves new message sending limits for an account. -func AccountLimitsSave(ctx context.Context, account string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, quotaMessageSize int64) (rerr error) { +// AccountAdminSettingsSave saves new account settings for an account only an admin can change. +func AccountAdminSettingsSave(ctx context.Context, account string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, quotaMessageSize int64, firstTimeSenderDelay bool) (rerr error) { log := pkglog.WithContext(ctx) defer func() { if rerr != nil { - log.Errorx("saving account limits", rerr, slog.String("account", account)) + log.Errorx("saving admin account settings", rerr, slog.String("account", account)) } }() @@ -1060,12 +1060,13 @@ func AccountLimitsSave(ctx context.Context, account string, maxOutgoingMessagesP acc.MaxOutgoingMessagesPerDay = maxOutgoingMessagesPerDay acc.MaxFirstTimeRecipientsPerDay = maxFirstTimeRecipientsPerDay acc.QuotaMessageSize = quotaMessageSize + acc.NoFirstTimeSenderDelay = !firstTimeSenderDelay nc.Accounts[account] = acc if err := writeDynamic(ctx, log, nc); err != nil { return fmt.Errorf("writing domains.conf: %v", err) } - log.Info("account limits saved", slog.String("account", account)) + log.Info("admin account settings saved", slog.String("account", account)) return nil } diff --git a/smtpserver/server.go b/smtpserver/server.go index 63b4d2a..6477020 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -2805,8 +2805,8 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW log.Check(err, "adding dmarc evaluation to database for aggregate report") } + conf, _ := acc.Conf() if !a.accept { - conf, _ := acc.Conf() if conf.RejectsMailbox != "" { present, _, messagehash, err := rejectPresent(log, acc, conf.RejectsMailbox, &m, dataFile) if err != nil { @@ -2870,10 +2870,10 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW } } - // If this is a first-time sender and not a forwarded message, wait before actually - // delivering. If this turns out to be a spammer, we've kept one of their - // connections busy. - if delayFirstTime && !m.IsForward && a.reason == reasonNoBadSignals && c.firstTimeSenderDelay > 0 { + // If this is a first-time sender and not a forwarded/mailing list message, wait + // before actually delivering. If this turns out to be a spammer, we've kept one of + // their connections busy. + if delayFirstTime && !m.IsForward && !m.IsMailingList && a.reason == reasonNoBadSignals && !conf.NoFirstTimeSenderDelay && c.firstTimeSenderDelay > 0 { log.Debug("delaying before delivering from sender without reputation", slog.Duration("delay", c.firstTimeSenderDelay)) mox.Sleep(mox.Context, c.firstTimeSenderDelay) } @@ -2915,7 +2915,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW metricDelivery.WithLabelValues("delivered", a.reason).Inc() log.Info("incoming message delivered", slog.String("reason", a.reason), slog.Any("msgfrom", msgFrom)) - conf, _ := acc.Conf() + conf, _ = acc.Conf() if conf.RejectsMailbox != "" && m.MessageID != "" { if err := acc.RejectsRemove(log, conf.RejectsMailbox, m.MessageID); err != nil { log.Errorx("removing message from rejects mailbox", err, slog.String("messageid", messageID)) diff --git a/webadmin/admin.go b/webadmin/admin.go index 08c9f80..0ee59b7 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -1957,10 +1957,10 @@ func (Admin) SetPassword(ctx context.Context, accountName, password string) { xcheckf(ctx, err, "setting password") } -// SetAccountLimits set new limits on outgoing messages for an account. -func (Admin) SetAccountLimits(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, maxMsgSize int64) { - err := mox.AccountLimitsSave(ctx, accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize) - xcheckf(ctx, err, "saving account limits") +// AccountSettingsSave set new settings for an account that only an admin can set. +func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, maxMsgSize int64, firstTimeSenderDelay bool) { + err := mox.AccountAdminSettingsSave(ctx, accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize, firstTimeSenderDelay) + xcheckf(ctx, err, "saving account settings") } // ClientConfigsDomain returns configurations for email clients, IMAP and diff --git a/webadmin/admin.js b/webadmin/admin.js index d881093..78455fa 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -795,12 +795,12 @@ var api; const params = [accountName, password]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } - // SetAccountLimits set new limits on outgoing messages for an account. - async SetAccountLimits(accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize) { - const fn = "SetAccountLimits"; - const paramTypes = [["string"], ["int32"], ["int32"], ["int64"]]; + // AccountSettingsSave set new settings for an account that only an admin can set. + async AccountSettingsSave(accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize, firstTimeSenderDelay) { + const fn = "AccountSettingsSave"; + const paramTypes = [["string"], ["int32"], ["int32"], ["int64"], ["bool"]]; const returnTypes = []; - const params = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize]; + const params = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize, firstTimeSenderDelay]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } // ClientConfigsDomain returns configurations for email clients, IMAP and @@ -1807,6 +1807,7 @@ const account = async (name) => { let maxOutgoingMessagesPerDay; let maxFirstTimeRecipientsPerDay; let quotaMessageSize; + let firstTimeSenderDelay; let formPassword; let fieldsetPassword; let password; @@ -1892,13 +1893,13 @@ const account = async (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('Limits'), dom.form(fieldsetLimits = 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.')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize))), ' Current usage is ', formatQuotaSize(Math.floor(diskUsage / (1024 * 1024)) * 1024 * 1024), '.'), dom.submitbutton('Save')), async function submit(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), domainName(d) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Settings'), dom.form(fieldsetLimits = 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.')), 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) { e.stopPropagation(); e.preventDefault(); fieldsetLimits.disabled = true; try { - await client.SetAccountLimits(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value)); - window.alert('Limits saved.'); + await client.AccountSettingsSave(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value), firstTimeSenderDelay.checked); + window.alert('Settings saved.'); } catch (err) { console.log({ err }); diff --git a/webadmin/admin.ts b/webadmin/admin.ts index 32e517e..fba7e2c 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -637,6 +637,7 @@ const account = async (name: string) => { let maxOutgoingMessagesPerDay: HTMLInputElement let maxFirstTimeRecipientsPerDay: HTMLInputElement let quotaMessageSize: HTMLInputElement + let firstTimeSenderDelay: HTMLInputElement let formPassword: HTMLFormElement let fieldsetPassword: HTMLFieldSetElement @@ -769,7 +770,7 @@ const account = async (name: string) => { ), ), dom.br(), - dom.h2('Limits'), + dom.h2('Settings'), dom.form( fieldsetLimits=dom.fieldset( dom.label( @@ -791,6 +792,13 @@ const account = async (name: string) => { 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: SubmitEvent) { @@ -798,8 +806,8 @@ const account = async (name: string) => { e.preventDefault() fieldsetLimits.disabled = true try { - await client.SetAccountLimits(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value)) - window.alert('Limits saved.') + await client.AccountSettingsSave(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value), firstTimeSenderDelay.checked) + window.alert('Settings saved.') } catch (err) { console.log({err}) window.alert('Error: ' + errmsg(err)) diff --git a/webadmin/api.json b/webadmin/api.json index 9105de6..10d5baf 100644 --- a/webadmin/api.json +++ b/webadmin/api.json @@ -617,8 +617,8 @@ "Returns": [] }, { - "Name": "SetAccountLimits", - "Docs": "SetAccountLimits set new limits on outgoing messages for an account.", + "Name": "AccountSettingsSave", + "Docs": "AccountSettingsSave set new settings for an account that only an admin can set.", "Params": [ { "Name": "accountName", @@ -643,6 +643,12 @@ "Typewords": [ "int64" ] + }, + { + "Name": "firstTimeSenderDelay", + "Typewords": [ + "bool" + ] } ], "Returns": [] diff --git a/webadmin/api.ts b/webadmin/api.ts index 14a3b44..52d864e 100644 --- a/webadmin/api.ts +++ b/webadmin/api.ts @@ -1276,12 +1276,12 @@ export class Client { return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void } - // SetAccountLimits set new limits on outgoing messages for an account. - async SetAccountLimits(accountName: string, maxOutgoingMessagesPerDay: number, maxFirstTimeRecipientsPerDay: number, maxMsgSize: number): Promise { - const fn: string = "SetAccountLimits" - const paramTypes: string[][] = [["string"],["int32"],["int32"],["int64"]] + // AccountSettingsSave set new settings for an account that only an admin can set. + async AccountSettingsSave(accountName: string, maxOutgoingMessagesPerDay: number, maxFirstTimeRecipientsPerDay: number, maxMsgSize: number, firstTimeSenderDelay: boolean): Promise { + const fn: string = "AccountSettingsSave" + const paramTypes: string[][] = [["string"],["int32"],["int32"],["int64"],["bool"]] const returnTypes: string[][] = [] - const params: any[] = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize] + const params: any[] = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize, firstTimeSenderDelay] return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void }