diff --git a/config/config.go b/config/config.go index d749b16..aa515d0 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:"Accounts to which email can be delivered. An account can accept email for multiple domains, for multiple localparts, and deliver to multiple mailboxes."` + 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."` 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."` diff --git a/config/doc.go b/config/doc.go index a323af2..fccab27 100644 --- a/config/doc.go +++ b/config/doc.go @@ -836,8 +836,10 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details. MinimumAttempts: 0 Transport: - # Accounts to which email can be delivered. An account can accept email for - # multiple domains, for multiple localparts, and deliver to multiple mailboxes. + # 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: x: diff --git a/lib.ts b/lib.ts index 816e94f..b5c5a6f 100644 --- a/lib.ts +++ b/lib.ts @@ -214,6 +214,7 @@ const attr = { action: (s: string) => _attr('action', s), method: (s: string) => _attr('method', s), autocomplete: (s: string) => _attr('autocomplete', s), + list: (s: string) => _attr('list', s), } const style = (x: {[k: string]: string | number}) => { return {_styles: x}} const prop = (x: {[k: string]: any}) => { return {_props: x}} diff --git a/webaccount/account.js b/webaccount/account.js index 5a97b81..f55164f 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -218,6 +218,7 @@ const [dom, style, attr, prop] = (function () { action: (s) => _attr('action', s), method: (s) => _attr('method', s), autocomplete: (s) => _attr('autocomplete', s), + list: (s) => _attr('list', s), }; const style = (x) => { return { _styles: x }; }; const prop = (x) => { return { _props: x }; }; diff --git a/webadmin/admin.js b/webadmin/admin.js index 286b649..8922b93 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -218,6 +218,7 @@ const [dom, style, attr, prop] = (function () { action: (s) => _attr('action', s), method: (s) => _attr('method', s), autocomplete: (s) => _attr('autocomplete', s), + list: (s) => _attr('list', s), }; const style = (x) => { return { _styles: x }; }; const prop = (x) => { return { _props: x }; }; @@ -1602,10 +1603,11 @@ const formatSize = (n) => { return n + ' bytes'; }; const index = async () => { - const [domains, queueSize, checkUpdatesEnabled] = await Promise.all([ + const [domains, queueSize, checkUpdatesEnabled, accounts] = await Promise.all([ client.Domains(), client.QueueSize(), client.CheckUpdatesEnabled(), + client.Accounts(), ]); let fieldset; let domain; @@ -1631,7 +1633,7 @@ const index = async () => { fieldset.disabled = false; } window.location.hash = '#domains/' + domain.value; - }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Domain', dom.br(), domain = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Postmaster/reporting account', dom.br(), account = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Localpart (optional)', attr.title('Must be set if and only if account does not yet exist. The localpart for the user of this domain. E.g. postmaster.')), dom.br(), localpart = dom.input()), ' ', dom.submitbutton('Add domain', attr.title('Domain will be added and the config reloaded. You should add the required DNS records after adding the domain.')))), dom.br(), dom.h2('Reports'), dom.div(dom.a('DMARC', attr.href('#dmarc/reports'))), dom.div(dom.a('TLS', attr.href('#tlsrpt/reports'))), dom.br(), dom.h2('Operations'), dom.div(dom.a('MTA-STS policies', attr.href('#mtasts'))), dom.div(dom.a('DMARC evaluations', attr.href('#dmarc/evaluations'))), dom.div(dom.a('TLS connection results', attr.href('#tlsrpt/results'))), dom.div(dom.a('DNSBL', attr.href('#dnsbl'))), dom.div(style({ marginTop: '.5ex' }), dom.form(async function submit(e) { + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Domain', attr.title('Domain for incoming/outgoing email to add to mox. Can also be a subdomain of a domain already configured.')), dom.br(), domain = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Postmaster/reporting account', attr.title('Account that is considered the owner of this domain. If the account does not yet exist, it will be created and a a localpart is required for the initial email address.')), dom.br(), account = dom.input(attr.required(''), attr.list('accountList')), dom.datalist(attr.id('accountList'), (accounts || []).map(a => dom.option(a)))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Localpart (if new account)', attr.title('Must be set if and only if account does not yet exist. A localpart is the part before the "@"-sign of an email address. An account requires an email address, so creating a new account for a domain requires a localpart to form an initial email address.')), dom.br(), localpart = dom.input()), ' ', dom.submitbutton('Add domain', attr.title('Domain will be added and the config reloaded. Add the required DNS records after adding the domain.')))), dom.br(), dom.h2('Reports'), dom.div(dom.a('DMARC', attr.href('#dmarc/reports'))), dom.div(dom.a('TLS', attr.href('#tlsrpt/reports'))), dom.br(), dom.h2('Operations'), dom.div(dom.a('MTA-STS policies', attr.href('#mtasts'))), dom.div(dom.a('DMARC evaluations', attr.href('#dmarc/evaluations'))), dom.div(dom.a('TLS connection results', attr.href('#tlsrpt/results'))), dom.div(dom.a('DNSBL', attr.href('#dnsbl'))), dom.div(style({ marginTop: '.5ex' }), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); try { @@ -1735,8 +1737,9 @@ const inlineBox = (color, ...l) => dom.span(style({ const accounts = async () => { const accounts = await client.Accounts(); let fieldset; - let account; let email; + let account; + let accountModified = false; dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Accounts'), dom.h2('Accounts'), (accounts || []).length === 0 ? dom.p('No accounts') : dom.ul((accounts || []).map(s => dom.li(dom.a(s, attr.href('#accounts/' + s))))), dom.br(), dom.h2('Add account'), dom.form(async function submit(e) { e.preventDefault(); @@ -1754,13 +1757,23 @@ const accounts = async () => { fieldset.disabled = false; } window.location.hash = '#accounts/' + account.value; - }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Account name', dom.br(), account = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Email address', dom.br(), email = dom.input(attr.type('email'), attr.required(''))), ' ', dom.submitbutton('Add account', attr.title('The account will be added and the config reloaded.'))))); + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Email address', attr.title('The initial email address for the new account. More addresses can be added after the account has been created.')), dom.br(), email = dom.input(attr.type('email'), attr.required(''), function keyup() { + 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() { + accountModified = true; + })), ' ', dom.submitbutton('Add account', attr.title('The account will be added and the config reloaded.'))))); }; const account = async (name) => { - const config = await client.Account(name); + const [config, domains] = await Promise.all([ + client.Account(name), + client.Domains(), + ]); let form; let fieldset; - let email; + let localpart; + let domain; let fieldsetLimits; let maxOutgoingMessagesPerDay; let maxFirstTimeRecipientsPerDay; @@ -1856,14 +1869,8 @@ const account = async (name) => { e.stopPropagation(); fieldset.disabled = true; try { - let addr = email.value; - if (!addr.includes('@')) { - if (!config.Domain) { - throw new Error('no default domain configured for account'); - } - addr += '@' + config.Domain; - } - await client.AddressAdd(addr, name); + let address = localpart.value + '@' + domain.value; + await client.AddressAdd(address, name); } catch (err) { console.log({ err }); @@ -1875,7 +1882,7 @@ const account = async (name) => { } form.reset(); window.location.reload(); // todo: only reload the destinations - }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Email address or localpart', attr.title('If empty, or localpart is empty, a catchall address is configured for the domain.')), dom.br(), email = dom.input()), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Limits'), dom.form(fieldsetLimits = dom.fieldset(dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum outgoing messages per day', attr.title('Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000. MaxOutgoingMessagesPerDay in configuration file.')), dom.br(), maxOutgoingMessagesPerDay = dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxOutgoingMessagesPerDay || 1000))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum first-time recipients per day', attr.title('Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200. MaxFirstTimeRecipientsPerDay in configuration file.')), dom.br(), maxFirstTimeRecipientsPerDay = dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxFirstTimeRecipientsPerDay || 200))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage.')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize)))), dom.submitbutton('Save')), async function submit(e) { + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an email address. If empty, a catchall address is configured for the domain.')), dom.br(), localpart = dom.input()), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain'), dom.br(), domain = dom.select((domains || []).map(d => dom.option(domainName(d), domainName(d) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Limits'), dom.form(fieldsetLimits = dom.fieldset(dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum outgoing messages per day', attr.title('Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000. MaxOutgoingMessagesPerDay in configuration file.')), dom.br(), maxOutgoingMessagesPerDay = dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxOutgoingMessagesPerDay || 1000))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum first-time recipients per day', attr.title('Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200. MaxFirstTimeRecipientsPerDay in configuration file.')), dom.br(), maxFirstTimeRecipientsPerDay = dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxFirstTimeRecipientsPerDay || 200))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage.')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize)))), dom.submitbutton('Save')), async function submit(e) { e.stopPropagation(); e.preventDefault(); fieldsetLimits.disabled = true; @@ -1948,12 +1955,13 @@ const account = async (name) => { const domain = async (d) => { const end = new Date(); const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); - const [dmarcSummaries, tlsrptSummaries, localpartAccounts, dnsdomain, clientConfigs] = await Promise.all([ + const [dmarcSummaries, tlsrptSummaries, localpartAccounts, dnsdomain, clientConfigs, accounts] = await Promise.all([ client.DMARCSummaries(start, end, d), client.TLSRPTSummaries(start, end, d), client.DomainLocalparts(d), client.Domain(d), client.ClientConfigsDomain(d), + client.Accounts(), ]); let form; let fieldset; @@ -1995,7 +2003,7 @@ const domain = async (d) => { } form.reset(); window.location.reload(); // todo: only reload the addresses - }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('An empty localpart is the catchall destination/address for the domain.')), dom.br(), localpart = dom.input()), ' ', dom.label(style({ display: 'inline-block' }), 'Account', dom.br(), account = dom.input(attr.required(''))), ' ', dom.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')))), dom.br(), dom.h2('External checks'), dom.ul(dom.li(link('https://internet.nl/mail/' + dnsdomain.ASCII + '/', 'Check configuration at internet.nl'))), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove domain', 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 address. An empty localpart is the catchall destination/address for the domain.')), dom.br(), localpart = dom.input()), '@', domainName(dnsdomain), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Account', attr.title('Account to assign the address to.')), dom.br(), account = 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('External checks'), dom.ul(dom.li(link('https://internet.nl/mail/' + dnsdomain.ASCII + '/', 'Check configuration at internet.nl'))), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove domain', async function click(e) { e.preventDefault(); if (!window.confirm('Are you sure you want to remove this domain?')) { return; diff --git a/webadmin/admin.ts b/webadmin/admin.ts index 54d3d2f..b2349e2 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -254,10 +254,11 @@ const formatSize = (n: number) => { } const index = async () => { - const [domains, queueSize, checkUpdatesEnabled] = await Promise.all([ + const [domains, queueSize, checkUpdatesEnabled, accounts] = await Promise.all([ client.Domains(), client.QueueSize(), client.CheckUpdatesEnabled(), + client.Accounts(), ]) let fieldset: HTMLFieldSetElement @@ -302,26 +303,27 @@ const index = async () => { fieldset=dom.fieldset( dom.label( style({display: 'inline-block'}), - 'Domain', + dom.span('Domain', attr.title('Domain for incoming/outgoing email to add to mox. Can also be a subdomain of a domain already configured.')), dom.br(), domain=dom.input(attr.required('')), ), ' ', dom.label( style({display: 'inline-block'}), - 'Postmaster/reporting account', + dom.span('Postmaster/reporting account', attr.title('Account that is considered the owner of this domain. If the account does not yet exist, it will be created and a a localpart is required for the initial email address.')), dom.br(), - account=dom.input(attr.required('')), + account=dom.input(attr.required(''), attr.list('accountList')), + dom.datalist(attr.id('accountList'), (accounts || []).map(a => dom.option(a))), ), ' ', dom.label( style({display: 'inline-block'}), - dom.span('Localpart (optional)', attr.title('Must be set if and only if account does not yet exist. The localpart for the user of this domain. E.g. postmaster.')), + dom.span('Localpart (if new account)', attr.title('Must be set if and only if account does not yet exist. A localpart is the part before the "@"-sign of an email address. An account requires an email address, so creating a new account for a domain requires a localpart to form an initial email address.')), dom.br(), localpart=dom.input(), ), ' ', - dom.submitbutton('Add domain', attr.title('Domain will be added and the config reloaded. You should add the required DNS records after adding the domain.')), + dom.submitbutton('Add domain', attr.title('Domain will be added and the config reloaded. Add the required DNS records after adding the domain.')), ), ), dom.br(), @@ -527,8 +529,9 @@ const accounts = async () => { const accounts = await client.Accounts() let fieldset: HTMLFieldSetElement - let account: HTMLInputElement let email: HTMLInputElement + let account: HTMLInputElement + let accountModified = false dom._kids(page, crumbs( @@ -561,16 +564,22 @@ const accounts = async () => { fieldset=dom.fieldset( dom.label( style({display: 'inline-block'}), - 'Account name', + dom.span('Email address', attr.title('The initial email address for the new account. More addresses can be added after the account has been created.')), dom.br(), - account=dom.input(attr.required('')), + email=dom.input(attr.type('email'), attr.required(''), function keyup() { + if (!accountModified) { + account.value = email.value.split('@')[0] + } + }), ), ' ', dom.label( style({display: 'inline-block'}), - 'Email address', + 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(), - email=dom.input(attr.type('email'), attr.required('')), + account=dom.input(attr.required(''), function change() { + accountModified = true + }), ), ' ', dom.submitbutton('Add account', attr.title('The account will be added and the config reloaded.')), @@ -580,11 +589,15 @@ const accounts = async () => { } const account = async (name: string) => { - const config = await client.Account(name) + const [config, domains] = await Promise.all([ + client.Account(name), + client.Domains(), + ]) let form: HTMLFormElement let fieldset: HTMLFieldSetElement - let email: HTMLInputElement + let localpart: HTMLInputElement + let domain: HTMLSelectElement let fieldsetLimits: HTMLFieldSetElement let maxOutgoingMessagesPerDay: HTMLInputElement @@ -708,14 +721,8 @@ const account = async (name: string) => { e.stopPropagation() fieldset.disabled = true try { - let addr = email.value - if (!addr.includes('@')) { - if (!config.Domain) { - throw new Error('no default domain configured for account') - } - addr += '@' + config.Domain - } - await client.AddressAdd(addr, name) + let address = localpart.value + '@' + domain.value + await client.AddressAdd(address, name) } catch (err) { console.log({err}) window.alert('Error: ' + errmsg(err)) @@ -729,9 +736,16 @@ const account = async (name: string) => { fieldset=dom.fieldset( dom.label( style({display: 'inline-block'}), - dom.span('Email address or localpart', attr.title('If empty, or localpart is empty, a catchall address is configured for the domain.')), + 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(), - email=dom.input(), + 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'), @@ -856,18 +870,19 @@ const account = async (name: string) => { const domain = async (d: string) => { const end = new Date() const start = new Date(new Date().getTime() - 30*24*3600*1000) - const [dmarcSummaries, tlsrptSummaries, localpartAccounts, dnsdomain, clientConfigs] = await Promise.all([ + const [dmarcSummaries, tlsrptSummaries, localpartAccounts, dnsdomain, clientConfigs, accounts] = await Promise.all([ client.DMARCSummaries(start, end, d), client.TLSRPTSummaries(start, end, d), client.DomainLocalparts(d), client.Domain(d), client.ClientConfigsDomain(d), + client.Accounts(), ]) let form: HTMLFormElement let fieldset: HTMLFieldSetElement let localpart: HTMLInputElement - let account: HTMLInputElement + let account: HTMLSelectElement dom._kids(page, crumbs( @@ -964,16 +979,17 @@ const domain = async (d: string) => { fieldset=dom.fieldset( dom.label( style({display: 'inline-block'}), - dom.span('Localpart', attr.title('An empty localpart is the catchall destination/address for the domain.')), + 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(), localpart=dom.input(), ), + '@', domainName(dnsdomain), ' ', dom.label( style({display: 'inline-block'}), - 'Account', + dom.span('Account', attr.title('Account to assign the address to.')), dom.br(), - account=dom.input(attr.required('')), + account=dom.select(attr.required(''), (accounts || []).map(a => dom.option(a))), ), ' ', dom.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')), diff --git a/webmail/msg.js b/webmail/msg.js index c2aeab0..119e435 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -218,6 +218,7 @@ const [dom, style, attr, prop] = (function () { action: (s) => _attr('action', s), method: (s) => _attr('method', s), autocomplete: (s) => _attr('autocomplete', s), + list: (s) => _attr('list', s), }; const style = (x) => { return { _styles: x }; }; const prop = (x) => { return { _props: x }; }; diff --git a/webmail/text.js b/webmail/text.js index e080ded..dc38131 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -218,6 +218,7 @@ const [dom, style, attr, prop] = (function () { action: (s) => _attr('action', s), method: (s) => _attr('method', s), autocomplete: (s) => _attr('autocomplete', s), + list: (s) => _attr('list', s), }; const style = (x) => { return { _styles: x }; }; const prop = (x) => { return { _props: x }; }; diff --git a/webmail/webmail.js b/webmail/webmail.js index ebe4442..bf0ff3e 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -218,6 +218,7 @@ const [dom, style, attr, prop] = (function () { action: (s) => _attr('action', s), method: (s) => _attr('method', s), autocomplete: (s) => _attr('autocomplete', s), + list: (s) => _attr('list', s), }; const style = (x) => { return { _styles: x }; }; const prop = (x) => { return { _props: x }; };