diff --git a/config/config.go b/config/config.go index aa515d0..796425c 100644 --- a/config/config.go +++ b/config/config.go @@ -107,7 +107,7 @@ type SpecialUseMailboxes struct { // Dynamic is the parsed form of domains.conf, and is automatically reloaded when changed. type Dynamic struct { Domains map[string]Domain `sconf-doc:"NOTE: This config file is in 'sconf' format. Indent with tabs. Comments must be on their own line, they don't end a line. Do not escape or quote strings. Details: https://pkg.go.dev/github.com/mjl-/sconf.\n\n\nDomains for which email is accepted. For internationalized domains, use their IDNA names in UTF-8."` - Accounts map[string]Account `sconf-doc:"Account represent mox users, each with a password and one or more email addresses to which email can be delivered (possibly different domains). Each account has its own on-disk directory holding its messages and index database. An account name has is not an email address."` + Accounts map[string]Account `sconf-doc:"Accounts represent mox users, each with a password and email address(es) to which email can be delivered (possibly at different domains). Each account has its own on-disk directory holding its messages and index database. An account name is not an email address."` WebDomainRedirects map[string]string `sconf:"optional" sconf-doc:"Redirect all requests from domain (key) to domain (value). Always redirects to HTTPS. For plain HTTP redirects, use a WebHandler with a WebRedirect."` WebHandlers []WebHandler `sconf:"optional" sconf-doc:"Handle webserver requests by serving static files, redirecting or reverse-proxying HTTP(s). The first matching WebHandler will handle the request. Built-in handlers, e.g. for account, admin, autoconfig and mta-sts always run first. If no handler matches, the response status code is file not found (404). If functionality you need is missng, simply forward the requests to an application that can provide the needed functionality."` Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates account routes, domain routes and finally these 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."` @@ -352,7 +352,7 @@ type Account struct { 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."` - Destinations map[string]Destination `sconf-doc:"Destinations, keys are email addresses (with IDNA domains). If the address is of the form '@domain', i.e. with localpart missing, it serves as a catchall for the domain, matching all messages that are not explicitly configured. Deprecated behaviour: If the address is not a full address but a localpart, it is combined with Domain to form a full address."` + Destinations map[string]Destination `sconf:"optional" sconf-doc:"Destinations, keys are email addresses (with IDNA domains). All destinations are allowed for logging in with IMAP/SMTP/webmail. If no destinations are configured, the account can not login. If the address is of the form '@domain', i.e. with localpart missing, it serves as a catchall for the domain, matching all messages that are not explicitly configured. Deprecated behaviour: If the address is not a full address but a localpart, it is combined with Domain to form a full address."` SubjectPass struct { Period time.Duration `sconf-doc:"How long unique values are accepted after generating, e.g. 12h."` // todo: have a reasonable default for this? } `sconf:"optional" sconf-doc:"If configured, messages classified as weakly spam are rejected with instructions to retry delivery, but this time with a signed token added to the subject. During the next delivery attempt, the signed token will bypass the spam filter. Messages with a clear spam signal, such as a known bad reputation, are rejected/delayed without a signed token."` diff --git a/config/doc.go b/config/doc.go index fccab27..93e0c13 100644 --- a/config/doc.go +++ b/config/doc.go @@ -836,10 +836,10 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. MinimumAttempts: 0 Transport: - # Account represent mox users, each with a password and one or more email - # addresses to which email can be delivered (possibly different domains). Each - # account has its own on-disk directory holding its messages and index database. - # An account name has is not an email address. + # Accounts represent mox users, each with a password and email address(es) to + # which email can be delivered (possibly at different domains). Each account has + # its own on-disk directory holding its messages and index database. An account + # name is not an email address. Accounts: x: @@ -854,11 +854,13 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. # be overridden per destination. (optional) FullName: - # Destinations, keys are email addresses (with IDNA domains). If the address is of - # the form '@domain', i.e. with localpart missing, it serves as a catchall for the - # domain, matching all messages that are not explicitly configured. Deprecated - # behaviour: If the address is not a full address but a localpart, it is combined - # with Domain to form a full address. + # Destinations, keys are email addresses (with IDNA domains). All destinations are + # allowed for logging in with IMAP/SMTP/webmail. If no destinations are + # configured, the account can not login. If the address is of the form '@domain', + # i.e. with localpart missing, it serves as a catchall for the domain, matching + # all messages that are not explicitly configured. Deprecated behaviour: If the + # address is not a full address but a localpart, it is combined with Domain to + # form a full address. (optional) Destinations: x: diff --git a/webaccount/account.js b/webaccount/account.js index f55164f..9e59b2e 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -916,7 +916,7 @@ const index = async () => { finally { fullNameFieldset.disabled = false; } - }), dom.br(), dom.h2('Addresses'), dom.ul(Object.entries(destinations).sort().map(t => dom.li(dom.a(t[0], attr.href('#destinations/' + t[0])), t[0].startsWith('@') ? ' (catchall)' : []))), dom.br(), dom.h2('Change password'), passwordForm = dom.form(passwordFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'New password', dom.br(), password1 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''), function focus() { + }), dom.br(), dom.h2('Addresses'), dom.ul(Object.entries(destinations || {}).length === 0 ? dom.li('(None, login disabled)') : [], Object.entries(destinations || {}).sort().map(t => dom.li(dom.a(t[0], attr.href('#destinations/' + t[0])), t[0].startsWith('@') ? ' (catchall)' : []))), dom.br(), dom.h2('Change password'), passwordForm = dom.form(passwordFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'New password', dom.br(), password1 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''), function focus() { passwordHint.style.display = ''; })), ' ', dom.label(style({ display: 'inline-block' }), 'New password repeat', dom.br(), password2 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''))), ' ', dom.submitbutton('Change password')), passwordHint = dom.div(style({ display: 'none', marginTop: '.5ex' }), dom.clickbutton('Generate random password', function click(e) { e.preventDefault(); @@ -1117,7 +1117,7 @@ const destination = async (name) => { let defaultMailbox; let fullName; let saveButton; - const addresses = [name, ...Object.keys(destinations).filter(a => !a.startsWith('@') && a !== name)]; + const addresses = [name, ...Object.keys(destinations || {}).filter(a => !a.startsWith('@') && a !== name)]; dom._kids(page, crumbs(crumblink('Mox Account', '#'), 'Destination ' + name), dom.div(dom.span('Default mailbox', attr.title('Default mailbox where email for this recipient is delivered to if it does not match any ruleset. Default is Inbox.')), dom.br(), defaultMailbox = dom.input(attr.value(dest.Mailbox), attr.placeholder('Inbox'))), dom.br(), dom.div(dom.span('Full name', attr.title('Name to use in From header when composing messages. If not set, the account default full name is used.')), dom.br(), fullName = dom.input(attr.value(dest.FullName))), dom.br(), dom.h2('Rulesets'), dom.p('Incoming messages are checked against the rulesets. If a ruleset matches, the message is delivered to the mailbox configured for the ruleset instead of to the default mailbox.'), dom.p('"Is Forward" does not affect matching, but changes prevents the sending mail server from being included in future junk classifications by clearing fields related to the forwarding email server (IP address, EHLO domain, MAIL FROM domain and a matching DKIM domain), and prevents DMARC rejects for forwarded messages.'), dom.p('"List allow domain" does not affect matching, but skips the regular spam checks if one of the verified domains is a (sub)domain of the domain mentioned here.'), dom.p('"Accept rejects to mailbox" does not affect matching, but causes messages classified as junk to be accepted and delivered to this mailbox, instead of being rejected during the SMTP transaction. Useful for incoming forwarded messages where rejecting incoming messages may cause the forwarding server to stop forwarding.'), dom.table(dom.thead(dom.tr(dom.th('SMTP "MAIL FROM" regexp', attr.title('Matches if this regular expression matches (a substring of) the SMTP MAIL FROM address (not the message From-header). E.g. user@example.org.')), dom.th('Verified domain', attr.title('Matches if this domain matches an SPF- and/or DKIM-verified (sub)domain.')), dom.th('Headers regexp', attr.title('Matches if these header field/value regular expressions all match (substrings of) the message headers. Header fields and valuees are converted to lower case before matching. Whitespace is trimmed from the value before matching. A header field can occur multiple times in a message, only one instance has to match. For mailing lists, you could match on ^list-id$ with the value typically the mailing list address in angled brackets with @ replaced with a dot, e.g. .')), dom.th('Is Forward', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. Can only be used together with SMTPMailFromRegexp and VerifiedDomain. SMTPMailFromRegexp must be set to the address used to deliver the forwarded message, e.g. '^user(|\\+.*)@forward\\.example$'. Changes to junk analysis: 1. Messages are not rejected for failing a DMARC policy, because a legitimate forwarded message without valid/intact/aligned DKIM signature would be rejected because any verified SPF domain will be 'unaligned', of the forwarding mail server. 2. The sending mail server IP address, and sending EHLO and MAIL FROM domains and matching DKIM domain aren't used in future reputation-based spam classifications (but other verified DKIM domains are) because the forwarding server is not a useful spam signal for future messages.")), dom.th('List allow domain', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. If this domain matches an SPF- and/or DKIM-verified (sub)domain, the message is accepted without further spam checks, such as a junk filter or DMARC reject evaluation. DMARC rejects should not apply for mailing lists that are not configured to rewrite the From-header of messages that don't have a passing DKIM signature of the From-domain. Otherwise, by rejecting messages, you may be automatically unsubscribed from the mailing list. The assumption is that mailing lists do their own spam filtering/moderation.")), dom.th('Allow rejects to mailbox', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. If a message is classified as spam, it isn't rejected during the SMTP transaction (the normal behaviour), but accepted during the SMTP transaction and delivered to the specified mailbox. The specified mailbox is not automatically cleaned up like the account global Rejects mailbox, unless set to that Rejects mailbox.")), dom.th('Mailbox', attr.title('Mailbox to deliver to if this ruleset matches.')), dom.th('Action'))), rulesetsTbody, dom.tfoot(dom.tr(dom.td(attr.colspan('7')), dom.td(dom.clickbutton('Add ruleset', function click() { addRulesetsRow({ SMTPMailFromRegexp: '', diff --git a/webaccount/account.ts b/webaccount/account.ts index e24ce82..b3d9a2b 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -341,7 +341,8 @@ const index = async () => { dom.h2('Addresses'), dom.ul( - Object.entries(destinations).sort().map(t => + Object.entries(destinations || {}).length === 0 ? dom.li('(None, login disabled)') : [], + Object.entries(destinations || {}).sort().map(t => dom.li( dom.a(t[0], attr.href('#destinations/'+t[0])), t[0].startsWith('@') ? ' (catchall)' : [], @@ -689,7 +690,7 @@ const destination = async (name: string) => { let fullName: HTMLInputElement let saveButton: HTMLButtonElement - const addresses = [name, ...Object.keys(destinations).filter(a => !a.startsWith('@') && a !== name)] + const addresses = [name, ...Object.keys(destinations || {}).filter(a => !a.startsWith('@') && a !== name)] dom._kids(page, crumbs( diff --git a/webadmin/admin.js b/webadmin/admin.js index 8922b93..8173b6c 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -1761,7 +1761,7 @@ const accounts = async () => { if (!accountModified) { account.value = email.value.split('@')[0]; } - })), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account name', attr.title('An account has one or more email addresses, a password. 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('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.'))))); }; @@ -1826,7 +1826,7 @@ const account = async (name) => { } return '' + v; }; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), name), dom.div('Default domain: ', config.Domain ? dom.a(config.Domain, attr.href('#domains/' + config.Domain)) : '(none)'), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Action'))), dom.tbody(Object.keys(config.Destinations).map(k => { + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), name), dom.div('Default domain: ', config.Domain ? dom.a(config.Domain, attr.href('#domains/' + config.Domain)) : '(none)'), dom.br(), 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) { diff --git a/webadmin/admin.ts b/webadmin/admin.ts index b2349e2..d895c9f 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -575,7 +575,7 @@ const accounts = async () => { ' ', dom.label( style({display: 'inline-block'}), - dom.span('Account name', attr.title('An account has one or more email addresses, a password. 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.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 @@ -669,7 +669,8 @@ const account = async (name: string) => { ), ), dom.tbody( - Object.keys(config.Destinations).map(k => { + Object.keys(config.Destinations || {}).length === 0 ? dom.tr(dom.td(attr.colspan('2'), '(None, login disabled)')) : [], + Object.keys(config.Destinations || {}).map(k => { let v: ElemArg = k const t = k.split('@') if (t.length > 1) {