diff --git a/webaccount/account.js b/webaccount/account.js index 9741ca0..52a738b 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -969,6 +969,8 @@ const check = async (elem, p) => { elem.disabled = false; } }; +// When white-space is relevant, e.g. for email addresses (e.g. " "@example.org). +const prewrap = (...l) => dom.span(style({ whiteSpace: 'pre-wrap' }), l); const client = new api.Client().withOptions({ csrfHeader: 'x-mox-csrf', login: login }).withAuthToken(localStorageGet('webaccountcsrftoken') || ''); const link = (href, anchorOpt) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), anchorOpt || href); const crumblink = (text, path) => { @@ -980,7 +982,7 @@ const crumblink = (text, path) => { const crumbs = (...l) => { const crumbtext = (e) => typeof e === 'string' ? e : e.text; document.title = l.map(e => crumbtext(e)).join(' - '); - const crumblink = (e) => typeof e === 'string' ? e : dom.a(e.text, attr.href(e.path)); + const crumblink = (e) => typeof e === 'string' ? prewrap(e) : dom.a(e.text, attr.href(e.path)); return [ dom.div(style({ float: 'right' }), localStorageGet('webaccountaddress') || '(unknown)', ' ', dom.clickbutton('Logout', attr.title('Logout, invalidating this session.'), async function click(e) { const b = e.target; @@ -1381,9 +1383,9 @@ const index = async () => { await check(fullNameFieldset, client.AccountSaveFullName(fullName.value)); fullName.setAttribute('value', fullName.value); fullNameForm.reset(); - }), dom.br(), dom.h2('Addresses'), dom.ul(Object.entries(acc.Destinations || {}).length === 0 ? dom.li('(None, login disabled)') : [], Object.entries(acc.Destinations || {}).sort().map(t => dom.li(dom.a(t[0], attr.href('#destinations/' + t[0])), t[0].startsWith('@') ? ' (catchall)' : []))), dom.br(), dom.h2('Aliases/lists'), 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.')), dom.th('Subscription address', attr.title('Address subscribed to the alias/list.')), 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())), (acc.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('5'), 'None')) : [], (acc.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(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)), dom.td(a.SubscriptionAddress), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td((a.MemberAddresses || []).length === 0 ? [] : + }), dom.br(), dom.h2('Addresses'), dom.ul(Object.entries(acc.Destinations || {}).length === 0 ? dom.li('(None, login disabled)') : [], Object.entries(acc.Destinations || {}).sort().map(t => dom.li(dom.a(prewrap(t[0]), attr.href('#destinations/' + encodeURIComponent(t[0]))), t[0].startsWith('@') ? ' (catchall)' : []))), dom.br(), dom.h2('Aliases/lists'), 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.')), dom.th('Subscription address', attr.title('Address subscribed to the alias/list.')), 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())), (acc.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('5'), 'None')) : [], (acc.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(prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain))), dom.td(prewrap(a.SubscriptionAddress)), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td((a.MemberAddresses || []).length === 0 ? [] : dom.clickbutton('Show members', function click() { - popup(dom.h1('Members of alias ', a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)), dom.ul((a.MemberAddresses || []).map(addr => dom.li(addr)))); + popup(dom.h1('Members of alias ', prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain))), dom.ul((a.MemberAddresses || []).map(addr => dom.li(prewrap(addr))))); }))))), 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) { @@ -1481,7 +1483,7 @@ const index = async () => { e.stopPropagation(); await check(e.target, client.SuppressionAdd(suppressionAddress.value, true, suppressionReason.value)); window.location.reload(); // todo: reload less - }), dom.table(dom.thead(dom.tr(dom.th('Address', attr.title('Address that caused this entry to be added to the list. The title (shown on hover) displays an address with a fictional simplified localpart, with lower-cased, dots removed, only first part before "+" or "-" (typicaly catchall separators). When checking if an address is on the suppression list, it is checked against this address.')), dom.th('Manual', attr.title('Whether suppression was added manually, instead of automatically based on bounces.')), dom.th('Reason'), dom.th('Since'), dom.th('Action'))), dom.tbody((suppressions || []).length === 0 ? dom.tr(dom.td(attr.colspan('5'), '(None)')) : [], (suppressions || []).map(s => dom.tr(dom.td(s.OriginalAddress, attr.title(s.BaseAddress)), dom.td(s.Manual ? '✓' : ''), dom.td(s.Reason), dom.td(age(s.Created)), dom.td(dom.clickbutton('Remove', async function click(e) { + }), dom.table(dom.thead(dom.tr(dom.th('Address', attr.title('Address that caused this entry to be added to the list. The title (shown on hover) displays an address with a fictional simplified localpart, with lower-cased, dots removed, only first part before "+" or "-" (typicaly catchall separators). When checking if an address is on the suppression list, it is checked against this address.')), dom.th('Manual', attr.title('Whether suppression was added manually, instead of automatically based on bounces.')), dom.th('Reason'), dom.th('Since'), dom.th('Action'))), dom.tbody((suppressions || []).length === 0 ? dom.tr(dom.td(attr.colspan('5'), '(None)')) : [], (suppressions || []).map(s => dom.tr(dom.td(prewrap(s.OriginalAddress), attr.title(s.BaseAddress)), dom.td(s.Manual ? '✓' : ''), dom.td(s.Reason), dom.td(age(s.Created)), dom.td(dom.clickbutton('Remove', async function click(e) { await check(e.target, client.SuppressionRemove(s.OriginalAddress)); window.location.reload(); // todo: reload less }))))), dom.tfoot(dom.tr(dom.td(suppressionAddress = dom.input(attr.type('required'), attr.form('suppressionAdd'))), dom.td(), dom.td(suppressionReason = dom.input(style({ width: '100%' }), attr.form('suppressionAdd'))), dom.td(), dom.td(dom.submitbutton('Add suppression', attr.form('suppressionAdd')))))), dom.br(), dom.h2('Export'), dom.p('Export all messages in all mailboxes.'), dom.form(attr.target('_blank'), attr.method('POST'), attr.action('export'), dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webaccountcsrftoken') || '')), dom.input(attr.type('hidden'), attr.name('mailbox'), attr.value('')), dom.input(attr.type('hidden'), attr.name('recursive'), attr.value('on')), dom.div(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.div(dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('maildir'), attr.checked('')), ' Maildir'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('format'), attr.value('mbox')), ' Mbox')), dom.div(dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tar')), ' Tar'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('tgz'), attr.checked('')), ' Tgz'), ' ', dom.label(dom.input(attr.type('radio'), attr.name('archive'), attr.value('zip')), ' Zip'), ' '), dom.div(style({ marginTop: '1ex' }), dom.submitbutton('Export')))), dom.br(), dom.h2('Import'), dom.p('Import messages from a .zip or .tgz file with maildirs and/or mbox files.'), importForm = dom.form(async function submit(e) { diff --git a/webaccount/account.ts b/webaccount/account.ts index c0e1d25..5d071c6 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -154,6 +154,9 @@ const check = async (elem: {disabled: boolean}, p: Promise): Promise => } } +// When white-space is relevant, e.g. for email addresses (e.g. " "@example.org). +const prewrap = (...l: string[]) => dom.span(style({whiteSpace: 'pre-wrap'}), l) + const client = new api.Client().withOptions({csrfHeader: 'x-mox-csrf', login: login}).withAuthToken(localStorageGet('webaccountcsrftoken') || '') const link = (href: string, anchorOpt: string) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), anchorOpt || href) @@ -169,7 +172,7 @@ const crumbs = (...l: ({text: string, path: string} | string)[]) => { document.title = l.map(e => crumbtext(e)).join(' - ') const crumblink = (e: {text: string, path: string} | string) => - typeof e === 'string' ? e : dom.a(e.text, attr.href(e.path)) + typeof e === 'string' ? prewrap(e) : dom.a(e.text, attr.href(e.path)) return [ dom.div( style({float: 'right'}), @@ -758,7 +761,7 @@ const index = async () => { Object.entries(acc.Destinations || {}).length === 0 ? dom.li('(None, login disabled)') : [], Object.entries(acc.Destinations || {}).sort().map(t => dom.li( - dom.a(t[0], attr.href('#destinations/'+t[0])), + dom.a(prewrap(t[0]), attr.href('#destinations/'+encodeURIComponent(t[0]))), t[0].startsWith('@') ? ' (catchall)' : [], ), ), @@ -779,17 +782,17 @@ const index = async () => { (acc.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('5'), 'None')) : [], (acc.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(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)), - dom.td(a.SubscriptionAddress), + dom.td(prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain))), + dom.td(prewrap(a.SubscriptionAddress)), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td( (a.MemberAddresses || []).length === 0 ? [] : dom.clickbutton('Show members', function click() { popup( - dom.h1('Members of alias ', a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)), + dom.h1('Members of alias ', prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain))), dom.ul( - (a.MemberAddresses || []).map(addr => dom.li(addr)), + (a.MemberAddresses || []).map(addr => dom.li(prewrap(addr))), ), ) }), @@ -1148,7 +1151,7 @@ const index = async () => { (suppressions || []).length === 0 ? dom.tr(dom.td(attr.colspan('5'), '(None)')) : [], (suppressions || []).map(s => dom.tr( - dom.td(s.OriginalAddress, attr.title(s.BaseAddress)), + dom.td(prewrap(s.OriginalAddress), attr.title(s.BaseAddress)), dom.td(s.Manual ? '✓' : ''), dom.td(s.Reason), dom.td(age(s.Created)), diff --git a/webadmin/admin.js b/webadmin/admin.js index add0e38..273506e 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -1845,6 +1845,8 @@ const check = async (elem, p) => { elem.disabled = false; } }; +// When white-space is relevant, e.g. for email addresses (e.g. " "@example.org). +const prewrap = (...l) => dom.span(style({ whiteSpace: 'pre-wrap' }), l); const green = '#1dea20'; const yellow = '#ffe400'; const red = '#ff7443'; @@ -1859,7 +1861,7 @@ const crumblink = (text, path) => { const crumbs = (...l) => { const crumbtext = (e) => typeof e === 'string' ? e : e.text; document.title = l.map(e => crumbtext(e)).join(' - '); - const crumblink = (e) => typeof e === 'string' ? e : dom.a(e.text, attr.href(e.path)); + const crumblink = (e) => typeof e === 'string' ? prewrap(e) : dom.a(e.text, attr.href(e.path)); return [ dom.div(style({ float: 'right' }), dom.clickbutton('Logout', attr.title('Logout, invalidating this session.'), async function click(e) { const b = e.target; @@ -1935,7 +1937,7 @@ const formatIP = (s) => { const buf = window.atob(s); const bytes = Uint8Array.from(buf, (m) => m.codePointAt(0) || 0); if (bytes.length === 4 || isIPv4MappedIPv6(bytes)) { - // Format last 4 bytes as IPv4 address.. + // Format last 4 bytes as IPv4 address. return [bytes.at(-4), bytes.at(-3), bytes.at(-2), bytes.at(-1)].join('.'); } return formatIPv6(bytes); @@ -2242,7 +2244,7 @@ const account = async (name) => { const d = t[t.length - 1]; const lp = t.slice(0, t.length - 1).join('@'); v = [ - lp, '@', + prewrap(lp), '@', dom.a(d, attr.href('#domains/' + d)), ]; if (lp === '') { @@ -2260,11 +2262,11 @@ const account = async (name) => { }))), dom.br(), dom.h2('Add address'), form = dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); - let address = localpart.value + '@' + domain.value; + const address = localpart.value + '@' + domain.value; 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('Aliases/lists'), dom.table(dom.thead(dom.tr(dom.th('Alias address'), 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(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain), attr.href('#domains/' + domainName(a.Alias.Domain) + '/alias/' + encodeURIComponent(a.Alias.LocalpartStr)))), dom.td(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), domainName(d) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Aliases/lists'), dom.table(dom.thead(dom.tr(dom.th('Alias address'), 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.')), 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) { @@ -2437,7 +2439,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..'), 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'))))); }; - dom._kids(page, 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(t[0] || '(catchall)'), dom.td(dom.a(t[1], attr.href('#accounts/' + t[1]))), dom.td(dom.clickbutton('Remove', async function click(e) { + dom._kids(page, 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) { e.preventDefault(); if (!window.confirm('Are you sure you want to remove this address?')) { return; @@ -2451,7 +2453,7 @@ const domain = async (d) => { 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 => { - return dom.tr(dom.td(dom.a(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')); + 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'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); @@ -2465,7 +2467,7 @@ const domain = async (d) => { Domain: dnsdomain, }; await check(aliasFieldset, client.AliasAdd(aliasLocalpart.value, d, alias)); - window.location.hash = '#domains/' + d + '/alias/' + aliasLocalpart.value; + window.location.hash = '#domains/' + d + '/alias/' + encodeURIComponent(aliasLocalpart.value); }, aliasFieldset = dom.fieldset(style({ display: 'flex', alignItems: 'flex-start', gap: '1em' }), dom.label(dom.div('Localpart', attr.title('The localpart is the part before the "@"-sign of an address.')), aliasLocalpart = dom.input(attr.required('')), '@', domainName(dnsdomain), ' '), dom.label(dom.div('Addresses', attr.title('One members address per line, full address of form localpart@domain. At least one address required.')), aliasAddresses = dom.textarea(attr.required(''), attr.rows('1'), function focus() { aliasAddresses.setAttribute('rows', '5'); })), dom.div(dom.div('\u00a0'), dom.submitbutton('Add alias', attr.title('Alias will be added and the config reloaded.'))))), dom.br(), RoutesEditor('domain-specific', transports, domainConfig.Routes || [], async (routes) => await client.DomainRoutesSave(d, routes)), dom.br(), dom.h2('Settings'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); @@ -2655,7 +2657,7 @@ const domainAlias = async (d, aliasLocalpart) => { check(aliasFieldset, client.AliasUpdate(aliasLocalpart, d, postPublic.checked, listMembers.checked, allowMsgFrom.checked)); }, aliasFieldset = dom.fieldset(style({ display: 'flex', flexDirection: 'column', gap: '.5ex' }), dom.label(postPublic = dom.input(attr.type('checkbox'), alias.PostPublic ? attr.checked('') : []), ' Public, anyone can post instead of only members'), dom.label(listMembers = dom.input(attr.type('checkbox'), alias.ListMembers ? attr.checked('') : []), ' Members can list other members'), dom.label(allowMsgFrom = dom.input(attr.type('checkbox'), alias.AllowMsgFrom ? attr.checked('') : []), ' Allow messages to use the alias address in the message From header'), dom.div(style({ marginTop: '1ex' }), dom.submitbutton('Save')))), dom.br(), dom.h2('Members'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th())), dom.tbody((alias.Addresses || []).map((address, index) => { const pa = (alias.ParsedAddresses || [])[index]; - return dom.tr(dom.td(address), dom.td(dom.a(pa.AccountName, attr.href('#accounts/' + pa.AccountName))), dom.td(dom.clickbutton('Remove', async function click(e) { + return dom.tr(dom.td(prewrap(address)), dom.td(dom.a(pa.AccountName, attr.href('#accounts/' + pa.AccountName))), dom.td(dom.clickbutton('Remove', async function click(e) { await check(e.target, client.AliasAddressesRemove(aliasLocalpart, d, [address])); window.location.reload(); // todo: reload less }))); @@ -2806,7 +2808,7 @@ const dmarcEvaluations = async () => { e.preventDefault(); await check(fieldset, client.DMARCSuppressAdd(reportingAddress.value, new Date(until.value), comment.value)); window.location.reload(); // todo: add the address to the list, or only reload the list - }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Reporting address', dom.br(), reportingAddress = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Until', dom.br(), until = dom.input(attr.type('date'), attr.required(''), attr.value(nextmonth.getFullYear() + '-' + (1 + nextmonth.getMonth()) + '-' + nextmonth.getDate()))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Comment (optional)'), dom.br(), comment = dom.input()), ' ', dom.submitbutton('Add', attr.title('Outgoing reports to this reporting address will be suppressed until the end time.')))), dom.br(), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Reporting address'), dom.th('Until'), dom.th('Comment'), dom.th('Action'))), dom.tbody((suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No suppressed reporting addresses.')) : [], (suppressAddresses || []).map(ba => dom.tr(dom.td(ba.ReportingAddress), dom.td(ba.Until.toISOString()), dom.td(ba.Comment), dom.td(dom.clickbutton('Remove', async function click(e) { + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Reporting address', dom.br(), reportingAddress = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Until', dom.br(), until = dom.input(attr.type('date'), attr.required(''), attr.value(nextmonth.getFullYear() + '-' + (1 + nextmonth.getMonth()) + '-' + nextmonth.getDate()))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Comment (optional)'), dom.br(), comment = dom.input()), ' ', dom.submitbutton('Add', attr.title('Outgoing reports to this reporting address will be suppressed until the end time.')))), dom.br(), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Reporting address'), dom.th('Until'), dom.th('Comment'), dom.th('Action'))), dom.tbody((suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No suppressed reporting addresses.')) : [], (suppressAddresses || []).map(ba => dom.tr(dom.td(prewrap(ba.ReportingAddress)), dom.td(ba.Until.toISOString()), dom.td(ba.Comment), dom.td(dom.clickbutton('Remove', async function click(e) { await check(e.target, client.DMARCSuppressRemove(ba.ID)); window.location.reload(); // todo: only reload the list }), ' ', dom.clickbutton('Extend for 1 month', async function click(e) { @@ -3057,7 +3059,7 @@ const tlsrptResults = async () => { e.preventDefault(); await check(fieldset, client.TLSRPTSuppressAdd(reportingAddress.value, new Date(until.value), comment.value)); window.location.reload(); // todo: add the address to the list, or only reload the list - }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Reporting address', dom.br(), reportingAddress = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Until', dom.br(), until = dom.input(attr.type('date'), attr.required(''), attr.value(nextmonth.getFullYear() + '-' + (1 + nextmonth.getMonth()) + '-' + nextmonth.getDate()))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Comment (optional)'), dom.br(), comment = dom.input()), ' ', dom.submitbutton('Add', attr.title('Outgoing reports to this reporting address will be suppressed until the end time.')))), dom.br(), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Reporting address'), dom.th('Until'), dom.th('Comment'), dom.th('Action'))), dom.tbody((suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No suppressed reporting addresses.')) : [], (suppressAddresses || []).map(ba => dom.tr(dom.td(ba.ReportingAddress), dom.td(ba.Until.toISOString()), dom.td(ba.Comment), dom.td(dom.clickbutton('Remove', async function click(e) { + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Reporting address', dom.br(), reportingAddress = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Until', dom.br(), until = dom.input(attr.type('date'), attr.required(''), attr.value(nextmonth.getFullYear() + '-' + (1 + nextmonth.getMonth()) + '-' + nextmonth.getDate()))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Comment (optional)'), dom.br(), comment = dom.input()), ' ', dom.submitbutton('Add', attr.title('Outgoing reports to this reporting address will be suppressed until the end time.')))), dom.br(), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Reporting address'), dom.th('Until'), dom.th('Comment'), dom.th('Action'))), dom.tbody((suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No suppressed reporting addresses.')) : [], (suppressAddresses || []).map(ba => dom.tr(dom.td(prewrap(ba.ReportingAddress)), dom.td(ba.Until.toISOString()), dom.td(ba.Comment), dom.td(dom.clickbutton('Remove', async function click(e) { await check(e.target, client.TLSRPTSuppressRemove(ba.ID)); window.location.reload(); // todo: only reload the list }), ' ', dom.clickbutton('Extend for 1 month', async function click(e) { @@ -3308,8 +3310,8 @@ const queueList = async () => { toggles.set(m.ID, dom.input(attr.type('checkbox'), msgs.length === 1 ? attr.checked('') : [])); } const ntbody = dom.tbody(dom._class('loadend'), msgs.length === 0 ? dom.tr(dom.td(attr.colspan('15'), 'No messages.')) : [], msgs.map(m => { - return dom.tr(dom.td(toggles.get(m.ID)), dom.td('' + m.ID + (m.BaseID > 0 ? '/' + m.BaseID : '')), dom.td(age(new Date(m.Queued), false, nowSecs)), dom.td(m.SenderAccount || '-'), dom.td(m.SenderLocalpart + "@" + ipdomainString(m.SenderDomain)), // todo: escaping of localpart - dom.td(m.RecipientLocalpart + "@" + ipdomainString(m.RecipientDomain)), // todo: escaping of localpart + return dom.tr(dom.td(toggles.get(m.ID)), dom.td('' + m.ID + (m.BaseID > 0 ? '/' + m.BaseID : '')), dom.td(age(new Date(m.Queued), false, nowSecs)), dom.td(m.SenderAccount || '-'), dom.td(prewrap(m.SenderLocalpart, "@", ipdomainString(m.SenderDomain))), // todo: escaping of localpart + dom.td(prewrap(m.RecipientLocalpart, "@", ipdomainString(m.RecipientDomain))), // todo: escaping of localpart dom.td(formatSize(m.Size)), dom.td('' + m.Attempts), dom.td(m.Hold ? 'Hold' : ''), dom.td(age(new Date(m.NextAttempt), true, nowSecs)), dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'), dom.td(m.Results && m.Results.length > 0 ? m.Results[m.Results.length - 1].Error : []), dom.td(m.Transport || '(default)'), dom.td(m.RequireTLS === true ? 'Yes' : (m.RequireTLS === false ? 'No' : '')), dom.td(dom.clickbutton('Details', function click() { popupDetails(m); }))); @@ -3496,8 +3498,8 @@ const retiredList = async () => { }; let tbody = dom.tbody(); const render = () => { - const ntbody = dom.tbody(dom._class('loadend'), retired.length === 0 ? dom.tr(dom.td(attr.colspan('14'), 'No retired messages.')) : [], retired.map(m => dom.tr(dom.td('' + m.ID + (m.BaseID > 0 ? '/' + m.BaseID : '')), dom.td(m.Success ? '✓' : ''), dom.td(age(new Date(m.LastActivity), false, nowSecs)), dom.td(age(new Date(m.Queued), false, nowSecs)), dom.td(m.SenderAccount || '-'), dom.td(m.SenderLocalpart + "@" + m.SenderDomainStr), // todo: escaping of localpart - dom.td(m.RecipientLocalpart + "@" + m.RecipientDomainStr), // todo: escaping of localpart + const ntbody = dom.tbody(dom._class('loadend'), retired.length === 0 ? dom.tr(dom.td(attr.colspan('14'), 'No retired messages.')) : [], retired.map(m => dom.tr(dom.td('' + m.ID + (m.BaseID > 0 ? '/' + m.BaseID : '')), dom.td(m.Success ? '✓' : ''), dom.td(age(new Date(m.LastActivity), false, nowSecs)), dom.td(age(new Date(m.Queued), false, nowSecs)), dom.td(m.SenderAccount || '-'), dom.td(prewrap(m.SenderLocalpart, "@", m.SenderDomainStr)), // todo: escaping of localpart + dom.td(prewrap(m.RecipientLocalpart, "@", m.RecipientDomainStr)), // todo: escaping of localpart dom.td(formatSize(m.Size)), dom.td('' + m.Attempts), dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'), dom.td(m.Results && m.Results.length > 0 ? m.Results[m.Results.length - 1].Error : []), dom.td(m.Transport || ''), dom.td(m.RequireTLS === true ? 'Yes' : (m.RequireTLS === false ? 'No' : '')), dom.td(dom.clickbutton('Details', function click() { popupDetails(m); }))))); diff --git a/webadmin/admin.ts b/webadmin/admin.ts index e8f4d1e..cc75750 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -140,6 +140,9 @@ const check = async (elem: {disabled: boolean}, p: Promise): Promise => } } +// When white-space is relevant, e.g. for email addresses (e.g. " "@example.org). +const prewrap = (...l: string[]) => dom.span(style({whiteSpace: 'pre-wrap'}), l) + const green = '#1dea20' const yellow = '#ffe400' const red = '#ff7443' @@ -158,7 +161,7 @@ const crumbs = (...l: ({text: string, path: string} | string)[]) => { document.title = l.map(e => crumbtext(e)).join(' - ') const crumblink = (e: {text: string, path: string} | string) => - typeof e === 'string' ? e : dom.a(e.text, attr.href(e.path)) + typeof e === 'string' ? prewrap(e) : dom.a(e.text, attr.href(e.path)) return [ dom.div( style({float: 'right'}), @@ -249,7 +252,7 @@ const formatIP = (s: string) => { const buf = window.atob(s) const bytes = Uint8Array.from(buf, (m) => m.codePointAt(0) || 0) if (bytes.length === 4 || isIPv4MappedIPv6(bytes)) { - // Format last 4 bytes as IPv4 address.. + // Format last 4 bytes as IPv4 address. return [bytes.at(-4), bytes.at(-3), bytes.at(-2), bytes.at(-1)].join('.') } return formatIPv6(bytes) @@ -822,7 +825,7 @@ const account = async (name: string) => { const d = t[t.length-1] const lp = t.slice(0, t.length-1).join('@') v = [ - lp, '@', + prewrap(lp), '@', dom.a(d, attr.href('#domains/'+d)), ] if (lp === '') { @@ -851,7 +854,7 @@ const account = async (name: string) => { async function submit(e: SubmitEvent) { e.preventDefault() e.stopPropagation() - let address = localpart.value + '@' + domain.value + const address = localpart.value + '@' + domain.value await check(fieldset, client.AddressAdd(address, name)) form.reset() window.location.reload() // todo: only reload the destinations @@ -890,8 +893,8 @@ const account = async (name: string) => { (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(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain), attr.href('#domains/'+domainName(a.Alias.Domain)+'/alias/'+encodeURIComponent(a.Alias.LocalpartStr)))), - dom.td(a.SubscriptionAddress), + 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'), @@ -1265,7 +1268,7 @@ const domain = async (d: string) => { dom.tbody( Object.entries(localpartAccounts).map(t => dom.tr( - dom.td(t[0] || '(catchall)'), + 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: MouseEvent) { @@ -1325,7 +1328,7 @@ const domain = async (d: string) => { 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(a.LocalpartStr, attr.href('#domains/'+d+'/alias/'+encodeURIComponent(a.LocalpartStr)))), + 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'), @@ -1348,7 +1351,7 @@ const domain = async (d: string) => { Domain: dnsdomain, } await check(aliasFieldset, client.AliasAdd(aliasLocalpart.value, d, alias)) - window.location.hash = '#domains/'+d+'/alias/'+aliasLocalpart.value + window.location.hash = '#domains/'+d+'/alias/'+encodeURIComponent(aliasLocalpart.value) }, aliasFieldset=dom.fieldset( style({display: 'flex', alignItems: 'flex-start', gap: '1em'}), @@ -1793,7 +1796,7 @@ const domainAlias = async (d: string, aliasLocalpart: string) => { crumbs( crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(domain.Domain), '#domains/'+d), - 'Alias '+aliasLocalpart+'@'+domainName(domain.Domain), + 'Alias ' + aliasLocalpart + '@' + domainName(domain.Domain), ), dom.h2('Alias'), @@ -1835,7 +1838,7 @@ const domainAlias = async (d: string, aliasLocalpart: string) => { (alias.Addresses || []).map((address, index) => { const pa = (alias.ParsedAddresses || [])[index] return dom.tr( - dom.td(address), + dom.td(prewrap(address)), dom.td(dom.a(pa.AccountName, attr.href('#accounts/'+pa.AccountName))), dom.td( dom.clickbutton('Remove', async function click(e: MouseEvent) { @@ -2236,7 +2239,7 @@ const dmarcEvaluations = async () => { (suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No suppressed reporting addresses.')) : [], (suppressAddresses || []).map(ba => dom.tr( - dom.td(ba.ReportingAddress), + dom.td(prewrap(ba.ReportingAddress)), dom.td(ba.Until.toISOString()), dom.td(ba.Comment), dom.td( @@ -2725,7 +2728,7 @@ const tlsrptResults = async () => { (suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No suppressed reporting addresses.')) : [], (suppressAddresses || []).map(ba => dom.tr( - dom.td(ba.ReportingAddress), + dom.td(prewrap(ba.ReportingAddress)), dom.td(ba.Until.toISOString()), dom.td(ba.Comment), dom.td( @@ -3214,8 +3217,8 @@ const queueList = async () => { dom.td(''+m.ID + (m.BaseID > 0 ? '/'+m.BaseID : '')), dom.td(age(new Date(m.Queued), false, nowSecs)), dom.td(m.SenderAccount || '-'), - dom.td(m.SenderLocalpart+"@"+ipdomainString(m.SenderDomain)), // todo: escaping of localpart - dom.td(m.RecipientLocalpart+"@"+ipdomainString(m.RecipientDomain)), // todo: escaping of localpart + dom.td(prewrap(m.SenderLocalpart, "@", ipdomainString(m.SenderDomain))), // todo: escaping of localpart + dom.td(prewrap(m.RecipientLocalpart, "@", ipdomainString(m.RecipientDomain))), // todo: escaping of localpart dom.td(formatSize(m.Size)), dom.td(''+m.Attempts), dom.td(m.Hold ? 'Hold' : ''), @@ -3668,8 +3671,8 @@ const retiredList = async () => { dom.td(age(new Date(m.LastActivity), false, nowSecs)), dom.td(age(new Date(m.Queued), false, nowSecs)), dom.td(m.SenderAccount || '-'), - dom.td(m.SenderLocalpart+"@"+m.SenderDomainStr), // todo: escaping of localpart - dom.td(m.RecipientLocalpart+"@"+m.RecipientDomainStr), // todo: escaping of localpart + dom.td(prewrap(m.SenderLocalpart, "@", m.SenderDomainStr)), // todo: escaping of localpart + dom.td(prewrap(m.RecipientLocalpart, "@", m.RecipientDomainStr)), // todo: escaping of localpart dom.td(formatSize(m.Size)), dom.td(''+m.Attempts), dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'),