mox/http/account.html
Mechiel Lukkien d48d19b840
in account web page, allow user to configure rulesets for delivery
for example, by matching incoming messags on smtp mail from, verified domains
(spf/dkim), headers. then delivering to a configured mailbox. for mailing
lists, if a verified domain matches, regular spam checks can be skipped.

this was already possible by editing the configuration file, but only admins
can edit that file. now users can manage their own rulesets.
2023-02-10 23:47:19 +01:00

386 lines
12 KiB
HTML

<!doctype html>
<html>
<head>
<title>Mox Account</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body, html { padding: 1em; font-size: 16px; }
* { font-size: inherit; font-family: ubuntu, lato, sans-serif; margin: 0; padding: 0; box-sizing: border-box; }
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; }
p { margin-bottom: 1em; max-width: 50em; }
[title] { text-decoration: underline; text-decoration-style: dotted; }
fieldset { border: 0; }
#page { opacity: 1; animation: fadein 0.15s ease-in; }
#page.loading { opacity: 0.1; animation: fadeout 1s ease-out; }
@keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } }
@keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } }
</style>
<script src="api/sherpa.js"></script>
</head>
<body>
<div id="page">Loading...</div>
<script>
const [dom, style, attr, prop] = (function() {
function _domKids(e, ...kl) {
kl.forEach(k => {
if (typeof k === 'string' || k instanceof String) {
e.appendChild(document.createTextNode(k))
} else if (k instanceof Node) {
e.appendChild(k)
} else if (Array.isArray(k)) {
_domKids(e, ...k)
} else if (typeof k === 'function') {
if (!k.name) {
throw new Error('function without name', k)
}
e.addEventListener(k.name, k)
} else if (typeof k === 'object' && k !== null) {
if (k.root) {
e.appendChild(k.root)
return
}
for (const key in k) {
const value = k[key]
if (key === '_prop') {
for (const prop in value) {
e[prop] = value[prop]
}
} else if (key === '_attr') {
for (const prop in value) {
e.setAttribute(prop, value[prop])
}
} else if (key === '_listen') {
e.addEventListener(...value)
} else {
e.style[key] = value
}
}
} else {
console.log('bad kid', k)
throw new Error('bad kid')
}
})
}
const _dom = (kind, ...kl) => {
const t = kind.split('.')
const e = document.createElement(t[0])
for (let i = 1; i < t.length; i++) {
e.classList.add(t[i])
}
_domKids(e, kl)
return e
}
_dom._kids = function(e, ...kl) {
while(e.firstChild) {
e.removeChild(e.firstChild)
}
_domKids(e, kl)
}
const dom = new Proxy(_dom, {
get: function(dom, prop) {
if (prop in dom) {
return dom[prop]
}
const fn = (...kl) => _dom(prop, kl)
dom[prop] = fn
return fn
},
apply: function(target, that, args) {
if (args.length === 1 && typeof args[0] === 'object' && !Array.isArray(args[0])) {
return {_attr: args[0]}
}
return _dom(...args)
},
})
const style = x => x
const attr = x => { return {_attr: x} }
const prop = x => { return {_prop: x} }
return [dom, style, attr, prop]
})()
const link = (href, anchorOpt) => dom.a(attr({href: href, rel: 'noopener noreferrer'}), anchorOpt || href)
const crumblink = (text, link) => dom.a(text, attr({href: link}))
const crumbs = (...l) => [dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])), dom.br()]
const footer = dom.div(
style({marginTop: '6ex', opacity: 0.75}),
link('https://github.com/mjl-/mox', 'mox'),
' ',
api._sherpa.version,
)
const domainName = d => {
return d.Unicode || d.ASCII
}
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(
dom.label(
style({display: 'inline-block'}),
'New password',
dom.br(),
password1=dom.input(attr({type: 'password', required: ''}), function focus() {
passwordHint.style.display = ''
}),
),
' ',
dom.label(
style({display: 'inline-block'}),
'New password repeat',
dom.br(),
password2=dom.input(attr({type: 'password', required: ''})),
),
' ',
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()
e.preventDefault()
if (!password1.value || password1.value !== password2.value) {
window.alert('Passwords do not match.')
return
}
fieldset.disabled = true
try {
await api.SetPassword(password1.value)
window.alert('Password has been changed.')
form.reset()
} catch (err) {
console.log({err})
window.alert('Error: ' + err.message)
} finally {
fieldset.disabled = false
}
},
),
footer,
)
}
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. <name\\.lists\\.example\\.org>.'})),
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
const page = document.getElementById('page')
const hashChange = async () => {
if (curhash === window.location.hash) {
return
}
let h = window.location.hash
if (h !== '' && h.substring(0, 1) == '#') {
h = h.substring(1)
}
const t = h.split('/')
page.classList.add('loading')
try {
if (h === '') {
await index()
} else if (t[0] === 'destinations' && t.length === 2) {
await destination(t[1])
} else {
dom._kids(page, 'page not found')
}
} catch (err) {
console.log({err})
window.alert('Error: ' + err.message)
window.location.hash = curhash
curhash = window.location.hash
return
}
curhash = window.location.hash
page.classList.remove('loading')
}
window.addEventListener('hashchange', hashChange)
hashChange()
}
window.addEventListener('load', init)
</script>
</body>
</html>