diff --git a/config/config.go b/config/config.go index 43bbe50..b12c7ce 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "crypto/x509" "net" + "reflect" "regexp" "time" @@ -216,15 +217,28 @@ type Destination struct { TLSReports bool `sconf:"-" json:"-"` } +// Equal returns whether d and o are equal, only looking at their user-changeable fields. +func (d Destination) Equal(o Destination) bool { + if d.Mailbox != o.Mailbox || len(d.Rulesets) != len(o.Rulesets) { + return false + } + for i, rs := range d.Rulesets { + if !rs.Equal(o.Rulesets[i]) { + return false + } + } + return true +} + type Ruleset struct { SMTPMailFromRegexp string `sconf:"optional" sconf-doc:"Matches if this regular expression matches (a substring of) the SMTP MAIL FROM address (not the message From-header). E.g. user@example.org."` - VerifiedDomain string `sconf:"optional" sconf-doc:"Matches if this domain or a subdomain matches a SPF- and/or DKIM-verified domain."` - HeadersRegexp map[string]string `sconf:"optional" sconf-doc:"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."` + VerifiedDomain string `sconf:"optional" sconf-doc:"Matches if this domain matches an SPF- and/or DKIM-verified (sub)domain."` + HeadersRegexp map[string]string `sconf:"optional" sconf-doc:"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. ."` // todo: add a SMTPRcptTo check, and MessageFrom that works on a properly parsed From header. - ListAllowDomain string `sconf:"optional" sconf-doc:"Influence the spam filtering, this does not change whether this ruleset applies to a message. 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."` + ListAllowDomain string `sconf:"optional" sconf-doc:"Influence the spam filtering, this does not change whether this ruleset applies to a message. 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."` - Mailbox string `sconf-doc:"Mailbox to deliver to if Rules match."` + Mailbox string `sconf-doc:"Mailbox to deliver to if this ruleset matches."` SMTPMailFromRegexpCompiled *regexp.Regexp `sconf:"-" json:"-"` VerifiedDNSDomain dns.Domain `sconf:"-"` @@ -232,6 +246,17 @@ type Ruleset struct { ListAllowDNSDomain dns.Domain `sconf:"-"` } +// Equal returns whether r and o are equal, only looking at their user-changeable fields. +func (r Ruleset) Equal(o Ruleset) bool { + if r.SMTPMailFromRegexp != o.SMTPMailFromRegexp || r.VerifiedDomain != o.VerifiedDomain || r.ListAllowDomain != o.ListAllowDomain || r.Mailbox != o.Mailbox { + return false + } + if !reflect.DeepEqual(r.HeadersRegexp, o.HeadersRegexp) { + return false + } + return true +} + type TLS struct { ACME string `sconf:"optional" sconf-doc:"Name of provider from top-level configuration to use for ACME, e.g. letsencrypt."` KeyCerts []struct { diff --git a/config/doc.go b/config/doc.go index e725101..31a4471 100644 --- a/config/doc.go +++ b/config/doc.go @@ -383,15 +383,17 @@ describe-static" and "mox config describe-domains": # address (not the message From-header). E.g. user@example.org. (optional) SMTPMailFromRegexp: - # Matches if this domain or a subdomain matches a SPF- and/or DKIM-verified - # domain. (optional) + # Matches if this domain matches an SPF- and/or DKIM-verified (sub)domain. + # (optional) VerifiedDomain: # 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. - # (optional) + # 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. + # . (optional) HeadersRegexp: x: @@ -401,10 +403,11 @@ describe-static" and "mox config describe-domains": # 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. (optional) + # may be automatically unsubscribed from the mailing list. The assumption is that + # mailing lists do their own spam filtering/moderation. (optional) ListAllowDomain: - # Mailbox to deliver to if Rules match. + # Mailbox to deliver to if this ruleset matches. Mailbox: # If configured, messages classified as weakly spam are rejected with instructions diff --git a/http/account.go b/http/account.go index 3784efb..ef4bc71 100644 --- a/http/account.go +++ b/http/account.go @@ -15,6 +15,8 @@ import ( "github.com/mjl-/sherpa" "github.com/mjl-/sherpaprom" + "github.com/mjl-/mox/config" + "github.com/mjl-/mox/dns" "github.com/mjl-/mox/metrics" "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mox-" @@ -114,3 +116,41 @@ func (Account) SetPassword(ctx context.Context, password string) { err = acc.SetPassword(password) xcheckf(ctx, err, "setting password") } + +// Destinations returns the default domain, and the destinations (keys are email +// addresses, or localparts to the default domain). +// todo: replace with a function that returns the whole account, when sherpadoc understands unnamed struct fields. +func (Account) Destinations(ctx context.Context) (dns.Domain, map[string]config.Destination) { + accountName := ctx.Value(authCtxKey).(string) + accConf, ok := mox.Conf.Account(accountName) + if !ok { + xcheckf(ctx, errors.New("not found"), "looking up account") + } + return accConf.DNSDomain, accConf.Destinations +} + +// DestinationSave updates a destination. +// OldDest is compared against the current destination. If it does not match, an +// error is returned. Otherwise newDest is saved and the configuration reloaded. +func (Account) DestinationSave(ctx context.Context, destName string, oldDest, newDest config.Destination) { + accountName := ctx.Value(authCtxKey).(string) + accConf, ok := mox.Conf.Account(accountName) + if !ok { + xcheckf(ctx, errors.New("not found"), "looking up account") + } + curDest, ok := accConf.Destinations[destName] + if !ok { + xcheckf(ctx, errors.New("not found"), "looking up destination") + } + + if !curDest.Equal(oldDest) { + xcheckf(ctx, errors.New("modified"), "checking stored destination") + } + + // Keep fields we manage. + newDest.DMARCReports = curDest.DMARCReports + newDest.TLSReports = curDest.TLSReports + + err := mox.DestinationSave(ctx, accountName, destName, newDest) + xcheckf(ctx, err, "saving destination") +} diff --git a/http/account.html b/http/account.html index 1048af2..fefa222 100644 --- a/http/account.html +++ b/http/account.html @@ -11,6 +11,7 @@ h1, h2, h3, h4 { margin-bottom: 1ex; } h1 { font-size: 1.2rem; } h2 { font-size: 1.1rem; } h3, h4 { font-size: 1rem; } +ul { padding-left: 1rem; } .literal { background-color: #fdfdfd; padding: .5em 1em; border: 1px solid #eee; border-radius: 4px; white-space: pre-wrap; font-family: monospace; font-size: 15px; tab-size: 4; } table td, table th { padding: .2em .5em; } table > tbody > tr:nth-child(odd) { background-color: #f8f8f8; } @@ -118,17 +119,39 @@ const footer = dom.div( api._sherpa.version, ) -const index = async () => { - let form, fieldset, password1, password2 +const domainName = d => { + return d.Unicode || d.ASCII +} - const blockStyle = style({ - display: 'block', - marginBottom: '1ex', - }) +const domainString = d => { + if (d.Unicode) { + return d.Unicode+" ("+d.ASCII+")" + } + return d.ASCII +} + +const index = async () => { + const [domain, destinations] = await api.Destinations() + + let form, fieldset, password1, password2, passwordHint const page = document.getElementById('page') dom._kids(page, crumbs('Mox Account'), + dom.div( + 'Default domain: ', + domain.ASCII ? domainString(domain) : '(none)', + ), + dom.br(), + dom.h2('Addresses'), + dom.ul( + Object.entries(destinations).sort().map(t => + dom.li( + dom.a(t[0], attr({href: '#destinations/'+t[0]})), + ), + ), + ), + dom.br(), dom.h2('Change password'), form=dom.form( fieldset=dom.fieldset( @@ -136,7 +159,9 @@ const index = async () => { style({display: 'inline-block'}), 'New password', dom.br(), - password1=dom.input(attr({type: 'password', required: ''})), + password1=dom.input(attr({type: 'password', required: ''}), function focus() { + passwordHint.style.display = '' + }), ), ' ', dom.label( @@ -147,6 +172,7 @@ const index = async () => { ), ' ', dom.button('Change password'), + passwordHint=dom.div(style({display: 'none', marginTop: '.5ex', fontStyle: 'italic'}), 'Password must be at least 8 characters.'), ), async function submit(e) { e.stopPropagation() @@ -172,6 +198,151 @@ const index = async () => { ) } +const destination = async (name) => { + const [domain, destinations] = await api.Destinations() + let dest = destinations[name] + if (!dest) { + throw new Error('destination not found') + } + + let rulesetsTbody = dom.tbody() + let rulesetsRows = [] + + const addRulesetsRow = (rs) => { + let headersCell = dom.td() + let headers = [] // Holds objects: {key, value, root} + const addHeader = (k, v) => { + let h = {} + h.root = dom.div( + h.key=dom.input(attr({value: k})), + ' ', + h.value=dom.input(attr({value: v})), + ' ', + dom.button('-', style({width: '1.5em'}), function click(e) { + h.root.remove() + headers = headers.filter(x => x !== h) + if (headers.length === 0) { + const b = dom.button('+', style({width: '1.5em'}), function click(e) { + e.target.remove() + addHeader('', '') + }) + headersCell.appendChild(dom.div(style({textAlign: 'right'}), b)) + } + }), + ' ', + dom.button('+', style({width: '1.5em'}), function click(e) { + addHeader('', '') + }), + ) + headers.push(h) + headersCell.appendChild(h.root) + } + Object.entries(rs.HeadersRegexp || {}).sort().forEach(t => + addHeader(t[0], t[1]) + ) + if (Object.entries(rs.HeadersRegexp || {}).length === 0) { + const b = dom.button('+', style({width: '1.5em'}), function click(e) { + e.target.remove() + addHeader('', '') + }) + headersCell.appendChild(dom.div(style({textAlign: 'right'}), b)) + } + + let row = {headers} + row.root=dom.tr( + dom.td(row.SMTPMailFromRegexp=dom.input(attr({value: rs.SMTPMailFromRegexp || ''}))), + dom.td(row.VerifiedDomain=dom.input(attr({value: rs.VerifiedDomain || ''}))), + headersCell, + dom.td(row.ListAllowDomain=dom.input(attr({value: rs.ListAllowDomain || ''}))), + dom.td(row.Mailbox=dom.input(attr({value: rs.Mailbox || ''}))), + dom.td( + dom.button('Remove ruleset', function click(e) { + row.root.remove() + rulesetsRows = rulesetsRows.filter(e => e !== row) + }), + ), + ) + rulesetsRows.push(row) + rulesetsTbody.appendChild(row.root) + } + + (dest.Rulesets || []).forEach(rs => { + addRulesetsRow(rs) + }) + + let defaultMailbox + let saveButton + + const page = document.getElementById('page') + 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, placeholder: 'Inbox'})), + dom + ), + 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('The "List allow domain" does not affect the matching, but skips the regular spam checks if one of the verified domains is a (sub)domain of the domain mentioned here.'), + 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('List allow domain', attr({title: "Influence the spam filtering, this does not change whether this ruleset applies to a message. 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('Mailbox', attr({title: 'Mailbox to deliver to if this ruleset matches.'})), + dom.th('Action'), + ) + ), + rulesetsTbody, + dom.tfoot( + dom.tr( + dom.td(attr({colspan: '5'})), + dom.td( + dom.button('Add ruleset', function click(e) { + addRulesetsRow({}) + }), + ), + ), + ), + ), + dom.br(), + saveButton=dom.button('Save', async function click(e) { + saveButton.disabled = true + try { + const newDest = { + Mailbox: defaultMailbox.value, + Rulesets: rulesetsRows.map(row => { + return { + SMTPMailFromRegexp: row.SMTPMailFromRegexp.value, + VerifiedDomain: row.VerifiedDomain.value, + HeadersRegexp: Object.fromEntries(row.headers.map(h => [h.key.value, h.value.value])), + ListAllowDomain: row.ListAllowDomain.value, + Mailbox: row.Mailbox.value, + } + }), + } + page.classList.add('loading') + await api.DestinationSave(name, dest, newDest) + dest = newDest // Set new dest, for if user edits again. Without this, they would get an error that the config has been modified. + } catch (err) { + console.log({err}) + window.alert('Error: '+err.message) + return + } finally { + saveButton.disabled = false + page.classList.remove('loading') + } + }), + ) +} + const init = async () => { let curhash @@ -185,10 +356,13 @@ const init = async () => { if (h !== '' && h.substring(0, 1) == '#') { h = h.substring(1) } + const t = h.split('/') page.classList.add('loading') try { - if (h == '') { + if (h === '') { await index() + } else if (t[0] === 'destinations' && t.length === 2) { + await destination(t[1]) } else { dom._kids(page, 'page not found') } diff --git a/http/accountapi.json b/http/accountapi.json index 73c0ff1..1fed71c 100644 --- a/http/accountapi.json +++ b/http/accountapi.json @@ -14,10 +14,153 @@ } ], "Returns": [] + }, + { + "Name": "Destinations", + "Docs": "Destinations returns the default domain, and the destinations (keys are email\naddresses, or localparts to the default domain).\ntodo: replace with a function that returns the whole account, when sherpadoc understands unnamed struct fields.", + "Params": [], + "Returns": [ + { + "Name": "r0", + "Typewords": [ + "Domain" + ] + }, + { + "Name": "r1", + "Typewords": [ + "{}", + "Destination" + ] + } + ] + }, + { + "Name": "DestinationSave", + "Docs": "DestinationSave updates a destination.\nOldDest is compared against the current destination. If it does not match, an\nerror is returned. Otherwise newDest is saved and the configuration reloaded.", + "Params": [ + { + "Name": "destName", + "Typewords": [ + "string" + ] + }, + { + "Name": "oldDest", + "Typewords": [ + "Destination" + ] + }, + { + "Name": "newDest", + "Typewords": [ + "Destination" + ] + } + ], + "Returns": [] } ], "Sections": [], - "Structs": [], + "Structs": [ + { + "Name": "Domain", + "Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups.", + "Fields": [ + { + "Name": "ASCII", + "Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case.", + "Typewords": [ + "string" + ] + }, + { + "Name": "Unicode", + "Docs": "Name as U-labels. Empty if this is an ASCII-only domain.", + "Typewords": [ + "string" + ] + } + ] + }, + { + "Name": "Destination", + "Docs": "", + "Fields": [ + { + "Name": "Mailbox", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "Rulesets", + "Docs": "", + "Typewords": [ + "[]", + "Ruleset" + ] + } + ] + }, + { + "Name": "Ruleset", + "Docs": "", + "Fields": [ + { + "Name": "SMTPMailFromRegexp", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "VerifiedDomain", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "HeadersRegexp", + "Docs": "", + "Typewords": [ + "{}", + "string" + ] + }, + { + "Name": "ListAllowDomain", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "Mailbox", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "VerifiedDNSDomain", + "Docs": "", + "Typewords": [ + "Domain" + ] + }, + { + "Name": "ListAllowDNSDomain", + "Docs": "", + "Typewords": [ + "Domain" + ] + } + ] + } + ], "Ints": [], "Strings": [], "SherpaVersion": 0, diff --git a/mox-/admin.go b/mox-/admin.go index f1a842e..04a4f79 100644 --- a/mox-/admin.go +++ b/mox-/admin.go @@ -703,6 +703,51 @@ func AddressRemove(ctx context.Context, address string) (rerr error) { return nil } +// DestinationSave updates a destination for an account and reloads the configuration. +func DestinationSave(ctx context.Context, account, destName string, newDest config.Destination) (rerr error) { + log := xlog.WithContext(ctx) + defer func() { + if rerr != nil { + log.Errorx("saving destination", rerr, mlog.Field("account", account), mlog.Field("destname", destName), mlog.Field("destination", newDest)) + } + }() + + Conf.dynamicMutex.Lock() + defer Conf.dynamicMutex.Unlock() + + c := Conf.Dynamic + acc, ok := c.Accounts[account] + if !ok { + return fmt.Errorf("account not present") + } + + if _, ok := acc.Destinations[destName]; !ok { + return fmt.Errorf("destination not present") + } + + // Compose new config without modifying existing data structures. If we fail, we + // leave no trace. + nc := c + nc.Accounts = map[string]config.Account{} + for name, a := range c.Accounts { + nc.Accounts[name] = a + } + nd := map[string]config.Destination{} + for dn, d := range acc.Destinations { + nd[dn] = d + } + nd[destName] = newDest + nacc := nc.Accounts[account] + nacc.Destinations = nd + nc.Accounts[account] = nacc + + if err := writeDynamic(ctx, nc); err != nil { + return fmt.Errorf("writing domains.conf: %v", err) + } + log.Info("destination saved", mlog.Field("account", account), mlog.Field("destname", destName)) + return nil +} + // ClientConfig holds the client configuration for IMAP/Submission for a // domain. type ClientConfig struct {