diff --git a/mox-/webappfile.go b/mox-/webappfile.go index 6eaeac0..ff87940 100644 --- a/mox-/webappfile.go +++ b/mox-/webappfile.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "io/fs" "log/slog" "net/http" "os" @@ -25,6 +26,7 @@ import ( type WebappFile struct { HTML, JS []byte // Embedded html/js data. HTMLPath, JSPath string // Paths to load html/js from during development. + CustomStem string // For trying to read css/js customizations from $configdir/$stem.{css,js}. sync.Mutex combined []byte @@ -107,28 +109,82 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri } } + // Check mtime of css/js files. + var haveCustomCSS, haveCustomJS bool + checkCustomMtime := func(ext string, have *bool) bool { + path := ConfigDirPath(a.CustomStem + "." + ext) + if fi, err := os.Stat(path); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + a.serverError(log, w, err, "stat customization file") + return false + } + } else if mtm := fi.ModTime(); mtm.After(diskmtime) { + diskmtime = mtm + *have = true + } + return true + } + if !checkCustomMtime("css", &haveCustomCSS) || !checkCustomMtime("js", &haveCustomJS) { + return + } + // Detect removal of custom files. + if fi, err := os.Stat(ConfigDirPath(".")); err == nil && fi.ModTime().After(diskmtime) { + diskmtime = fi.ModTime() + } + + a.Lock() + refreshdisk = refreshdisk || diskmtime.After(a.mtime) + a.Unlock() + gz := AcceptsGzip(r) var out []byte var mtime time.Time var origSize int64 - func() { + ok := func() bool { a.Lock() defer a.Unlock() if refreshdisk || a.combined == nil { - script := []byte(``) - index := bytes.Index(html, script) - if index < 0 { - a.serverError(log, w, errors.New("script not found"), "generating combined html") - return + var customCSS, customJS []byte + var err error + if haveCustomCSS { + customCSS, err = os.ReadFile(ConfigDirPath(a.CustomStem + ".css")) + if err != nil { + a.serverError(log, w, err, "read custom css file") + return false + } + } + if haveCustomJS { + customJS, err = os.ReadFile(ConfigDirPath(a.CustomStem + ".js")) + if err != nil { + a.serverError(log, w, err, "read custom js file") + return false + } + } + + cssp := []byte(`/* css placeholder */`) + cssi := bytes.Index(html, cssp) + if cssi < 0 { + a.serverError(log, w, errors.New("css placeholder not found"), "generating combined html") + return false + } + jsp := []byte(`/* js placeholder */`) + jsi := bytes.Index(html, jsp) + if jsi < 0 { + a.serverError(log, w, errors.New("js placeholder not found"), "generating combined html") + return false } var b bytes.Buffer - b.Write(html[:index]) - fmt.Fprintf(&b, "") - b.Write(html[index+len(script):]) + b.Write(html[jsi+len(jsp):]) out = b.Bytes() a.combined = out if refreshdisk { @@ -152,7 +208,7 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri } if err != nil { a.serverError(log, w, err, "gzipping combined html") - return + return false } a.combinedGzip = b.Bytes() } @@ -160,7 +216,11 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri out = a.combinedGzip } mtime = a.mtime + return true }() + if !ok { + return + } w.Header().Set("Content-Type", "text/html; charset=utf-8") http.ServeContent(gzipInjector{w, gz, origSize}, r, "", mtime, bytes.NewReader(out)) diff --git a/webaccount/account.go b/webaccount/account.go index ec2f4e0..d5cef38 100644 --- a/webaccount/account.go +++ b/webaccount/account.go @@ -51,10 +51,11 @@ var accountHTML []byte var accountJS []byte var webaccountFile = &mox.WebappFile{ - HTML: accountHTML, - JS: accountJS, - HTMLPath: filepath.FromSlash("webaccount/account.html"), - JSPath: filepath.FromSlash("webaccount/account.js"), + HTML: accountHTML, + JS: accountJS, + HTMLPath: filepath.FromSlash("webaccount/account.html"), + JSPath: filepath.FromSlash("webaccount/account.js"), + CustomStem: "webaccount", } var accountDoc = mustParseAPI("account", accountapiJSON) diff --git a/webaccount/account.html b/webaccount/account.html index 4cd6fee..0c18d50 100644 --- a/webaccount/account.html +++ b/webaccount/account.html @@ -30,10 +30,14 @@ thead { position: sticky; top: 0; background-color: white; box-shadow: 0 1px 1px .autosize { display: inline-grid; max-width: 90vw; } .autosize.input { grid-area: 1 / 2; } .autosize::after { content: attr(data-value); margin-right: 1em; line-height: 0; visibility: hidden; white-space: pre-wrap; overflow-x: hidden; } + +/* css placeholder */
Loading...
- + diff --git a/webaccount/account.js b/webaccount/account.js index 26904c5..e30e6c4 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -1396,7 +1396,7 @@ const index = async () => { body.setAttribute('rows', '' + Math.min(40, (body.value.split('\n').length + 1))); onchange(); }; - dom._kids(page, crumbs('Mox Account'), dom.div('Default domain: ', acc.DNSDomain.ASCII ? domainString(acc.DNSDomain) : '(none)'), dom.br(), fullNameForm = dom.form(fullNameFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Full name', dom.br(), fullName = dom.input(attr.value(acc.FullName), attr.title('Name to use in From header when composing messages. Can be overridden per configured address.'))), ' ', dom.submitbutton('Save')), async function submit(e) { + const root = dom.div(crumbs('Mox Account'), dom.div('Default domain: ', acc.DNSDomain.ASCII ? domainString(acc.DNSDomain) : '(none)'), dom.br(), fullNameForm = dom.form(fullNameFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Full name', dom.br(), fullName = dom.input(attr.value(acc.FullName), attr.title('Name to use in From header when composing messages. Can be overridden per configured address.'))), ' ', dom.submitbutton('Save')), async function submit(e) { e.preventDefault(); await check(fullNameFieldset, client.AccountSaveFullName(fullName.value)); fullName.setAttribute('value', fullName.value); @@ -1590,33 +1590,36 @@ const index = async () => { mailboxPrefixHint.style.display = ''; })), mailboxPrefixHint = dom.p(style({ display: 'none', fontStyle: 'italic', marginTop: '.5ex' }), 'If set, any mbox/maildir path with this prefix will have it stripped before importing. For example, if all mailboxes are in a directory "Takeout", specify that path in the field above so mailboxes like "Takeout/Inbox.mbox" are imported into a mailbox called "Inbox" instead of "Takeout/Inbox".')), dom.div(dom.submitbutton('Upload and import'), dom.p(style({ fontStyle: 'italic', marginTop: '.5ex' }), 'The file is uploaded first, then its messages are imported, finally messages are matched for threading. Importing is done in a transaction, you can abort the entire import before it is finished.')))), importAbortBox = dom.div(), // Outside fieldset because it gets disabled, above progress because may be scrolling it down quickly with problems. importProgress = dom.div(style({ display: 'none' })), dom.br(), footer); - // Try to show the progress of an earlier import session. The user may have just - // refreshed the browser. - let importToken; - try { - importToken = window.sessionStorage.getItem('ImportToken') || ''; - } - catch (err) { - console.log('looking up ImportToken in session storage', { err }); - return; - } - if (!importToken) { - return; - } - importFieldset.disabled = true; - dom._kids(importProgress, dom.div(dom.div('Reconnecting to import...'))); - importProgress.style.display = ''; - importTrack(importToken) - .catch(() => { - if (window.confirm('Error reconnecting to import. Remove this import session?')) { - window.sessionStorage.removeItem('ImportToken'); - dom._kids(importProgress); - importProgress.style.display = 'none'; + (async () => { + // Try to show the progress of an earlier import session. The user may have just + // refreshed the browser. + let importToken; + try { + importToken = window.sessionStorage.getItem('ImportToken') || ''; } - }) - .finally(() => { - importFieldset.disabled = false; - }); + catch (err) { + console.log('looking up ImportToken in session storage', { err }); + return; + } + if (!importToken) { + return; + } + importFieldset.disabled = true; + dom._kids(importProgress, dom.div(dom.div('Reconnecting to import...'))); + importProgress.style.display = ''; + importTrack(importToken) + .catch(() => { + if (window.confirm('Error reconnecting to import. Remove this import session?')) { + window.sessionStorage.removeItem('ImportToken'); + dom._kids(importProgress); + importProgress.style.display = 'none'; + } + }) + .finally(() => { + importFieldset.disabled = false; + }); + })(); + return root; }; const destination = async (name) => { const [acc] = await client.Account(); @@ -1692,7 +1695,7 @@ const destination = async (name) => { let fullName; let saveButton; const addresses = [name, ...Object.keys(acc.Destinations || {}).filter(a => !a.startsWith('@') && a !== name)]; - dom._kids(page, crumbs(crumblink('Mox Account', '#'), 'Destination ' + name), dom.div(dom.span('Default mailbox', attr.title('Default mailbox where email for this recipient is delivered to if it does not match any ruleset. Default is Inbox.')), dom.br(), defaultMailbox = dom.input(attr.value(dest.Mailbox), attr.placeholder('Inbox'))), dom.br(), dom.div(dom.span('Full name', attr.title('Name to use in From header when composing messages. If not set, the account default full name is used.')), dom.br(), fullName = dom.input(attr.value(dest.FullName))), dom.br(), dom.h2('Rulesets'), dom.p('Incoming messages are checked against the rulesets. If a ruleset matches, the message is delivered to the mailbox configured for the ruleset instead of to the default mailbox.'), dom.p('"Is Forward" does not affect matching, but changes prevents the sending mail server from being included in future junk classifications by clearing fields related to the forwarding email server (IP address, EHLO domain, MAIL FROM domain and a matching DKIM domain), and prevents DMARC rejects for forwarded messages.'), dom.p('"List allow domain" does not affect matching, but skips the regular spam checks if one of the verified domains is a (sub)domain of the domain mentioned here.'), dom.p('"Accept rejects to mailbox" does not affect matching, but causes messages classified as junk to be accepted and delivered to this mailbox, instead of being rejected during the SMTP transaction. Useful for incoming forwarded messages where rejecting incoming messages may cause the forwarding server to stop forwarding.'), dom.table(dom.thead(dom.tr(dom.th('SMTP "MAIL FROM" regexp', attr.title('Matches if this regular expression matches (a substring of) the SMTP MAIL FROM address (not the message From-header). E.g. user@example.org.')), dom.th('Message "From" address regexp', attr.title('Matches if this regular expression matches (a substring of) the single address in the message From header.')), dom.th('Verified domain', attr.title('Matches if this domain matches an SPF- and/or DKIM-verified (sub)domain.')), dom.th('Headers regexp', attr.title('Matches if these header field/value regular expressions all match (substrings of) the message headers. Header fields and valuees are converted to lower case before matching. Whitespace is trimmed from the value before matching. A header field can occur multiple times in a message, only one instance has to match. For mailing lists, you could match on ^list-id$ with the value typically the mailing list address in angled brackets with @ replaced with a dot, e.g. .')), dom.th('Is Forward', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. Can only be used together with SMTPMailFromRegexp and VerifiedDomain. SMTPMailFromRegexp must be set to the address used to deliver the forwarded message, e.g. '^user(|\\+.*)@forward\\.example$'. Changes to junk analysis: 1. Messages are not rejected for failing a DMARC policy, because a legitimate forwarded message without valid/intact/aligned DKIM signature would be rejected because any verified SPF domain will be 'unaligned', of the forwarding mail server. 2. The sending mail server IP address, and sending EHLO and MAIL FROM domains and matching DKIM domain aren't used in future reputation-based spam classifications (but other verified DKIM domains are) because the forwarding server is not a useful spam signal for future messages.")), dom.th('List allow domain', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. If this domain matches an SPF- and/or DKIM-verified (sub)domain, the message is accepted without further spam checks, such as a junk filter or DMARC reject evaluation. DMARC rejects should not apply for mailing lists that are not configured to rewrite the From-header of messages that don't have a passing DKIM signature of the From-domain. Otherwise, by rejecting messages, you may be automatically unsubscribed from the mailing list. The assumption is that mailing lists do their own spam filtering/moderation.")), dom.th('Allow rejects to mailbox', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. If a message is classified as spam, it isn't rejected during the SMTP transaction (the normal behaviour), but accepted during the SMTP transaction and delivered to the specified mailbox. The specified mailbox is not automatically cleaned up like the account global Rejects mailbox, unless set to that Rejects mailbox.")), dom.th('Mailbox', attr.title('Mailbox to deliver to if this ruleset matches.')), dom.th('Comment', attr.title('Free-form comments.')), dom.th('Action'))), rulesetsTbody, dom.tfoot(dom.tr(dom.td(attr.colspan('9')), dom.td(dom.clickbutton('Add ruleset', function click() { + return dom.div(crumbs(crumblink('Mox Account', '#'), 'Destination ' + name), dom.div(dom.span('Default mailbox', attr.title('Default mailbox where email for this recipient is delivered to if it does not match any ruleset. Default is Inbox.')), dom.br(), defaultMailbox = dom.input(attr.value(dest.Mailbox), attr.placeholder('Inbox'))), dom.br(), dom.div(dom.span('Full name', attr.title('Name to use in From header when composing messages. If not set, the account default full name is used.')), dom.br(), fullName = dom.input(attr.value(dest.FullName))), dom.br(), dom.h2('Rulesets'), dom.p('Incoming messages are checked against the rulesets. If a ruleset matches, the message is delivered to the mailbox configured for the ruleset instead of to the default mailbox.'), dom.p('"Is Forward" does not affect matching, but changes prevents the sending mail server from being included in future junk classifications by clearing fields related to the forwarding email server (IP address, EHLO domain, MAIL FROM domain and a matching DKIM domain), and prevents DMARC rejects for forwarded messages.'), dom.p('"List allow domain" does not affect matching, but skips the regular spam checks if one of the verified domains is a (sub)domain of the domain mentioned here.'), dom.p('"Accept rejects to mailbox" does not affect matching, but causes messages classified as junk to be accepted and delivered to this mailbox, instead of being rejected during the SMTP transaction. Useful for incoming forwarded messages where rejecting incoming messages may cause the forwarding server to stop forwarding.'), dom.table(dom.thead(dom.tr(dom.th('SMTP "MAIL FROM" regexp', attr.title('Matches if this regular expression matches (a substring of) the SMTP MAIL FROM address (not the message From-header). E.g. user@example.org.')), dom.th('Message "From" address regexp', attr.title('Matches if this regular expression matches (a substring of) the single address in the message From header.')), dom.th('Verified domain', attr.title('Matches if this domain matches an SPF- and/or DKIM-verified (sub)domain.')), dom.th('Headers regexp', attr.title('Matches if these header field/value regular expressions all match (substrings of) the message headers. Header fields and valuees are converted to lower case before matching. Whitespace is trimmed from the value before matching. A header field can occur multiple times in a message, only one instance has to match. For mailing lists, you could match on ^list-id$ with the value typically the mailing list address in angled brackets with @ replaced with a dot, e.g. .')), dom.th('Is Forward', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. Can only be used together with SMTPMailFromRegexp and VerifiedDomain. SMTPMailFromRegexp must be set to the address used to deliver the forwarded message, e.g. '^user(|\\+.*)@forward\\.example$'. Changes to junk analysis: 1. Messages are not rejected for failing a DMARC policy, because a legitimate forwarded message without valid/intact/aligned DKIM signature would be rejected because any verified SPF domain will be 'unaligned', of the forwarding mail server. 2. The sending mail server IP address, and sending EHLO and MAIL FROM domains and matching DKIM domain aren't used in future reputation-based spam classifications (but other verified DKIM domains are) because the forwarding server is not a useful spam signal for future messages.")), dom.th('List allow domain', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. If this domain matches an SPF- and/or DKIM-verified (sub)domain, the message is accepted without further spam checks, such as a junk filter or DMARC reject evaluation. DMARC rejects should not apply for mailing lists that are not configured to rewrite the From-header of messages that don't have a passing DKIM signature of the From-domain. Otherwise, by rejecting messages, you may be automatically unsubscribed from the mailing list. The assumption is that mailing lists do their own spam filtering/moderation.")), dom.th('Allow rejects to mailbox', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. If a message is classified as spam, it isn't rejected during the SMTP transaction (the normal behaviour), but accepted during the SMTP transaction and delivered to the specified mailbox. The specified mailbox is not automatically cleaned up like the account global Rejects mailbox, unless set to that Rejects mailbox.")), dom.th('Mailbox', attr.title('Mailbox to deliver to if this ruleset matches.')), dom.th('Comment', attr.title('Free-form comments.')), dom.th('Action'))), rulesetsTbody, dom.tfoot(dom.tr(dom.td(attr.colspan('9')), dom.td(dom.clickbutton('Add ruleset', function click() { addRulesetsRow({ SMTPMailFromRegexp: '', MsgFromRegexp: '', @@ -1743,15 +1746,20 @@ const init = async () => { const t = h.split('/'); page.classList.add('loading'); try { + let root; if (h === '') { - await index(); + root = await index(); } else if (t[0] === 'destinations' && t.length === 2) { - await destination(t[1]); + root = await destination(t[1]); } else { - dom._kids(page, 'page not found'); + root = dom.div('page not found'); } + if (window.moxBeforeDisplay) { + moxBeforeDisplay(root); + } + dom._kids(page, root); } catch (err) { console.log({ err }); diff --git a/webaccount/account.ts b/webaccount/account.ts index c503548..631f9cb 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -5,6 +5,8 @@ declare let page: HTMLElement declare let moxversion: string declare let moxgoos: string declare let moxgoarch: string +// From customization script. +declare let moxBeforeDisplay: (webmailroot: HTMLElement) => void const login = async (reason: string) => { return new Promise((resolve: (v: string) => void, _) => { @@ -737,7 +739,7 @@ const index = async () => { onchange() } - dom._kids(page, + const root = dom.div( crumbs('Mox Account'), dom.div( 'Default domain: ', @@ -1390,36 +1392,40 @@ const index = async () => { footer, ) - // Try to show the progress of an earlier import session. The user may have just - // refreshed the browser. - let importToken: string - try { - importToken = window.sessionStorage.getItem('ImportToken') || '' - } catch (err) { - console.log('looking up ImportToken in session storage', {err}) - return - } - if (!importToken) { - return - } - importFieldset.disabled = true - dom._kids(importProgress, - dom.div( - dom.div('Reconnecting to import...'), - ), - ) - importProgress.style.display = '' - importTrack(importToken) - .catch(() => { - if (window.confirm('Error reconnecting to import. Remove this import session?')) { - window.sessionStorage.removeItem('ImportToken') - dom._kids(importProgress) - importProgress.style.display = 'none' + ;(async () => { + // Try to show the progress of an earlier import session. The user may have just + // refreshed the browser. + let importToken: string + try { + importToken = window.sessionStorage.getItem('ImportToken') || '' + } catch (err) { + console.log('looking up ImportToken in session storage', {err}) + return } - }) - .finally(() => { - importFieldset.disabled = false - }) + if (!importToken) { + return + } + importFieldset.disabled = true + dom._kids(importProgress, + dom.div( + dom.div('Reconnecting to import...'), + ), + ) + importProgress.style.display = '' + importTrack(importToken) + .catch(() => { + if (window.confirm('Error reconnecting to import. Remove this import session?')) { + window.sessionStorage.removeItem('ImportToken') + dom._kids(importProgress) + importProgress.style.display = 'none' + } + }) + .finally(() => { + importFieldset.disabled = false + }) + })() + + return root } const destination = async (name: string) => { @@ -1552,7 +1558,7 @@ const destination = async (name: string) => { const addresses = [name, ...Object.keys(acc.Destinations || {}).filter(a => !a.startsWith('@') && a !== name)] - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Account', '#'), 'Destination ' + name, @@ -1664,13 +1670,18 @@ const init = async () => { const t = h.split('/') page.classList.add('loading') try { + let root: HTMLElement if (h === '') { - await index() + root = await index() } else if (t[0] === 'destinations' && t.length === 2) { - await destination(t[1]) + root = await destination(t[1]) } else { - dom._kids(page, 'page not found') + root = dom.div('page not found') } + if ((window as any).moxBeforeDisplay) { + moxBeforeDisplay(root) + } + dom._kids(page, root) } catch (err) { console.log({err}) window.alert('Error: ' + errmsg(err)) diff --git a/webadmin/admin.go b/webadmin/admin.go index 10f38e9..207ba5d 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -80,10 +80,11 @@ var adminHTML []byte var adminJS []byte var webadminFile = &mox.WebappFile{ - HTML: adminHTML, - JS: adminJS, - HTMLPath: filepath.FromSlash("webadmin/admin.html"), - JSPath: filepath.FromSlash("webadmin/admin.js"), + HTML: adminHTML, + JS: adminJS, + HTMLPath: filepath.FromSlash("webadmin/admin.html"), + JSPath: filepath.FromSlash("webadmin/admin.js"), + CustomStem: "webadmin", } var adminDoc = mustParseAPI("admin", adminapiJSON) diff --git a/webadmin/admin.html b/webadmin/admin.html index e0887c4..6e0160b 100644 --- a/webadmin/admin.html +++ b/webadmin/admin.html @@ -36,10 +36,14 @@ thead { position: sticky; top: 0; background-color: white; box-shadow: 0 1px 1px #page.loading, .loadstart { 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 } } + +/* css placeholder */
Loading...
- + diff --git a/webadmin/admin.js b/webadmin/admin.js index b1e8e2c..8dacf74 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -1921,7 +1921,7 @@ const index = async () => { let recvIDFieldset; let recvID; let cidElem; - dom._kids(page, crumbs('Mox Admin'), checkUpdatesEnabled ? [] : dom.p(box(yellow, 'Warning: Checking for updates has not been enabled in mox.conf (CheckUpdates: true).', dom.br(), 'Make sure you stay up to date through another mechanism!', dom.br(), 'You have a responsibility to keep the internet-connected software you run up to date and secure!', dom.br(), 'See ', link('https://updates.xmox.nl/changelog'))), dom.p(dom.a('Accounts', attr.href('#accounts')), dom.br(), dom.a('Queue', attr.href('#queue')), ' (' + queueSize + ')', dom.br(), dom.a('Webhook queue', attr.href('#webhookqueue')), ' (' + hooksQueueSize + ')', dom.br()), dom.h2('Domains'), (domains || []).length === 0 ? box(red, 'No domains') : + return dom.div(crumbs('Mox Admin'), checkUpdatesEnabled ? [] : dom.p(box(yellow, 'Warning: Checking for updates has not been enabled in mox.conf (CheckUpdates: true).', dom.br(), 'Make sure you stay up to date through another mechanism!', dom.br(), 'You have a responsibility to keep the internet-connected software you run up to date and secure!', dom.br(), 'See ', link('https://updates.xmox.nl/changelog'))), dom.p(dom.a('Accounts', attr.href('#accounts')), dom.br(), dom.a('Queue', attr.href('#queue')), ' (' + queueSize + ')', dom.br(), dom.a('Webhook queue', attr.href('#webhookqueue')), ' (' + hooksQueueSize + ')', dom.br()), dom.h2('Domains'), (domains || []).length === 0 ? box(red, 'No domains') : dom.ul((domains || []).map(d => dom.li(dom.a(attr.href('#domains/' + domainName(d)), domainString(d))))), dom.br(), dom.h2('Add domain'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); @@ -1942,11 +1942,11 @@ const globalRoutes = async () => { client.Transports(), client.Config(), ]); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Routes'), RoutesEditor('global', transports, config.Routes || [], async (routes) => await client.RoutesSave(routes))); + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Routes'), RoutesEditor('global', transports, config.Routes || [], async (routes) => await client.RoutesSave(routes))); }; const config = async () => { const [staticPath, dynamicPath, staticText, dynamicText] = await client.ConfigFiles(); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Config'), dom.h2(staticPath), dom.pre(dom._class('literal'), staticText), dom.h2(dynamicPath), dom.pre(dom._class('literal'), dynamicText)); + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Config'), dom.h2(staticPath), dom.pre(dom._class('literal'), staticText), dom.h2(dynamicPath), dom.pre(dom._class('literal'), dynamicText)); }; const loglevels = async () => { const loglevels = await client.LogLevels(); @@ -1955,7 +1955,7 @@ const loglevels = async () => { let fieldset; let pkg; let level; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Log levels'), dom.p('Note: changing a log level here only changes it for the current process. When mox restarts, it sets the log levels from the configuration file. Change mox.conf to keep the changes.'), dom.table(dom.thead(dom.tr(dom.th('Package', attr.title('Log levels can be configured per package. E.g. smtpserver, imapserver, dkim, dmarc, tlsrpt, etc.')), dom.th('Level', attr.title('If you set the log level to "trace", imap and smtp protocol transcripts will be logged. Sensitive authentication is replaced with "***" unless the level is >= "traceauth". Data is masked with "..." unless the level is "tracedata".')), dom.th('Action'))), dom.tbody(Object.entries(loglevels).map(t => { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Log levels'), dom.p('Note: changing a log level here only changes it for the current process. When mox restarts, it sets the log levels from the configuration file. Change mox.conf to keep the changes.'), dom.table(dom.thead(dom.tr(dom.th('Package', attr.title('Log levels can be configured per package. E.g. smtpserver, imapserver, dkim, dmarc, tlsrpt, etc.')), dom.th('Level', attr.title('If you set the log level to "trace", imap and smtp protocol transcripts will be logged. Sensitive authentication is replaced with "***" unless the level is >= "traceauth". Data is masked with "..." unless the level is "tracedata".')), dom.th('Action'))), dom.tbody(Object.entries(loglevels).map(t => { let lvl; return dom.tr(dom.td(t[0] || '(default)'), dom.td(lvl = dom.select(levels.map(l => dom.option(l, t[1] === l ? attr.selected('') : [])))), dom.td(dom.clickbutton('Save', attr.title('Set new log level for package.'), async function click(e) { e.preventDefault(); @@ -2000,7 +2000,7 @@ const accounts = async () => { let domain; let account; let accountModified = false; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Accounts'), dom.h2('Accounts'), (accounts || []).length === 0 ? dom.p('No accounts') : + return dom.div(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(); e.stopPropagation(); @@ -2134,7 +2134,7 @@ const account = async (name) => { } return v * mult; }; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), name), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Action'))), dom.tbody(Object.keys(config.Destinations || {}).length === 0 ? dom.tr(dom.td(attr.colspan('2'), '(None, login disabled)')) : [], Object.keys(config.Destinations || {}).map(k => { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), name), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Action'))), dom.tbody(Object.keys(config.Destinations || {}).length === 0 ? dom.tr(dom.td(attr.colspan('2'), '(None, login disabled)')) : [], Object.keys(config.Destinations || {}).map(k => { let v = k; const t = k.split('@'); if (t.length > 1) { @@ -2338,7 +2338,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(prewrap(t[0]) || '(catchall)'), dom.td(dom.a(t[1], attr.href('#accounts/' + t[1]))), dom.td(dom.clickbutton('Remove', async function click(e) { + return dom.div(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? If it is a member of an alias, it will be removed from the alias.')) { return; @@ -2550,7 +2550,7 @@ const domainAlias = async (d, aliasLocalpart) => { let addFieldset; let addAddress; let delFieldset; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(domain.Domain), '#domains/' + d), 'Alias ' + aliasLocalpart + '@' + domainName(domain.Domain)), dom.h2('Alias'), dom.form(async function submit(e) { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(domain.Domain), '#domains/' + d), 'Alias ' + aliasLocalpart + '@' + domainName(domain.Domain)), dom.h2('Alias'), dom.form(async function submit(e) { e.preventDefault(); e.stopPropagation(); check(aliasFieldset, client.AliasUpdate(aliasLocalpart, d, postPublic.checked, listMembers.checked, allowMsgFrom.checked)); @@ -2580,7 +2580,7 @@ const domainDNSRecords = async (d) => { client.DomainRecords(d), client.ParseDomain(d), ]); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'DNS Records'), dom.h1('Required DNS records'), dom.pre(dom._class('literal'), (records || []).join('\n')), dom.br()); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'DNS Records'), dom.h1('Required DNS records'), dom.pre(dom._class('literal'), (records || []).join('\n')), dom.br()); }; const domainDNSCheck = async (d) => { const [checks, dnsdomain] = await Promise.all([ @@ -2668,16 +2668,16 @@ const domainDNSCheck = async (d) => { const detailsAutodiscover = !checks.Autodiscover.Records ? [] : [ dom.table(dom.thead(dom.tr(dom.th('Host'), dom.th('Port'), dom.th('Priority'), dom.th('Weight'), dom.th('IPs'))), dom.tbody((checks.Autodiscover.Records || []).map(r => dom.tr([r.Target, r.Port, r.Priority, r.Weight, (r.IPs || []).join(', ')].map(s => dom.td('' + s)))))), ]; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'Check DNS'), dom.h1('DNS records and domain configuration check'), resultSection('DNSSEC', checks.DNSSEC, detailsDNSSEC), resultSection('IPRev', checks.IPRev, detailsIPRev), resultSection('MX', checks.MX, detailsMX), resultSection('TLS', checks.TLS, detailsTLS), resultSection('DANE', checks.DANE, detailsDANE), resultSection('SPF', checks.SPF, detailsSPF), resultSection('DKIM', checks.DKIM, detailsDKIM), resultSection('DMARC', checks.DMARC, detailsDMARC), resultSection('Host TLSRPT', checks.HostTLSRPT, detailsTLSRPT(checks.HostTLSRPT)), resultSection('Domain TLSRPT', checks.DomainTLSRPT, detailsTLSRPT(checks.DomainTLSRPT)), resultSection('MTA-STS', checks.MTASTS, detailsMTASTS), resultSection('SRV conf', checks.SRVConf, detailsSRVConf), resultSection('Autoconf', checks.Autoconf, detailsAutoconf), resultSection('Autodiscover', checks.Autodiscover, detailsAutodiscover), dom.br()); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'Check DNS'), dom.h1('DNS records and domain configuration check'), resultSection('DNSSEC', checks.DNSSEC, detailsDNSSEC), resultSection('IPRev', checks.IPRev, detailsIPRev), resultSection('MX', checks.MX, detailsMX), resultSection('TLS', checks.TLS, detailsTLS), resultSection('DANE', checks.DANE, detailsDANE), resultSection('SPF', checks.SPF, detailsSPF), resultSection('DKIM', checks.DKIM, detailsDKIM), resultSection('DMARC', checks.DMARC, detailsDMARC), resultSection('Host TLSRPT', checks.HostTLSRPT, detailsTLSRPT(checks.HostTLSRPT)), resultSection('Domain TLSRPT', checks.DomainTLSRPT, detailsTLSRPT(checks.DomainTLSRPT)), resultSection('MTA-STS', checks.MTASTS, detailsMTASTS), resultSection('SRV conf', checks.SRVConf, detailsSRVConf), resultSection('Autoconf', checks.Autoconf, detailsAutoconf), resultSection('Autodiscover', checks.Autodiscover, detailsAutodiscover), dom.br()); }; const dmarcIndex = async () => { - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'DMARC'), dom.ul(dom.li(dom.a(attr.href('#dmarc/reports'), 'Reports'), ', incoming DMARC aggregate reports.'), dom.li(dom.a(attr.href('#dmarc/evaluations'), 'Evaluations'), ', for outgoing DMARC aggregate reports.'))); + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'DMARC'), dom.ul(dom.li(dom.a(attr.href('#dmarc/reports'), 'Reports'), ', incoming DMARC aggregate reports.'), dom.li(dom.a(attr.href('#dmarc/evaluations'), 'Evaluations'), ', for outgoing DMARC aggregate reports.'))); }; const dmarcReports = async () => { const end = new Date(); const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); const summaries = await client.DMARCSummaries(start, end, ""); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), 'Aggregate reporting summary'), dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), renderDMARCSummaries(summaries || [])); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), 'Aggregate reporting summary'), dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), renderDMARCSummaries(summaries || [])); }; const renderDMARCSummaries = (summaries) => { return [ @@ -2702,7 +2702,7 @@ const dmarcEvaluations = async () => { let until; let comment; const nextmonth = new Date(new Date().getTime() + 31 * 24 * 3600 * 1000); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), 'Evaluations'), dom.p('Incoming messages are checked against the DMARC policy of the domain in the message From header. If the policy requests reporting on the resulting evaluations, they are stored in the database. Each interval of 1 to 24 hours, the evaluations may be sent to a reporting address specified in the domain\'s DMARC policy. Not all evaluations are a reason to send a report, but if a report is sent all evaluations are included.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Domain', attr.title('Domain in the message From header. Keep in mind these can be forged, so this does not necessarily mean someone from this domain authentically tried delivering email.')), dom.th('Dispositions', attr.title('Unique dispositions occurring in report.')), dom.th('Evaluations', attr.title('Total number of message delivery attempts, including retries.')), dom.th('Send report', attr.title('Whether the current evaluations will cause a report to be sent.')))), dom.tbody(Object.entries(evalStats).sort((a, b) => a[0] < b[0] ? -1 : 1).map(t => dom.tr(dom.td(dom.a(attr.href('#dmarc/evaluations/' + domainName(t[1].Domain)), domainString(t[1].Domain))), dom.td((t[1].Dispositions || []).join(' ')), dom.td(style({ textAlign: 'right' }), '' + t[1].Count), dom.td(style({ textAlign: 'right' }), t[1].SendReport ? '✓' : ''))), isEmpty(evalStats) ? dom.tr(dom.td(attr.colspan('3'), 'No evaluations.')) : [])), dom.br(), dom.br(), dom.h2('Suppressed reporting addresses'), dom.p('In practice, sending a DMARC report to a reporting address can cause DSN to be sent back. Such addresses can be added to a suppression list for a period, to reduce noise in the postmaster mailbox.'), dom.form(async function submit(e) { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), 'Evaluations'), dom.p('Incoming messages are checked against the DMARC policy of the domain in the message From header. If the policy requests reporting on the resulting evaluations, they are stored in the database. Each interval of 1 to 24 hours, the evaluations may be sent to a reporting address specified in the domain\'s DMARC policy. Not all evaluations are a reason to send a report, but if a report is sent all evaluations are included.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Domain', attr.title('Domain in the message From header. Keep in mind these can be forged, so this does not necessarily mean someone from this domain authentically tried delivering email.')), dom.th('Dispositions', attr.title('Unique dispositions occurring in report.')), dom.th('Evaluations', attr.title('Total number of message delivery attempts, including retries.')), dom.th('Send report', attr.title('Whether the current evaluations will cause a report to be sent.')))), dom.tbody(Object.entries(evalStats).sort((a, b) => a[0] < b[0] ? -1 : 1).map(t => dom.tr(dom.td(dom.a(attr.href('#dmarc/evaluations/' + domainName(t[1].Domain)), domainString(t[1].Domain))), dom.td((t[1].Dispositions || []).join(' ')), dom.td(style({ textAlign: 'right' }), '' + t[1].Count), dom.td(style({ textAlign: 'right' }), t[1].SendReport ? '✓' : ''))), isEmpty(evalStats) ? dom.tr(dom.td(attr.colspan('3'), 'No evaluations.')) : [])), dom.br(), dom.br(), dom.h2('Suppressed reporting addresses'), dom.p('In practice, sending a DMARC report to a reporting address can cause DSN to be sent back. Such addresses can be added to a suppression list for a period, to reduce noise in the postmaster mailbox.'), dom.form(async function submit(e) { e.stopPropagation(); e.preventDefault(); await check(fieldset, client.DMARCSuppressAdd(reportingAddress.value, new Date(until.value), comment.value)); @@ -2752,7 +2752,7 @@ const dmarcEvaluationsDomain = async (domain) => { }); return r; }; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), crumblink('Evaluations', '#dmarc/evaluations'), 'Domain ' + domainString(d)), dom.div(dom.clickbutton('Remove evaluations', async function click(e) { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), crumblink('Evaluations', '#dmarc/evaluations'), 'Domain ' + domainString(d)), dom.div(dom.clickbutton('Remove evaluations', async function click(e) { await check(e.target, client.DMARCRemoveEvaluations(domain)); window.location.reload(); // todo: only clear the table? })), dom.br(), dom.p('The evaluations below will be sent in a DMARC aggregate report to the addresses found in the published DMARC DNS record, which is fetched again before sending the report. The fields Interval hours, Addresses and Policy are only filled for the first row and whenever a new value in the published DMARC record is encountered.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('ID'), dom.th('Evaluated'), dom.th('Optional', attr.title('Some evaluations will not cause a DMARC aggregate report to be sent. But if a report is sent, optional records are included.')), dom.th('Interval hours', attr.title('DMARC policies published by a domain can specify how often they would like to receive reports. The default is 24 hours, but can be as often as each hour. To keep reports comparable between different mail servers that send reports, reports are sent at rounded up intervals of whole hours that can divide a 24 hour day, and are aligned with the start of a day at UTC.')), dom.th('Addresses', attr.title('Addresses that will receive the report. An address can have a maximum report size configured. If there is no address, no report will be sent.')), dom.th('Policy', attr.title('Summary of the policy as encountered in the DMARC DNS record of the domain, and used for evaluation.')), dom.th('IP', attr.title('IP address of delivery attempt that was evaluated, relevant for SPF.')), dom.th('Disposition', attr.title('Our decision to accept/reject this message. It may be different than requested by the published policy. For example, when overriding due to delivery from a mailing list or forwarded address.')), dom.th('Aligned DKIM/SPF', attr.title('Whether DKIM and SPF had an aligned pass, where strict/relaxed alignment means whether the domain of an SPF pass and DKIM pass matches the exact domain (strict) or optionally a subdomain (relaxed). A DMARC pass requires at least one pass.')), dom.th('Envelope to', attr.title('Domain used in SMTP RCPT TO during delivery.')), dom.th('Envelope from', attr.title('Domain used in SMTP MAIL FROM during delivery.')), dom.th('Message from', attr.title('Domain in "From" message header.')), dom.th('DKIM details', attr.title('Results of verifying DKIM-Signature headers in message. Only signatures with matching organizational domain are included, regardless of strict/relaxed DKIM alignment in DMARC policy.')), dom.th('SPF details', attr.title('Results of SPF check used in DMARC evaluation. "mfrom" indicates the "SMTP MAIL FROM" domain was used, "helo" indicates the SMTP EHLO domain was used.')))), dom.tbody((evaluations || []).map(e => { @@ -2799,7 +2799,7 @@ const domainDMARC = async (d) => { client.Domain(d), ]); // todo future: table sorting? period selection (last day, 7 days, 1 month, 1 year, custom period)? collapse rows for a report? show totals per report? a simple bar graph to visualize messages and dmarc/dkim/spf fails? similar for TLSRPT. - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'DMARC aggregate reports'), dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), dom.p('Below the DMARC aggregate reports for the past 30 days.'), (reports || []).length === 0 ? dom.div('No DMARC reports for domain.') : + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'DMARC aggregate reports'), dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), dom.p('Below the DMARC aggregate reports for the past 30 days.'), (reports || []).length === 0 ? dom.div('No DMARC reports for domain.') : dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('ID'), dom.th('Organisation', attr.title('Organization that sent the DMARC report.')), dom.th('Period (UTC)', attr.title('Period this reporting period is about. Mail servers are recommended to stick to whole UTC days.')), dom.th('Policy', attr.title('The DMARC policy that the remote mail server had fetched and applied to the message. A policy that changed during the reporting period may result in unexpected policy evaluations.')), dom.th('Source IP', attr.title('Remote IP address of session at remote mail server.')), dom.th('Messages', attr.title('Total messages that the results apply to.')), dom.th('Result', attr.title('DMARC evaluation result.')), dom.th('ADKIM', attr.title('DKIM alignment. For a pass, one of the DKIM signatures that pass must be strict/relaxed-aligned with the domain, as specified by the policy.')), dom.th('ASPF', attr.title('SPF alignment. For a pass, the SPF policy must pass and be strict/relaxed-aligned with the domain, as specified by the policy.')), dom.th('SMTP to', attr.title('Domain of destination address, as specified during the SMTP session.')), dom.th('SMTP from', attr.title('Domain of originating address, as specified during the SMTP session.')), dom.th('Header from', attr.title('Domain of address in From-header of message.')), dom.th('Auth Results', attr.title('Details of DKIM and/or SPF authentication results. DMARC requires at least one aligned DKIM or SPF pass.')))), dom.tbody((reports || []).map(r => { const m = r.ReportMetadata; let policy = []; @@ -2912,10 +2912,10 @@ const domainDMARCReport = async (d, reportID) => { client.DMARCReportID(d, reportID), client.Domain(d), ]); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), crumblink('DMARC aggregate reports', '#domains/' + d + '/dmarc'), 'Report ' + reportID), dom.p('Below is the raw report as received from the remote mail server.'), dom.div(dom._class('literal'), JSON.stringify(report, null, '\t'))); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), crumblink('DMARC aggregate reports', '#domains/' + d + '/dmarc'), 'Report ' + reportID), dom.p('Below is the raw report as received from the remote mail server.'), dom.div(dom._class('literal'), JSON.stringify(report, null, '\t'))); }; const tlsrptIndex = async () => { - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'TLSRPT'), dom.ul(dom.li(dom.a(attr.href('#tlsrpt/reports'), 'Reports'), ', incoming TLS reports.'), dom.li(dom.a(attr.href('#tlsrpt/results'), 'Results'), ', for outgoing TLS reports.'))); + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'TLSRPT'), dom.ul(dom.li(dom.a(attr.href('#tlsrpt/reports'), 'Reports'), ', incoming TLS reports.'), dom.li(dom.a(attr.href('#tlsrpt/results'), 'Results'), ', for outgoing TLS reports.'))); }; const tlsrptResults = async () => { const [results, suppressAddresses] = await Promise.all([ @@ -2928,7 +2928,7 @@ const tlsrptResults = async () => { let until; let comment; const nextmonth = new Date(new Date().getTime() + 31 * 24 * 3600 * 1000); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), 'Results'), dom.p('Messages are delivered with SMTP with TLS using STARTTLS if supported and/or required by the recipient domain\'s mail server. TLS connections may fail for various reasons, such as mismatching certificate host name, expired certificates or TLS protocol version/cipher suite incompatibilities. Statistics about successful connections and failed connections are tracked. Results can be tracked for recipient domains (for MTA-STS policies), and per MX host (for DANE). A domain/host can publish a TLSRPT DNS record with addresses that should receive TLS reports. Reports are sent every 24 hours. Not all results are enough reason to send a report, but if a report is sent all results are included. By default, reports are only sent if a report contains a connection failure. Sending reports about all-successful connections can be configured. Reports sent to recipient domains include the results for its MX hosts, and reports for an MX host reference the recipient domains.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Day (UTC)', attr.title('Day covering these results, a whole day from 00:00 UTC to 24:00 UTC.')), dom.th('Recipient domain', attr.title('Domain of addressee. For delivery to a recipient, the recipient and policy domains will match for reporting on MTA-STS policies, but can also result in reports for hosts from the MX record of the recipient to report on DANE policies.')), dom.th('Policy domain', attr.title('Domain for TLSRPT policy, specifying URIs to which reports should be sent.')), dom.th('Host', attr.title('Whether policy domain is an (MX) host (for DANE), or a recipient domain (for MTA-STS).')), dom.th('Policies', attr.title('Policies found.')), dom.th('Success', attr.title('Total number of successful connections.')), dom.th('Failure', attr.title('Total number of failed connection attempts.')), dom.th('Failure details', attr.title('Total number of details about failures.')), dom.th('Send report', attr.title('Whether the current results may cause a report to be sent. To prevent report loops, reports are not sent for TLS connections used to deliver TLS or DMARC reports. Whether a report is eventually sent depends on more factors, such as whether the policy domain has a TLSRPT policy with reporting addresses, and whether TLS connection failures were registered (depending on configuration).')))), dom.tbody((results || []).sort((a, b) => { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), 'Results'), dom.p('Messages are delivered with SMTP with TLS using STARTTLS if supported and/or required by the recipient domain\'s mail server. TLS connections may fail for various reasons, such as mismatching certificate host name, expired certificates or TLS protocol version/cipher suite incompatibilities. Statistics about successful connections and failed connections are tracked. Results can be tracked for recipient domains (for MTA-STS policies), and per MX host (for DANE). A domain/host can publish a TLSRPT DNS record with addresses that should receive TLS reports. Reports are sent every 24 hours. Not all results are enough reason to send a report, but if a report is sent all results are included. By default, reports are only sent if a report contains a connection failure. Sending reports about all-successful connections can be configured. Reports sent to recipient domains include the results for its MX hosts, and reports for an MX host reference the recipient domains.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Day (UTC)', attr.title('Day covering these results, a whole day from 00:00 UTC to 24:00 UTC.')), dom.th('Recipient domain', attr.title('Domain of addressee. For delivery to a recipient, the recipient and policy domains will match for reporting on MTA-STS policies, but can also result in reports for hosts from the MX record of the recipient to report on DANE policies.')), dom.th('Policy domain', attr.title('Domain for TLSRPT policy, specifying URIs to which reports should be sent.')), dom.th('Host', attr.title('Whether policy domain is an (MX) host (for DANE), or a recipient domain (for MTA-STS).')), dom.th('Policies', attr.title('Policies found.')), dom.th('Success', attr.title('Total number of successful connections.')), dom.th('Failure', attr.title('Total number of failed connection attempts.')), dom.th('Failure details', attr.title('Total number of details about failures.')), dom.th('Send report', attr.title('Whether the current results may cause a report to be sent. To prevent report loops, reports are not sent for TLS connections used to deliver TLS or DMARC reports. Whether a report is eventually sent depends on more factors, such as whether the policy domain has a TLSRPT policy with reporting addresses, and whether TLS connection failures were registered (depending on configuration).')))), dom.tbody((results || []).sort((a, b) => { if (a.DayUTC !== b.DayUTC) { return a.DayUTC < b.DayUTC ? -1 : 1; } @@ -2970,7 +2970,7 @@ const tlsrptResultsPolicyDomain = async (isrcptdom, domain) => { const [d, tlsresults] = await client.TLSRPTResultsDomain(isrcptdom, domain); const recordPromise = client.LookupTLSRPTRecord(domain); let recordBox; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Results', '#tlsrpt/results'), (isrcptdom ? 'Recipient domain ' : 'Host ') + domainString(d)), dom.div(dom.clickbutton('Remove results', async function click(e) { + const root = dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Results', '#tlsrpt/results'), (isrcptdom ? 'Recipient domain ' : 'Host ') + domainString(d)), dom.div(dom.clickbutton('Remove results', async function click(e) { e.preventDefault(); await check(e.target, client.TLSRPTRemoveResults(isrcptdom, domain, '')); window.location.reload(); // todo: only clear the table? @@ -2999,12 +2999,13 @@ const tlsrptResultsPolicyDomain = async (isrcptdom, domain) => { } dom._kids(recordBox, l); })(); + return root; }; const tlsrptReports = async () => { const end = new Date(); const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); const summaries = await client.TLSRPTSummaries(start, end, ''); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), 'Reports'), dom.p('TLSRPT (TLS reporting) is a mechanism to request feedback from other mail servers about TLS connections to your mail server. If is typically used along with MTA-STS and/or DANE to enforce that SMTP connections are protected with TLS. Mail servers implementing TLSRPT will typically send a daily report with both successful and failed connection counts, including details about failures.'), renderTLSRPTSummaries(summaries || [])); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), 'Reports'), dom.p('TLSRPT (TLS reporting) is a mechanism to request feedback from other mail servers about TLS connections to your mail server. If is typically used along with MTA-STS and/or DANE to enforce that SMTP connections are protected with TLS. Mail servers implementing TLSRPT will typically send a daily report with both successful and failed connection counts, including details about failures.'), renderTLSRPTSummaries(summaries || [])); }; const renderTLSRPTSummaries = (summaries) => { return [ @@ -3030,7 +3031,7 @@ const domainTLSRPT = async (d) => { } return s; }; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Reports', '#tlsrpt/reports'), 'Domain ' + domainString(dnsdomain)), dom.p('TLSRPT (TLS reporting) is a mechanism to request feedback from other mail servers about TLS connections to your mail server. If is typically used along with MTA-STS and/or DANE to enforce that SMTP connections are protected with TLS. Mail servers implementing TLSRPT will typically send a daily report with both successful and failed connection counts, including details about failures.'), dom.p('Below the TLS reports for the past 30 days.'), (records || []).length === 0 ? dom.div('No TLS reports for domain.') : + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Reports', '#tlsrpt/reports'), 'Domain ' + domainString(dnsdomain)), dom.p('TLSRPT (TLS reporting) is a mechanism to request feedback from other mail servers about TLS connections to your mail server. If is typically used along with MTA-STS and/or DANE to enforce that SMTP connections are protected with TLS. Mail servers implementing TLSRPT will typically send a daily report with both successful and failed connection counts, including details about failures.'), dom.p('Below the TLS reports for the past 30 days.'), (records || []).length === 0 ? dom.div('No TLS reports for domain.') : dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Report', attr.colspan('3')), dom.th('Policy', attr.colspan('3')), dom.th('Failure Details', attr.colspan('8'))), dom.tr(dom.th('ID'), dom.th('From', attr.title('SMTP mail from from which we received the report.')), dom.th('Period (UTC)', attr.title('Period this reporting period is about. Mail servers are recommended to stick to whole UTC days.')), dom.th('Policy', attr.title('The policy applied, typically STSv1.')), dom.th('Successes', attr.title('Total number of successful TLS connections for policy.')), dom.th('Failures', attr.title('Total number of failed TLS connections for policy.')), dom.th('Result Type', attr.title('Type of failure.')), dom.th('Sending MTA', attr.title('IP of sending MTA.')), dom.th('Receiving MX Host'), dom.th('Receiving MX HELO'), dom.th('Receiving IP'), dom.th('Count', attr.title('Number of TLS connections that failed with these details.')), dom.th('More', attr.title('Optional additional information about the failure.')), dom.th('Code', attr.title('Optional API error code relating to the failure.')))), dom.tbody((records || []).map(record => { const r = record.Report; let nrows = 0; @@ -3080,11 +3081,11 @@ const domainTLSRPTID = async (d, reportID) => { client.TLSReportID(d, reportID), client.ParseDomain(d), ]); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Reports', '#tlsrpt/reports'), crumblink('Domain ' + domainString(dnsdomain), '#tlsrpt/reports/' + d + ''), 'Report ' + reportID), dom.p('Below is the raw report as received from the remote mail server.'), dom.div(dom._class('literal'), JSON.stringify(report, null, '\t'))); + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Reports', '#tlsrpt/reports'), crumblink('Domain ' + domainString(dnsdomain), '#tlsrpt/reports/' + d + ''), 'Report ' + reportID), dom.p('Below is the raw report as received from the remote mail server.'), dom.div(dom._class('literal'), JSON.stringify(report, null, '\t'))); }; const mtasts = async () => { const policies = await client.MTASTSPolicies(); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'MTA-STS policies'), dom.p("MTA-STS is a mechanism allowing email domains to publish a policy for using SMTP STARTTLS and TLS verification. See ", link('https://www.rfc-editor.org/rfc/rfc8461.html', 'RFC 8461'), '.'), dom.p("The SMTP protocol is unencrypted by default, though the SMTP STARTTLS command is typically used to enable TLS on a connection. However, MTA's using STARTTLS typically do not validate the TLS certificate. An MTA-STS policy can specify that validation of host name, non-expiration and webpki trust is required."), makeMTASTSTable(policies || [])); + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'MTA-STS policies'), dom.p("MTA-STS is a mechanism allowing email domains to publish a policy for using SMTP STARTTLS and TLS verification. See ", link('https://www.rfc-editor.org/rfc/rfc8461.html', 'RFC 8461'), '.'), dom.p("The SMTP protocol is unencrypted by default, though the SMTP STARTTLS command is typically used to enable TLS on a connection. However, MTA's using STARTTLS typically do not validate the TLS certificate. An MTA-STS policy can specify that validation of host name, non-expiration and webpki trust is required."), makeMTASTSTable(policies || [])); }; const formatMTASTSMX = (mx) => { return mx.map(e => { @@ -3131,7 +3132,7 @@ const dnsbl = async () => { const url = (ip) => 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html'; let fieldset; let monitorTextarea; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'DNS blocklist status for IPs'), dom.p('Follow the external links to a third party DNSBL checker to see if the IP is on one of the many blocklist.'), dom.ul(Object.entries(ipZoneResults).sort().map(ipZones => { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'DNS blocklist status for IPs'), dom.p('Follow the external links to a third party DNSBL checker to see if the IP is on one of the many blocklist.'), dom.ul(Object.entries(ipZoneResults).sort().map(ipZones => { const [ip, zoneResults] = ipZones; return dom.li(link(url(ip), ip), !ipZones.length ? [] : dom.ul(Object.entries(zoneResults).sort().map(zoneResult => dom.li(zoneResult[0] + ': ', zoneResult[1] === 'pass' ? 'pass' : box(red, zoneResult[1]))))); })), !Object.entries(ipZoneResults).length ? box(red, 'No IPs found.') : [], dom.br(), dom.h2('DNSBL zones checked due to being used for incoming deliveries'), (usingZones || []).length === 0 ? @@ -3230,7 +3231,7 @@ const queueList = async () => { window.alert('' + n + ' message(s) updated'); window.location.reload(); // todo: reload less }); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Queue'), dom.p(dom.a(attr.href('#queue/retired'), 'Retired messages')), dom.h2('Hold rules', attr.title('Messages submitted to the queue that match a hold rule are automatically marked as "on hold", preventing delivery until explicitly taken off hold again.')), dom.form(attr.id('holdRuleForm'), async function submit(e) { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Queue'), dom.p(dom.a(attr.href('#queue/retired'), 'Retired messages')), dom.h2('Hold rules', attr.title('Messages submitted to the queue that match a hold rule are automatically marked as "on hold", preventing delivery until explicitly taken off hold again.')), dom.form(attr.id('holdRuleForm'), async function submit(e) { e.preventDefault(); e.stopPropagation(); const pr = { @@ -3406,7 +3407,7 @@ const retiredList = async () => { tbody = ntbody; }; render(); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Queue', '#queue'), 'Retired messages'), + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Queue', '#queue'), 'Retired messages'), // Filtering. filterForm = dom.form(attr.id('queuefilter'), // Referenced by input elements in table row. async function submit(e) { @@ -3531,7 +3532,7 @@ const hooksList = async () => { window.alert('' + n + ' hook(s) updated'); window.location.reload(); // todo: reload less }); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Webhook queue'), dom.p(dom.a(attr.href('#webhookqueue/retired'), 'Retired webhooks')), dom.h2('Webhooks'), dom.table(dom._class('hover'), style({ width: '100%' }), dom.thead(dom.tr(dom.td(attr.colspan('2'), 'Filter'), dom.td(filterSubmitted = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: "<-1h" for filtering webhooks submitted more than 1 hour ago.'))), dom.td(), dom.td(), dom.td(), dom.td(filterAccount = dom.input(attr.form('hooksfilter'), style({ width: '8em' }))), dom.td(filterEvent = dom.select(attr.form('hooksfilter'), function change() { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Webhook queue'), dom.p(dom.a(attr.href('#webhookqueue/retired'), 'Retired webhooks')), dom.h2('Webhooks'), dom.table(dom._class('hover'), style({ width: '100%' }), dom.thead(dom.tr(dom.td(attr.colspan('2'), 'Filter'), dom.td(filterSubmitted = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: "<-1h" for filtering webhooks submitted more than 1 hour ago.'))), dom.td(), dom.td(), dom.td(), dom.td(filterAccount = dom.input(attr.form('hooksfilter'), style({ width: '8em' }))), dom.td(filterEvent = dom.select(attr.form('hooksfilter'), function change() { filterForm.requestSubmit(); }, dom.option(''), // note: outgoing hook events are in ../webhook/webhook.go, ../mox-/config.go ../webadmin/admin.ts and ../webapi/gendoc.sh. keep in sync. @@ -3625,7 +3626,7 @@ const hooksRetiredList = async () => { tbody = ntbody; }; render(); - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Webhook queue', '#webhookqueue'), 'Retired webhooks'), dom.h2('Retired webhooks'), dom.table(dom._class('hover'), style({ width: '100%' }), dom.thead(dom.tr(dom.td('Filter'), dom.td(), dom.td(filterLastActivity = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: ">-1h" for filtering last activity for webhooks more than 1 hour ago.'))), dom.td(filterSubmitted = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: "<-1h" for filtering webhooks submitted more than 1 hour ago.'))), dom.td(), dom.td(), dom.td(), dom.td(filterAccount = dom.input(attr.form('hooksfilter'), style({ width: '8em' }))), dom.td(filterEvent = dom.select(attr.form('hooksfilter'), function change() { + return dom.div(crumbs(crumblink('Mox Admin', '#'), crumblink('Webhook queue', '#webhookqueue'), 'Retired webhooks'), dom.h2('Retired webhooks'), dom.table(dom._class('hover'), style({ width: '100%' }), dom.thead(dom.tr(dom.td('Filter'), dom.td(), dom.td(filterLastActivity = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: ">-1h" for filtering last activity for webhooks more than 1 hour ago.'))), dom.td(filterSubmitted = dom.input(attr.form('hooksfilter'), style({ width: '7em' }), attr.title('Example: "<-1h" for filtering webhooks submitted more than 1 hour ago.'))), dom.td(), dom.td(), dom.td(), dom.td(filterAccount = dom.input(attr.form('hooksfilter'), style({ width: '8em' }))), dom.td(filterEvent = dom.select(attr.form('hooksfilter'), function change() { filterForm.requestSubmit(); }, dom.option(''), // note: outgoing hook events are in ../webhook/webhook.go, ../mox-/config.go ../webadmin/admin.ts and ../webapi/gendoc.sh. keep in sync. @@ -4004,7 +4005,7 @@ const webserver = async () => { }), ]; }; - dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Webserver config'), dom.form(fieldset = dom.fieldset(dom.h2('Domain redirects', attr.title('Corresponds with WebDomainRedirects in domains.conf')), dom.p('Incoming requests for these domains are redirected to the target domain, with HTTPS.'), dom.table(dom.thead(dom.tr(dom.th('From'), dom.th('To'), dom.th('Action ', dom.clickbutton('Add', function click() { + return dom.div(crumbs(crumblink('Mox Admin', '#'), 'Webserver config'), dom.form(fieldset = dom.fieldset(dom.h2('Domain redirects', attr.title('Corresponds with WebDomainRedirects in domains.conf')), dom.p('Incoming requests for these domains are redirected to the target domain, with HTTPS.'), dom.table(dom.thead(dom.tr(dom.th('From'), dom.th('To'), dom.th('Action ', dom.clickbutton('Add', function click() { const row = redirectRow([{ ASCII: '', Unicode: '' }, { ASCII: '', Unicode: '' }]); redirectsTbody.appendChild(row.root); noredirect.style.display = redirectRows.length ? 'none' : ''; @@ -4027,96 +4028,101 @@ const init = async () => { const t = h.split('/'); page.classList.add('loading'); try { + let root; if (h == '') { - await index(); + root = await index(); } else if (h === 'config') { - await config(); + root = await config(); } else if (h === 'loglevels') { - await loglevels(); + root = await loglevels(); } else if (h === 'accounts') { - await accounts(); + root = await accounts(); } else if (t[0] === 'accounts' && t.length === 2) { - await account(t[1]); + root = await account(t[1]); } else if (t[0] === 'domains' && t.length === 2) { - await domain(t[1]); + root = await domain(t[1]); } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'alias') { - await domainAlias(t[1], t[3]); + root = await domainAlias(t[1], t[3]); } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dmarc') { - await domainDMARC(t[1]); + root = await domainDMARC(t[1]); } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'dmarc' && parseInt(t[3])) { - await domainDMARCReport(t[1], parseInt(t[3])); + root = await domainDMARCReport(t[1], parseInt(t[3])); } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnscheck') { - await domainDNSCheck(t[1]); + root = await domainDNSCheck(t[1]); } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnsrecords') { - await domainDNSRecords(t[1]); + root = await domainDNSRecords(t[1]); } else if (h === 'queue') { - await queueList(); + root = await queueList(); } else if (h === 'queue/retired') { - await retiredList(); + root = await retiredList(); } else if (h === 'webhookqueue') { - await hooksList(); + root = await hooksList(); } else if (h === 'webhookqueue/retired') { - await hooksRetiredList(); + root = await hooksRetiredList(); } else if (h === 'tlsrpt') { - await tlsrptIndex(); + root = await tlsrptIndex(); } else if (h === 'tlsrpt/reports') { - await tlsrptReports(); + root = await tlsrptReports(); } else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 3) { - await domainTLSRPT(t[2]); + root = await domainTLSRPT(t[2]); } else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 4 && parseInt(t[3])) { - await domainTLSRPTID(t[2], parseInt(t[3])); + root = await domainTLSRPTID(t[2], parseInt(t[3])); } else if (h === 'tlsrpt/results') { - await tlsrptResults(); + root = await tlsrptResults(); } else if (t[0] == 'tlsrpt' && t[1] == 'results' && (t[2] === 'rcptdom' || t[2] == 'host') && t.length === 4) { - await tlsrptResultsPolicyDomain(t[2] === 'rcptdom', t[3]); + root = await tlsrptResultsPolicyDomain(t[2] === 'rcptdom', t[3]); } else if (h === 'dmarc') { - await dmarcIndex(); + root = await dmarcIndex(); } else if (h === 'dmarc/reports') { - await dmarcReports(); + root = await dmarcReports(); } else if (h === 'dmarc/evaluations') { - await dmarcEvaluations(); + root = await dmarcEvaluations(); } else if (t[0] == 'dmarc' && t[1] == 'evaluations' && t.length === 3) { - await dmarcEvaluationsDomain(t[2]); + root = await dmarcEvaluationsDomain(t[2]); } else if (h === 'mtasts') { - await mtasts(); + root = await mtasts(); } else if (h === 'dnsbl') { - await dnsbl(); + root = await dnsbl(); } else if (h === 'routes') { - await globalRoutes(); + root = await globalRoutes(); } else if (h === 'webserver') { - await webserver(); + root = await webserver(); } else { - dom._kids(page, 'page not found'); + root = dom.div('page not found'); } + if (window.moxBeforeDisplay) { + moxBeforeDisplay(root); + } + dom._kids(page, root); } catch (err) { console.log('error', err); diff --git a/webadmin/admin.ts b/webadmin/admin.ts index d1b30d9..c953f4b 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -5,6 +5,8 @@ declare let page: HTMLElement declare let moxversion: string declare let moxgoos: string declare let moxgoarch: string +// From customization script. +declare let moxBeforeDisplay: (webmailroot: HTMLElement) => void const login = async (reason: string) => { return new Promise((resolve: (v: string) => void, _) => { @@ -346,7 +348,7 @@ const index = async () => { let recvID: HTMLInputElement let cidElem: HTMLSpanElement - dom._kids(page, + return dom.div( crumbs('Mox Admin'), checkUpdatesEnabled ? [] : dom.p(box(yellow, 'Warning: Checking for updates has not been enabled in mox.conf (CheckUpdates: true).', dom.br(), 'Make sure you stay up to date through another mechanism!', dom.br(), 'You have a responsibility to keep the internet-connected software you run up to date and secure!', dom.br(), 'See ', link('https://updates.xmox.nl/changelog'))), dom.p( @@ -439,7 +441,7 @@ const globalRoutes = async () => { client.Config(), ]) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Routes', @@ -451,7 +453,7 @@ const globalRoutes = async () => { const config = async () => { const [staticPath, dynamicPath, staticText, dynamicText] = await client.ConfigFiles() - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Config', @@ -473,7 +475,7 @@ const loglevels = async () => { let pkg: HTMLInputElement let level: HTMLSelectElement - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Log levels', @@ -584,7 +586,7 @@ const accounts = async () => { let account: HTMLInputElement let accountModified = false - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Accounts', @@ -803,7 +805,7 @@ const account = async (name: string) => { return v*mult } - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), @@ -1219,7 +1221,7 @@ const domain = async (d: string) => { ) } - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Domain ' + domainString(dnsdomain), @@ -1794,7 +1796,7 @@ const domainAlias = async (d: string, aliasLocalpart: string) => { let delFieldset: HTMLFieldSetElement - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(domain.Domain), '#domains/'+d), @@ -1901,7 +1903,7 @@ const domainDNSRecords = async (d: string) => { client.ParseDomain(d), ]) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), @@ -2056,7 +2058,7 @@ const domainDNSCheck = async (d: string) => { ), ] - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), @@ -2082,7 +2084,7 @@ const domainDNSCheck = async (d: string) => { } const dmarcIndex = async () => { - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'DMARC', @@ -2103,7 +2105,7 @@ const dmarcReports = async () => { const start = new Date(new Date().getTime() - 30*24*3600*1000) const summaries = await client.DMARCSummaries(start, end, "") - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), @@ -2165,7 +2167,7 @@ const dmarcEvaluations = async () => { const nextmonth = new Date(new Date().getTime()+31*24*3600*1000) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), @@ -2305,7 +2307,7 @@ const dmarcEvaluationsDomain = async (domain: string) => { return r } - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), @@ -2411,7 +2413,7 @@ const domainDMARC = async (d: string) => { // todo future: table sorting? period selection (last day, 7 days, 1 month, 1 year, custom period)? collapse rows for a report? show totals per report? a simple bar graph to visualize messages and dmarc/dkim/spf fails? similar for TLSRPT. - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), @@ -2578,7 +2580,7 @@ const domainDMARCReport = async (d: string, reportID: number) => { client.Domain(d), ]) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), @@ -2591,7 +2593,7 @@ const domainDMARCReport = async (d: string, reportID: number) => { } const tlsrptIndex = async () => { - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'TLSRPT', @@ -2621,7 +2623,7 @@ const tlsrptResults = async () => { let comment: HTMLInputElement const nextmonth = new Date(new Date().getTime()+31*24*3600*1000) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), @@ -2758,7 +2760,7 @@ const tlsrptResultsPolicyDomain = async (isrcptdom: boolean, domain: string) => const recordPromise = client.LookupTLSRPTRecord(domain) let recordBox: HTMLElement - dom._kids(page, + const root = dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), @@ -2808,6 +2810,8 @@ const tlsrptResultsPolicyDomain = async (isrcptdom: boolean, domain: string) => } dom._kids(recordBox, l) })() + + return root } const tlsrptReports = async () => { @@ -2815,7 +2819,7 @@ const tlsrptReports = async () => { const start = new Date(new Date().getTime() - 30*24*3600*1000) const summaries = await client.TLSRPTSummaries(start, end, '') - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), @@ -2872,7 +2876,7 @@ const domainTLSRPT = async (d: string) => { return s } - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), @@ -2968,7 +2972,7 @@ const domainTLSRPTID = async (d: string, reportID: number) => { client.ParseDomain(d), ]) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), @@ -2984,7 +2988,7 @@ const domainTLSRPTID = async (d: string, reportID: number) => { const mtasts = async () => { const policies = await client.MTASTSPolicies() - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'MTA-STS policies', @@ -3056,7 +3060,7 @@ const dnsbl = async () => { let fieldset: HTMLFieldSetElement let monitorTextarea: HTMLTextAreaElement - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'DNS blocklist status for IPs', @@ -3256,7 +3260,7 @@ const queueList = async () => { window.location.reload() // todo: reload less }) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Queue', @@ -3696,7 +3700,7 @@ const retiredList = async () => { } render() - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Queue', '#queue'), @@ -3978,7 +3982,7 @@ const hooksList = async () => { window.location.reload() // todo: reload less }) - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Webhook queue', @@ -4266,7 +4270,7 @@ const hooksRetiredList = async () => { } render() - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), crumblink('Webhook queue', '#webhookqueue'), @@ -5039,7 +5043,7 @@ const webserver = async () => { ] } - dom._kids(page, + return dom.div( crumbs( crumblink('Mox Admin', '#'), 'Webserver config', @@ -5121,67 +5125,72 @@ const init = async () => { const t = h.split('/') page.classList.add('loading') try { + let root: HTMLElement if (h == '') { - await index() + root = await index() } else if (h === 'config') { - await config() + root = await config() } else if (h === 'loglevels') { - await loglevels() + root = await loglevels() } else if (h === 'accounts') { - await accounts() + root = await accounts() } else if (t[0] === 'accounts' && t.length === 2) { - await account(t[1]) + root = await account(t[1]) } else if (t[0] === 'domains' && t.length === 2) { - await domain(t[1]) + root = await domain(t[1]) } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'alias') { - await domainAlias(t[1], t[3]) + root = await domainAlias(t[1], t[3]) } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dmarc') { - await domainDMARC(t[1]) + root = await domainDMARC(t[1]) } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'dmarc' && parseInt(t[3])) { - await domainDMARCReport(t[1], parseInt(t[3])) + root = await domainDMARCReport(t[1], parseInt(t[3])) } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnscheck') { - await domainDNSCheck(t[1]) + root = await domainDNSCheck(t[1]) } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnsrecords') { - await domainDNSRecords(t[1]) + root = await domainDNSRecords(t[1]) } else if (h === 'queue') { - await queueList() + root = await queueList() } else if (h === 'queue/retired') { - await retiredList() + root = await retiredList() } else if (h === 'webhookqueue') { - await hooksList() + root = await hooksList() } else if (h === 'webhookqueue/retired') { - await hooksRetiredList() + root = await hooksRetiredList() } else if (h === 'tlsrpt') { - await tlsrptIndex() + root = await tlsrptIndex() } else if (h === 'tlsrpt/reports') { - await tlsrptReports() + root = await tlsrptReports() } else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 3) { - await domainTLSRPT(t[2]) + root = await domainTLSRPT(t[2]) } else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 4 && parseInt(t[3])) { - await domainTLSRPTID(t[2], parseInt(t[3])) + root = await domainTLSRPTID(t[2], parseInt(t[3])) } else if (h === 'tlsrpt/results') { - await tlsrptResults() + root = await tlsrptResults() } else if (t[0] == 'tlsrpt' && t[1] == 'results' && (t[2] === 'rcptdom' || t[2] == 'host') && t.length === 4) { - await tlsrptResultsPolicyDomain(t[2] === 'rcptdom', t[3]) + root = await tlsrptResultsPolicyDomain(t[2] === 'rcptdom', t[3]) } else if (h === 'dmarc') { - await dmarcIndex() + root = await dmarcIndex() } else if (h === 'dmarc/reports') { - await dmarcReports() + root = await dmarcReports() } else if (h === 'dmarc/evaluations') { - await dmarcEvaluations() + root = await dmarcEvaluations() } else if (t[0] == 'dmarc' && t[1] == 'evaluations' && t.length === 3) { - await dmarcEvaluationsDomain(t[2]) + root = await dmarcEvaluationsDomain(t[2]) } else if (h === 'mtasts') { - await mtasts() + root = await mtasts() } else if (h === 'dnsbl') { - await dnsbl() + root = await dnsbl() } else if (h === 'routes') { - await globalRoutes() + root = await globalRoutes() } else if (h === 'webserver') { - await webserver() + root = await webserver() } else { - dom._kids(page, 'page not found') + root = dom.div('page not found') } + if ((window as any).moxBeforeDisplay) { + moxBeforeDisplay(root) + } + dom._kids(page, root) } catch (err) { console.log('error', err) window.alert('Error: ' + errmsg(err)) diff --git a/webmail/lib.ts b/webmail/lib.ts index b05bd43..c113451 100644 --- a/webmail/lib.ts +++ b/webmail/lib.ts @@ -9,11 +9,11 @@ // We keep the default/regular styles and dark-mode styles in separate stylesheets. const cssStyle = dom.style(attr.type('text/css')) -document.head.appendChild(cssStyle) +document.head.prepend(cssStyle) const styleSheet = cssStyle.sheet! const cssStyleDark = dom.style(attr.type('text/css')) -document.head.appendChild(cssStyleDark) +document.head.prepend(cssStyleDark) const styleSheetDark = cssStyleDark.sheet! styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}') const darkModeRule = styleSheetDark.cssRules[0] as CSSMediaRule @@ -42,8 +42,11 @@ const ensureCSS = (selector: string, styles: { [prop: string]: string | number | let darkst: CSSStyleDeclaration | undefined for (let [k, v] of Object.entries(styles)) { // We've kept the camel-case in our code which we had from when we did "st[prop] = - // value". It is more convenient as object keys. So convert to kebab-case. - k = k.replace(/[A-Z]/g, s => '-'+s.toLowerCase()) + // value". It is more convenient as object keys. So convert to kebab-case, but only + // if this is not a css property. + if (!k.startsWith('--')) { + k = k.replace(/[A-Z]/g, s => '-'+s.toLowerCase()) + } if (Array.isArray(v)) { if (v.length !== 2) { throw new Error('2 elements required for light/dark mode style, got '+v.length) @@ -70,64 +73,132 @@ const css = (className: string, styles: { [prop: string]: string | number | stri // todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. // todo: add the standard padding and border-radius, perhaps more. -// todo: could make some of these {prop: value} objects and pass them directly to css() -const styles = { - color: ['black', '#ddd'], - colorMild: ['#555', '#bbb'], - colorMilder: ['#666', '#aaa'], - backgroundColor: ['white', '#222'], - backgroundColorMild: ['#f8f8f8', '#080808'], - backgroundColorMilder: ['#999', '#777'], - borderColor: ['#ccc', '#333'], - mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], - msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], - boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], +// We define css variables, making them easy to override. +ensureCSS(':root', { + '--color': ['black', '#ddd'], + '--colorMild': ['#555', '#bbb'], + '--colorMilder': ['#666', '#aaa'], + '--backgroundColor': ['white', '#222'], + '--backgroundColorMild': ['#f8f8f8', '#080808'], + '--backgroundColorMilder': ['#999', '#777'], + '--borderColor': ['#ccc', '#333'], + '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'], + '--msglistBackgroundColor': ['#f5ffff', '#04130d'], + '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], - buttonBackground: ['#eee', '#222'], - buttonBorderColor: ['#888', '#666'], - buttonHoverBackground: ['#ddd', '#333'], + '--buttonBackground': ['#eee', '#222'], + '--buttonBorderColor': ['#888', '#666'], + '--buttonHoverBackground': ['#ddd', '#333'], - overlayOpaqueBackgroundColor: ['#eee', '#011'], - overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + '--overlayOpaqueBackgroundColor': ['#eee', '#011'], + '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], - popupColor: ['black', 'white'], - popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], - popupBorderColor: ['#ccc', '#555'], + '--popupColor': ['black', 'white'], + '--popupBackgroundColor': ['white', '#313233'], + '--popupBorderColor': ['#ccc', '#555'], - highlightBackground: ['gold', '#a70167'], - highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], - highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], + '--highlightBackground': ['gold', '#a70167'], + '--highlightBorderColor': ['#8c7600', '#fd1fa7'], + '--highlightBackgroundHover': ['#ffbd21', '#710447'], - mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], - mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], + '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'], + '--mailboxHoverBackgroundColor': ['#eee', '#421f15'], - msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], - msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], - msgItemFocusBorderColor: ['#2685ff', '#2685ff'], + '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'], + '--msgItemHoverBackgroundColor': ['#eee', '#073348'], + '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'], - buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], - buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], + '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'], + '--buttonTristateOffBackground': ['#ffb192', '#bf410f'], - warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], + '--warningBackgroundColor': ['#ffca91', '#a85700'], - successBackground: ['#d2f791', '#1fa204'], - emphasisBackground: ['#666', '#aaa'], + '--successBackground': ['#d2f791', '#1fa204'], + '--emphasisBackground': ['#666', '#aaa'], // For authentication/security results. - underlineGreen: '#50c40f', - underlineRed: '#e15d1c', - underlineBlue: '#09f', - underlineGrey: '#888', + '--underlineGreen': '#50c40f', + '--underlineRed': '#e15d1c', + '--underlineBlue': '#09f', + '--underlineGrey': '#888', + + '--quoted1Color': ['#03828f', '#71f2ff'], // red + '--quoted2Color': ['#c7445c', '#ec4c4c'], // green + '--quoted3Color': ['#417c10', '#73e614'], // blue + + '--scriptSwitchUnderlineColor': ['#dca053', '#e88f1e'], + + '--linkColor': ['#096bc2', '#63b6ff'], + '--linkVisitedColor': ['#0704c1', '#c763ff'], +}) + +// Typed way to reference a css variables. Kept from before used variables. +const styles = { + color: 'var(--color)', + colorMild: 'var(--colorMild)', + colorMilder: 'var(--colorMilder)', + backgroundColor: 'var(--backgroundColor)', + backgroundColorMild: 'var(--backgroundColorMild)', + backgroundColorMilder: 'var(--backgroundColorMilder)', + borderColor: 'var(--borderColor)', + mailboxesTopBackgroundColor: 'var(--mailboxesTopBackgroundColor)', + msglistBackgroundColor: 'var(--msglistBackgroundColor)', + boxShadow: 'var(--boxShadow)', + + buttonBackground: 'var(--buttonBackground)', + buttonBorderColor: 'var(--buttonBorderColor)', + buttonHoverBackground: 'var(--buttonHoverBackground)', + + overlayOpaqueBackgroundColor: 'var(--overlayOpaqueBackgroundColor)', + overlayBackgroundColor: 'var(--overlayBackgroundColor)', + + popupColor: 'var(--popupColor)', + popupBackgroundColor: 'var(--popupBackgroundColor)', + popupBorderColor: 'var(--popupBorderColor)', + + highlightBackground: 'var(--highlightBackground)', + highlightBorderColor: 'var(--highlightBorderColor)', + highlightBackgroundHover: 'var(--highlightBackgroundHover)', + + mailboxActiveBackground: 'var(--mailboxActiveBackground)', + mailboxHoverBackgroundColor: 'var(--mailboxHoverBackgroundColor)', + + msgItemActiveBackground: 'var(--msgItemActiveBackground)', + msgItemHoverBackgroundColor: 'var(--msgItemHoverBackgroundColor)', + msgItemFocusBorderColor: 'var(--msgItemFocusBorderColor)', + + buttonTristateOnBackground: 'var(--buttonTristateOnBackground)', + buttonTristateOffBackground: 'var(--buttonTristateOffBackground)', + + warningBackgroundColor: 'var(--warningBackgroundColor)', + + successBackground: 'var(--successBackground)', + emphasisBackground: 'var(--emphasisBackground)', + + // For authentication/security results. + underlineGreen: 'var(--underlineGreen)', + underlineRed: 'var(--underlineRed)', + underlineBlue: 'var(--underlineBlue)', + underlineGrey: 'var(--underlineGrey)', + + quoted1Color: 'var(--quoted1Color)', + quoted2Color: 'var(--quoted2Color)', + quoted3Color: 'var(--quoted3Color)', + + scriptSwitchUnderlineColor: 'var(--scriptSwitchUnderlineColor)', + + linkColor: 'var(--linkColor)', + linkVisitedColor: 'var(--linkVisitedColor)', } const styleClasses = { // For quoted text, with multiple levels of indentations. quoted: [ - css('quoted1', {color: ['#03828f', '#71f2ff']}), // red - css('quoted2', {color: ['#c7445c', 'rgb(236, 76, 76)']}), // green - css('quoted3', {color: ['#417c10', 'rgb(115, 230, 20)']}), // blue + css('quoted1', {color: styles.quoted1Color}), + css('quoted2', {color: styles.quoted2Color}), + css('quoted3', {color: styles.quoted3Color}), ], // When text switches between unicode scripts. - scriptswitch: css('scriptswitch', {textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)']}), + scriptswitch: css('scriptswitch', {textDecoration: 'underline 2px', textDecorationColor: styles.scriptSwitchUnderlineColor}), textMild: css('textMild', {color: styles.colorMild}), // For keywords (also known as flags/labels/tags) on messages. keyword: css('keyword', {padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor}), @@ -138,15 +209,15 @@ ensureCSS('.msgHeaders td', {wordBreak: 'break-word'}) // Prevent horizontal scr ensureCSS('.keyword.keywordCollapsed', {opacity: .75}), // Generic styling. +ensureCSS('html', {backgroundColor: 'var(--backgroundColor)', color: 'var(--color)'}) ensureCSS('*', {fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box'}) ensureCSS('.mono, .mono *', {fontFamily: "'ubuntu mono', monospace"}) ensureCSS('table td, table th', {padding: '.15em .25em'}) ensureCSS('.pad', {padding: '.5em'}) ensureCSS('iframe', {border: 0}) ensureCSS('img, embed, video, iframe', {backgroundColor: 'white', color: 'black'}) -ensureCSS(':root', {backgroundColor: styles.backgroundColor, color: styles.color}) -ensureCSS('a', {color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)']}) -ensureCSS('a:visited', {color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)']}) +ensureCSS('a', {color: styles.linkColor}) +ensureCSS('a:visited', {color: styles.linkVisitedColor}) // For message view with multiple inline elements (often a single text and multiple messages). ensureCSS('.textmulti > *:nth-child(even)', {backgroundColor: ['#f4f4f4', '#141414']}) diff --git a/webmail/msg.html b/webmail/msg.html index d50aa74..2fb6f95 100644 --- a/webmail/msg.html +++ b/webmail/msg.html @@ -4,10 +4,17 @@ Message +
Loading...
+ + diff --git a/webmail/msg.js b/webmail/msg.js index 076e4e2..071949f 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -1055,10 +1055,10 @@ var api; // instances of a class. // We keep the default/regular styles and dark-mode styles in separate stylesheets. const cssStyle = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyle); +document.head.prepend(cssStyle); const styleSheet = cssStyle.sheet; const cssStyleDark = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyleDark); +document.head.prepend(cssStyleDark); const styleSheetDark = cssStyleDark.sheet; styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); const darkModeRule = styleSheetDark.cssRules[0]; @@ -1085,8 +1085,11 @@ const ensureCSS = (selector, styles, important) => { let darkst; for (let [k, v] of Object.entries(styles)) { // We've kept the camel-case in our code which we had from when we did "st[prop] = - // value". It is more convenient as object keys. So convert to kebab-case. - k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + // value". It is more convenient as object keys. So convert to kebab-case, but only + // if this is not a css property. + if (!k.startsWith('--')) { + k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + } if (Array.isArray(v)) { if (v.length !== 2) { throw new Error('2 elements required for light/dark mode style, got ' + v.length); @@ -1112,54 +1115,105 @@ const css = (className, styles, important) => { }; // todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. // todo: add the standard padding and border-radius, perhaps more. -// todo: could make some of these {prop: value} objects and pass them directly to css() -const styles = { - color: ['black', '#ddd'], - colorMild: ['#555', '#bbb'], - colorMilder: ['#666', '#aaa'], - backgroundColor: ['white', '#222'], - backgroundColorMild: ['#f8f8f8', '#080808'], - backgroundColorMilder: ['#999', '#777'], - borderColor: ['#ccc', '#333'], - mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], - msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], - boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], - buttonBackground: ['#eee', '#222'], - buttonBorderColor: ['#888', '#666'], - buttonHoverBackground: ['#ddd', '#333'], - overlayOpaqueBackgroundColor: ['#eee', '#011'], - overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], - popupColor: ['black', 'white'], - popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], - popupBorderColor: ['#ccc', '#555'], - highlightBackground: ['gold', '#a70167'], - highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], - highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], - mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], - mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], - msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], - msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], - msgItemFocusBorderColor: ['#2685ff', '#2685ff'], - buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], - buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], - warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], - successBackground: ['#d2f791', '#1fa204'], - emphasisBackground: ['#666', '#aaa'], +// We define css variables, making them easy to override. +ensureCSS(':root', { + '--color': ['black', '#ddd'], + '--colorMild': ['#555', '#bbb'], + '--colorMilder': ['#666', '#aaa'], + '--backgroundColor': ['white', '#222'], + '--backgroundColorMild': ['#f8f8f8', '#080808'], + '--backgroundColorMilder': ['#999', '#777'], + '--borderColor': ['#ccc', '#333'], + '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'], + '--msglistBackgroundColor': ['#f5ffff', '#04130d'], + '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + '--buttonBackground': ['#eee', '#222'], + '--buttonBorderColor': ['#888', '#666'], + '--buttonHoverBackground': ['#ddd', '#333'], + '--overlayOpaqueBackgroundColor': ['#eee', '#011'], + '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + '--popupColor': ['black', 'white'], + '--popupBackgroundColor': ['white', '#313233'], + '--popupBorderColor': ['#ccc', '#555'], + '--highlightBackground': ['gold', '#a70167'], + '--highlightBorderColor': ['#8c7600', '#fd1fa7'], + '--highlightBackgroundHover': ['#ffbd21', '#710447'], + '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'], + '--mailboxHoverBackgroundColor': ['#eee', '#421f15'], + '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'], + '--msgItemHoverBackgroundColor': ['#eee', '#073348'], + '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'], + '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'], + '--buttonTristateOffBackground': ['#ffb192', '#bf410f'], + '--warningBackgroundColor': ['#ffca91', '#a85700'], + '--successBackground': ['#d2f791', '#1fa204'], + '--emphasisBackground': ['#666', '#aaa'], // For authentication/security results. - underlineGreen: '#50c40f', - underlineRed: '#e15d1c', - underlineBlue: '#09f', - underlineGrey: '#888', + '--underlineGreen': '#50c40f', + '--underlineRed': '#e15d1c', + '--underlineBlue': '#09f', + '--underlineGrey': '#888', + '--quoted1Color': ['#03828f', '#71f2ff'], + '--quoted2Color': ['#c7445c', '#ec4c4c'], + '--quoted3Color': ['#417c10', '#73e614'], + '--scriptSwitchUnderlineColor': ['#dca053', '#e88f1e'], + '--linkColor': ['#096bc2', '#63b6ff'], + '--linkVisitedColor': ['#0704c1', '#c763ff'], +}); +// Typed way to reference a css variables. Kept from before used variables. +const styles = { + color: 'var(--color)', + colorMild: 'var(--colorMild)', + colorMilder: 'var(--colorMilder)', + backgroundColor: 'var(--backgroundColor)', + backgroundColorMild: 'var(--backgroundColorMild)', + backgroundColorMilder: 'var(--backgroundColorMilder)', + borderColor: 'var(--borderColor)', + mailboxesTopBackgroundColor: 'var(--mailboxesTopBackgroundColor)', + msglistBackgroundColor: 'var(--msglistBackgroundColor)', + boxShadow: 'var(--boxShadow)', + buttonBackground: 'var(--buttonBackground)', + buttonBorderColor: 'var(--buttonBorderColor)', + buttonHoverBackground: 'var(--buttonHoverBackground)', + overlayOpaqueBackgroundColor: 'var(--overlayOpaqueBackgroundColor)', + overlayBackgroundColor: 'var(--overlayBackgroundColor)', + popupColor: 'var(--popupColor)', + popupBackgroundColor: 'var(--popupBackgroundColor)', + popupBorderColor: 'var(--popupBorderColor)', + highlightBackground: 'var(--highlightBackground)', + highlightBorderColor: 'var(--highlightBorderColor)', + highlightBackgroundHover: 'var(--highlightBackgroundHover)', + mailboxActiveBackground: 'var(--mailboxActiveBackground)', + mailboxHoverBackgroundColor: 'var(--mailboxHoverBackgroundColor)', + msgItemActiveBackground: 'var(--msgItemActiveBackground)', + msgItemHoverBackgroundColor: 'var(--msgItemHoverBackgroundColor)', + msgItemFocusBorderColor: 'var(--msgItemFocusBorderColor)', + buttonTristateOnBackground: 'var(--buttonTristateOnBackground)', + buttonTristateOffBackground: 'var(--buttonTristateOffBackground)', + warningBackgroundColor: 'var(--warningBackgroundColor)', + successBackground: 'var(--successBackground)', + emphasisBackground: 'var(--emphasisBackground)', + // For authentication/security results. + underlineGreen: 'var(--underlineGreen)', + underlineRed: 'var(--underlineRed)', + underlineBlue: 'var(--underlineBlue)', + underlineGrey: 'var(--underlineGrey)', + quoted1Color: 'var(--quoted1Color)', + quoted2Color: 'var(--quoted2Color)', + quoted3Color: 'var(--quoted3Color)', + scriptSwitchUnderlineColor: 'var(--scriptSwitchUnderlineColor)', + linkColor: 'var(--linkColor)', + linkVisitedColor: 'var(--linkVisitedColor)', }; const styleClasses = { // For quoted text, with multiple levels of indentations. quoted: [ - css('quoted1', { color: ['#03828f', '#71f2ff'] }), - css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), - css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue + css('quoted1', { color: styles.quoted1Color }), + css('quoted2', { color: styles.quoted2Color }), + css('quoted3', { color: styles.quoted3Color }), ], // When text switches between unicode scripts. - scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)'] }), + scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: styles.scriptSwitchUnderlineColor }), textMild: css('textMild', { color: styles.colorMild }), // For keywords (also known as flags/labels/tags) on messages. keyword: css('keyword', { padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor }), @@ -1168,15 +1222,15 @@ const styleClasses = { ensureCSS('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values. ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), // Generic styling. - ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); + ensureCSS('html', { backgroundColor: 'var(--backgroundColor)', color: 'var(--color)' }); +ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); ensureCSS('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" }); ensureCSS('table td, table th', { padding: '.15em .25em' }); ensureCSS('.pad', { padding: '.5em' }); ensureCSS('iframe', { border: 0 }); ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); -ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); -ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); -ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] }); +ensureCSS('a', { color: styles.linkColor }); +ensureCSS('a:visited', { color: styles.linkVisitedColor }); // For message view with multiple inline elements (often a single text and multiple messages). ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); @@ -1417,13 +1471,17 @@ const init = () => { iframepath += '?sameorigin=true'; let iframe; const page = document.getElementById('page'); - dom._kids(page, dom.div(css('msgMeta', { backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor }), msgheaderview, msgattachmentview), iframe = dom.iframe(attr.title('Message body.'), attr.src(iframepath), css('msgIframe', { width: '100%', height: '100%' }), function load() { + const root = dom.div(dom.div(css('msgMeta', { backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor }), msgheaderview, msgattachmentview), iframe = dom.iframe(attr.title('Message body.'), attr.src(iframepath), css('msgIframe', { width: '100%', height: '100%' }), function load() { // Note: we load the iframe content specifically in a way that fires the load event only when the content is fully rendered. iframe.style.height = iframe.contentDocument.documentElement.scrollHeight + 'px'; if (window.location.hash === '#print') { window.print(); } })); + if (typeof moxBeforeDisplay !== 'undefined') { + moxBeforeDisplay(root); + } + dom._kids(page, root); }; try { init(); diff --git a/webmail/msg.ts b/webmail/msg.ts index b690969..46454bb 100644 --- a/webmail/msg.ts +++ b/webmail/msg.ts @@ -2,6 +2,8 @@ // Loaded from synchronous javascript. declare let messageItem: api.MessageItem +// From customization script. +declare let moxBeforeDisplay: (root: HTMLElement) => void const init = () => { const mi = api.parser.MessageItem(messageItem) @@ -40,7 +42,7 @@ const init = () => { let iframe: HTMLIFrameElement const page = document.getElementById('page')! - dom._kids(page, + const root = dom.div( dom.div( css('msgMeta', {backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor}), msgheaderview, @@ -59,6 +61,10 @@ const init = () => { }, ) ) + if (typeof moxBeforeDisplay !== 'undefined') { + moxBeforeDisplay(root) + } + dom._kids(page, root) } try { diff --git a/webmail/text.html b/webmail/text.html index 5121d37..2129b09 100644 --- a/webmail/text.html +++ b/webmail/text.html @@ -3,10 +3,17 @@ +
Loading...
+ + diff --git a/webmail/text.js b/webmail/text.js index 73496da..2bc0569 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -1055,10 +1055,10 @@ var api; // instances of a class. // We keep the default/regular styles and dark-mode styles in separate stylesheets. const cssStyle = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyle); +document.head.prepend(cssStyle); const styleSheet = cssStyle.sheet; const cssStyleDark = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyleDark); +document.head.prepend(cssStyleDark); const styleSheetDark = cssStyleDark.sheet; styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); const darkModeRule = styleSheetDark.cssRules[0]; @@ -1085,8 +1085,11 @@ const ensureCSS = (selector, styles, important) => { let darkst; for (let [k, v] of Object.entries(styles)) { // We've kept the camel-case in our code which we had from when we did "st[prop] = - // value". It is more convenient as object keys. So convert to kebab-case. - k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + // value". It is more convenient as object keys. So convert to kebab-case, but only + // if this is not a css property. + if (!k.startsWith('--')) { + k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + } if (Array.isArray(v)) { if (v.length !== 2) { throw new Error('2 elements required for light/dark mode style, got ' + v.length); @@ -1112,54 +1115,105 @@ const css = (className, styles, important) => { }; // todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. // todo: add the standard padding and border-radius, perhaps more. -// todo: could make some of these {prop: value} objects and pass them directly to css() -const styles = { - color: ['black', '#ddd'], - colorMild: ['#555', '#bbb'], - colorMilder: ['#666', '#aaa'], - backgroundColor: ['white', '#222'], - backgroundColorMild: ['#f8f8f8', '#080808'], - backgroundColorMilder: ['#999', '#777'], - borderColor: ['#ccc', '#333'], - mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], - msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], - boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], - buttonBackground: ['#eee', '#222'], - buttonBorderColor: ['#888', '#666'], - buttonHoverBackground: ['#ddd', '#333'], - overlayOpaqueBackgroundColor: ['#eee', '#011'], - overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], - popupColor: ['black', 'white'], - popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], - popupBorderColor: ['#ccc', '#555'], - highlightBackground: ['gold', '#a70167'], - highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], - highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], - mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], - mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], - msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], - msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], - msgItemFocusBorderColor: ['#2685ff', '#2685ff'], - buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], - buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], - warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], - successBackground: ['#d2f791', '#1fa204'], - emphasisBackground: ['#666', '#aaa'], +// We define css variables, making them easy to override. +ensureCSS(':root', { + '--color': ['black', '#ddd'], + '--colorMild': ['#555', '#bbb'], + '--colorMilder': ['#666', '#aaa'], + '--backgroundColor': ['white', '#222'], + '--backgroundColorMild': ['#f8f8f8', '#080808'], + '--backgroundColorMilder': ['#999', '#777'], + '--borderColor': ['#ccc', '#333'], + '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'], + '--msglistBackgroundColor': ['#f5ffff', '#04130d'], + '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + '--buttonBackground': ['#eee', '#222'], + '--buttonBorderColor': ['#888', '#666'], + '--buttonHoverBackground': ['#ddd', '#333'], + '--overlayOpaqueBackgroundColor': ['#eee', '#011'], + '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + '--popupColor': ['black', 'white'], + '--popupBackgroundColor': ['white', '#313233'], + '--popupBorderColor': ['#ccc', '#555'], + '--highlightBackground': ['gold', '#a70167'], + '--highlightBorderColor': ['#8c7600', '#fd1fa7'], + '--highlightBackgroundHover': ['#ffbd21', '#710447'], + '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'], + '--mailboxHoverBackgroundColor': ['#eee', '#421f15'], + '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'], + '--msgItemHoverBackgroundColor': ['#eee', '#073348'], + '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'], + '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'], + '--buttonTristateOffBackground': ['#ffb192', '#bf410f'], + '--warningBackgroundColor': ['#ffca91', '#a85700'], + '--successBackground': ['#d2f791', '#1fa204'], + '--emphasisBackground': ['#666', '#aaa'], // For authentication/security results. - underlineGreen: '#50c40f', - underlineRed: '#e15d1c', - underlineBlue: '#09f', - underlineGrey: '#888', + '--underlineGreen': '#50c40f', + '--underlineRed': '#e15d1c', + '--underlineBlue': '#09f', + '--underlineGrey': '#888', + '--quoted1Color': ['#03828f', '#71f2ff'], + '--quoted2Color': ['#c7445c', '#ec4c4c'], + '--quoted3Color': ['#417c10', '#73e614'], + '--scriptSwitchUnderlineColor': ['#dca053', '#e88f1e'], + '--linkColor': ['#096bc2', '#63b6ff'], + '--linkVisitedColor': ['#0704c1', '#c763ff'], +}); +// Typed way to reference a css variables. Kept from before used variables. +const styles = { + color: 'var(--color)', + colorMild: 'var(--colorMild)', + colorMilder: 'var(--colorMilder)', + backgroundColor: 'var(--backgroundColor)', + backgroundColorMild: 'var(--backgroundColorMild)', + backgroundColorMilder: 'var(--backgroundColorMilder)', + borderColor: 'var(--borderColor)', + mailboxesTopBackgroundColor: 'var(--mailboxesTopBackgroundColor)', + msglistBackgroundColor: 'var(--msglistBackgroundColor)', + boxShadow: 'var(--boxShadow)', + buttonBackground: 'var(--buttonBackground)', + buttonBorderColor: 'var(--buttonBorderColor)', + buttonHoverBackground: 'var(--buttonHoverBackground)', + overlayOpaqueBackgroundColor: 'var(--overlayOpaqueBackgroundColor)', + overlayBackgroundColor: 'var(--overlayBackgroundColor)', + popupColor: 'var(--popupColor)', + popupBackgroundColor: 'var(--popupBackgroundColor)', + popupBorderColor: 'var(--popupBorderColor)', + highlightBackground: 'var(--highlightBackground)', + highlightBorderColor: 'var(--highlightBorderColor)', + highlightBackgroundHover: 'var(--highlightBackgroundHover)', + mailboxActiveBackground: 'var(--mailboxActiveBackground)', + mailboxHoverBackgroundColor: 'var(--mailboxHoverBackgroundColor)', + msgItemActiveBackground: 'var(--msgItemActiveBackground)', + msgItemHoverBackgroundColor: 'var(--msgItemHoverBackgroundColor)', + msgItemFocusBorderColor: 'var(--msgItemFocusBorderColor)', + buttonTristateOnBackground: 'var(--buttonTristateOnBackground)', + buttonTristateOffBackground: 'var(--buttonTristateOffBackground)', + warningBackgroundColor: 'var(--warningBackgroundColor)', + successBackground: 'var(--successBackground)', + emphasisBackground: 'var(--emphasisBackground)', + // For authentication/security results. + underlineGreen: 'var(--underlineGreen)', + underlineRed: 'var(--underlineRed)', + underlineBlue: 'var(--underlineBlue)', + underlineGrey: 'var(--underlineGrey)', + quoted1Color: 'var(--quoted1Color)', + quoted2Color: 'var(--quoted2Color)', + quoted3Color: 'var(--quoted3Color)', + scriptSwitchUnderlineColor: 'var(--scriptSwitchUnderlineColor)', + linkColor: 'var(--linkColor)', + linkVisitedColor: 'var(--linkVisitedColor)', }; const styleClasses = { // For quoted text, with multiple levels of indentations. quoted: [ - css('quoted1', { color: ['#03828f', '#71f2ff'] }), - css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), - css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue + css('quoted1', { color: styles.quoted1Color }), + css('quoted2', { color: styles.quoted2Color }), + css('quoted3', { color: styles.quoted3Color }), ], // When text switches between unicode scripts. - scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)'] }), + scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: styles.scriptSwitchUnderlineColor }), textMild: css('textMild', { color: styles.colorMild }), // For keywords (also known as flags/labels/tags) on messages. keyword: css('keyword', { padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor }), @@ -1168,15 +1222,15 @@ const styleClasses = { ensureCSS('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values. ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), // Generic styling. - ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); + ensureCSS('html', { backgroundColor: 'var(--backgroundColor)', color: 'var(--color)' }); +ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); ensureCSS('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" }); ensureCSS('table td, table th', { padding: '.15em .25em' }); ensureCSS('.pad', { padding: '.5em' }); ensureCSS('iframe', { border: 0 }); ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); -ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); -ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); -ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] }); +ensureCSS('a', { color: styles.linkColor }); +ensureCSS('a:visited', { color: styles.linkVisitedColor }); // For message view with multiple inline elements (often a single text and multiple messages). ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); @@ -1392,10 +1446,14 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd const init = async () => { const pm = api.parser.ParsedMessage(parsedMessage); const mi = api.parser.MessageItem(messageItem); - dom._kids(document.body, dom.div(dom._class('pad', 'mono', 'textmulti'), css('msgTextPreformatted', { whiteSpace: 'pre-wrap' }), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), (mi.Attachments || []).filter(f => isImage(f)).map(f => { + const root = dom.div(dom.div(dom._class('pad', 'mono', 'textmulti'), css('msgTextPreformatted', { whiteSpace: 'pre-wrap' }), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), (mi.Attachments || []).filter(f => isImage(f)).map(f => { const pathStr = [0].concat(f.Path || []).join('.'); return dom.div(dom.div(css('msgAttachment', { flexGrow: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', maxHeight: 'calc(100% - 50px)' }), dom.img(attr.src('view/' + pathStr), attr.title(f.Filename), css('msgAttachmentImage', { maxWidth: '100%', maxHeight: '100%', boxShadow: styles.boxShadow })))); }))); + if (typeof moxBeforeDisplay !== 'undefined') { + moxBeforeDisplay(root); + } + dom._kids(document.body, root); }; init() .catch((err) => { diff --git a/webmail/text.ts b/webmail/text.ts index 74798c6..00f494d 100644 --- a/webmail/text.ts +++ b/webmail/text.ts @@ -3,11 +3,13 @@ // Loaded from synchronous javascript. declare let messageItem: api.MessageItem declare let parsedMessage: api.ParsedMessage +// From customization script. +declare let moxBeforeDisplay: (root: HTMLElement) => void const init = async () => { const pm = api.parser.ParsedMessage(parsedMessage) const mi = api.parser.MessageItem(messageItem) - dom._kids(document.body, + const root = dom.div( dom.div(dom._class('pad', 'mono', 'textmulti'), css('msgTextPreformatted', {whiteSpace: 'pre-wrap'}), (pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), @@ -26,6 +28,10 @@ const init = async () => { }), ) ) + if (typeof moxBeforeDisplay !== 'undefined') { + moxBeforeDisplay(root) + } + dom._kids(document.body, root) } init() diff --git a/webmail/webmail.go b/webmail/webmail.go index c960831..8cfa3d7 100644 --- a/webmail/webmail.go +++ b/webmail/webmail.go @@ -12,6 +12,7 @@ import ( "errors" "fmt" "io" + "io/fs" "log/slog" "mime" "net/http" @@ -21,6 +22,7 @@ import ( "runtime/debug" "strconv" "strings" + "time" _ "embed" @@ -147,27 +149,62 @@ func xdbread(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) { } var webmailFile = &mox.WebappFile{ - HTML: webmailHTML, - JS: webmailJS, - HTMLPath: filepath.FromSlash("webmail/webmail.html"), - JSPath: filepath.FromSlash("webmail/webmail.js"), + HTML: webmailHTML, + JS: webmailJS, + HTMLPath: filepath.FromSlash("webmail/webmail.html"), + JSPath: filepath.FromSlash("webmail/webmail.js"), + CustomStem: "webmail", } -// Serve content, either from a file, or return the fallback data. Caller -// should already have set the content-type. We use this to return a file from -// the local file system (during development), or embedded in the binary (when -// deployed). -func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request, path string, fallback []byte) { +func customization() (css, js []byte, err error) { + if css, err = os.ReadFile(mox.ConfigDirPath("webmail.css")); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, nil, err + } + if js, err = os.ReadFile(mox.ConfigDirPath("webmail.js")); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, nil, err + } + css = append([]byte("/* Custom CSS by admin from $configdir/webmail.css: */\n"), css...) + js = append([]byte("// Custom JS by admin from $configdir/webmail.js:\n"), js...) + js = append(js, '\n') + return css, js, nil +} + +// Serve HTML content, either from a file, or return the fallback data. If +// customize is set, css/js is inserted if configured. Caller should already have +// set the content-type. We use this to return a file from the local file system +// (during development), or embedded in the binary (when deployed). +func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request, path string, fallback []byte, customize bool) { + serve := func(mtime time.Time, rd io.ReadSeeker) { + if customize { + buf, err := io.ReadAll(rd) + if err != nil { + log.Errorx("reading content to customize", err) + http.Error(w, "500 - internal server error - reading content to customize", http.StatusInternalServerError) + return + } + customCSS, customJS, err := customization() + if err != nil { + log.Errorx("reading customizations", err) + http.Error(w, "500 - internal server error - reading customizations", http.StatusInternalServerError) + return + } + buf = bytes.Replace(buf, []byte("/* css placeholder */"), customCSS, 1) + buf = bytes.Replace(buf, []byte("/* js placeholder */"), customJS, 1) + rd = bytes.NewReader(buf) + } + http.ServeContent(w, r, "", mtime, rd) + } + f, err := os.Open(path) if err == nil { defer f.Close() st, err := f.Stat() if err == nil { - http.ServeContent(w, r, "", st.ModTime(), f) + serve(st.ModTime(), f) return } } - http.ServeContent(w, r, "", mox.FallbackMtime(log), bytes.NewReader(fallback)) + serve(mox.FallbackMtime(log), bytes.NewReader(fallback)) } func init() { @@ -261,7 +298,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt } w.Header().Set("Content-Type", "application/javascript; charset=utf-8") - serveContentFallback(log, w, r, path, fallback) + serveContentFallback(log, w, r, path, fallback, false) return } @@ -621,7 +658,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt path := filepath.FromSlash("webmail/msg.html") fallback := webmailmsgHTML - serveContentFallback(log, w, r, path, fallback) + serveContentFallback(log, w, r, path, fallback, true) case len(t) == 2 && t[1] == "parsedmessage.js": // Used by msg.html, for the msg* endpoints, for the data needed to show all data @@ -689,7 +726,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt // from disk. path := filepath.FromSlash("webmail/text.html") fallback := webmailtextHTML - serveContentFallback(log, w, r, path, fallback) + serveContentFallback(log, w, r, path, fallback, true) case len(t) == 2 && (t[1] == "html" || t[1] == "htmlexternal"): // Returns the first HTML part, with "cid:" URIs replaced with an inlined datauri diff --git a/webmail/webmail.html b/webmail/webmail.html index 24669e7..3819cc7 100644 --- a/webmail/webmail.html +++ b/webmail/webmail.html @@ -14,10 +14,14 @@ fieldset { border: 0; } @keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } } @keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } } .invert { filter: invert(100%); } + +/* css placeholder */
Loading...
- + diff --git a/webmail/webmail.js b/webmail/webmail.js index 863864e..733b139 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -1055,10 +1055,10 @@ var api; // instances of a class. // We keep the default/regular styles and dark-mode styles in separate stylesheets. const cssStyle = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyle); +document.head.prepend(cssStyle); const styleSheet = cssStyle.sheet; const cssStyleDark = dom.style(attr.type('text/css')); -document.head.appendChild(cssStyleDark); +document.head.prepend(cssStyleDark); const styleSheetDark = cssStyleDark.sheet; styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); const darkModeRule = styleSheetDark.cssRules[0]; @@ -1085,8 +1085,11 @@ const ensureCSS = (selector, styles, important) => { let darkst; for (let [k, v] of Object.entries(styles)) { // We've kept the camel-case in our code which we had from when we did "st[prop] = - // value". It is more convenient as object keys. So convert to kebab-case. - k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + // value". It is more convenient as object keys. So convert to kebab-case, but only + // if this is not a css property. + if (!k.startsWith('--')) { + k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); + } if (Array.isArray(v)) { if (v.length !== 2) { throw new Error('2 elements required for light/dark mode style, got ' + v.length); @@ -1112,54 +1115,105 @@ const css = (className, styles, important) => { }; // todo: reduce number of colors. hopefully we can derive some colors from a few base colors (making them brighter/darker, or shifting hue, etc). then make them configurable through settings. // todo: add the standard padding and border-radius, perhaps more. -// todo: could make some of these {prop: value} objects and pass them directly to css() -const styles = { - color: ['black', '#ddd'], - colorMild: ['#555', '#bbb'], - colorMilder: ['#666', '#aaa'], - backgroundColor: ['white', '#222'], - backgroundColorMild: ['#f8f8f8', '#080808'], - backgroundColorMilder: ['#999', '#777'], - borderColor: ['#ccc', '#333'], - mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], - msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], - boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], - buttonBackground: ['#eee', '#222'], - buttonBorderColor: ['#888', '#666'], - buttonHoverBackground: ['#ddd', '#333'], - overlayOpaqueBackgroundColor: ['#eee', '#011'], - overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], - popupColor: ['black', 'white'], - popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], - popupBorderColor: ['#ccc', '#555'], - highlightBackground: ['gold', '#a70167'], - highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], - highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], - mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], - mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], - msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], - msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], - msgItemFocusBorderColor: ['#2685ff', '#2685ff'], - buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], - buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], - warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], - successBackground: ['#d2f791', '#1fa204'], - emphasisBackground: ['#666', '#aaa'], +// We define css variables, making them easy to override. +ensureCSS(':root', { + '--color': ['black', '#ddd'], + '--colorMild': ['#555', '#bbb'], + '--colorMilder': ['#666', '#aaa'], + '--backgroundColor': ['white', '#222'], + '--backgroundColorMild': ['#f8f8f8', '#080808'], + '--backgroundColorMilder': ['#999', '#777'], + '--borderColor': ['#ccc', '#333'], + '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'], + '--msglistBackgroundColor': ['#f5ffff', '#04130d'], + '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], + '--buttonBackground': ['#eee', '#222'], + '--buttonBorderColor': ['#888', '#666'], + '--buttonHoverBackground': ['#ddd', '#333'], + '--overlayOpaqueBackgroundColor': ['#eee', '#011'], + '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], + '--popupColor': ['black', 'white'], + '--popupBackgroundColor': ['white', '#313233'], + '--popupBorderColor': ['#ccc', '#555'], + '--highlightBackground': ['gold', '#a70167'], + '--highlightBorderColor': ['#8c7600', '#fd1fa7'], + '--highlightBackgroundHover': ['#ffbd21', '#710447'], + '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'], + '--mailboxHoverBackgroundColor': ['#eee', '#421f15'], + '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'], + '--msgItemHoverBackgroundColor': ['#eee', '#073348'], + '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'], + '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'], + '--buttonTristateOffBackground': ['#ffb192', '#bf410f'], + '--warningBackgroundColor': ['#ffca91', '#a85700'], + '--successBackground': ['#d2f791', '#1fa204'], + '--emphasisBackground': ['#666', '#aaa'], // For authentication/security results. - underlineGreen: '#50c40f', - underlineRed: '#e15d1c', - underlineBlue: '#09f', - underlineGrey: '#888', + '--underlineGreen': '#50c40f', + '--underlineRed': '#e15d1c', + '--underlineBlue': '#09f', + '--underlineGrey': '#888', + '--quoted1Color': ['#03828f', '#71f2ff'], + '--quoted2Color': ['#c7445c', '#ec4c4c'], + '--quoted3Color': ['#417c10', '#73e614'], + '--scriptSwitchUnderlineColor': ['#dca053', '#e88f1e'], + '--linkColor': ['#096bc2', '#63b6ff'], + '--linkVisitedColor': ['#0704c1', '#c763ff'], +}); +// Typed way to reference a css variables. Kept from before used variables. +const styles = { + color: 'var(--color)', + colorMild: 'var(--colorMild)', + colorMilder: 'var(--colorMilder)', + backgroundColor: 'var(--backgroundColor)', + backgroundColorMild: 'var(--backgroundColorMild)', + backgroundColorMilder: 'var(--backgroundColorMilder)', + borderColor: 'var(--borderColor)', + mailboxesTopBackgroundColor: 'var(--mailboxesTopBackgroundColor)', + msglistBackgroundColor: 'var(--msglistBackgroundColor)', + boxShadow: 'var(--boxShadow)', + buttonBackground: 'var(--buttonBackground)', + buttonBorderColor: 'var(--buttonBorderColor)', + buttonHoverBackground: 'var(--buttonHoverBackground)', + overlayOpaqueBackgroundColor: 'var(--overlayOpaqueBackgroundColor)', + overlayBackgroundColor: 'var(--overlayBackgroundColor)', + popupColor: 'var(--popupColor)', + popupBackgroundColor: 'var(--popupBackgroundColor)', + popupBorderColor: 'var(--popupBorderColor)', + highlightBackground: 'var(--highlightBackground)', + highlightBorderColor: 'var(--highlightBorderColor)', + highlightBackgroundHover: 'var(--highlightBackgroundHover)', + mailboxActiveBackground: 'var(--mailboxActiveBackground)', + mailboxHoverBackgroundColor: 'var(--mailboxHoverBackgroundColor)', + msgItemActiveBackground: 'var(--msgItemActiveBackground)', + msgItemHoverBackgroundColor: 'var(--msgItemHoverBackgroundColor)', + msgItemFocusBorderColor: 'var(--msgItemFocusBorderColor)', + buttonTristateOnBackground: 'var(--buttonTristateOnBackground)', + buttonTristateOffBackground: 'var(--buttonTristateOffBackground)', + warningBackgroundColor: 'var(--warningBackgroundColor)', + successBackground: 'var(--successBackground)', + emphasisBackground: 'var(--emphasisBackground)', + // For authentication/security results. + underlineGreen: 'var(--underlineGreen)', + underlineRed: 'var(--underlineRed)', + underlineBlue: 'var(--underlineBlue)', + underlineGrey: 'var(--underlineGrey)', + quoted1Color: 'var(--quoted1Color)', + quoted2Color: 'var(--quoted2Color)', + quoted3Color: 'var(--quoted3Color)', + scriptSwitchUnderlineColor: 'var(--scriptSwitchUnderlineColor)', + linkColor: 'var(--linkColor)', + linkVisitedColor: 'var(--linkVisitedColor)', }; const styleClasses = { // For quoted text, with multiple levels of indentations. quoted: [ - css('quoted1', { color: ['#03828f', '#71f2ff'] }), - css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), - css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue + css('quoted1', { color: styles.quoted1Color }), + css('quoted2', { color: styles.quoted2Color }), + css('quoted3', { color: styles.quoted3Color }), ], // When text switches between unicode scripts. - scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: ['#dca053', 'rgb(232, 143, 30)'] }), + scriptswitch: css('scriptswitch', { textDecoration: 'underline 2px', textDecorationColor: styles.scriptSwitchUnderlineColor }), textMild: css('textMild', { color: styles.colorMild }), // For keywords (also known as flags/labels/tags) on messages. keyword: css('keyword', { padding: '0 .15em', borderRadius: '.15em', fontWeight: 'normal', fontSize: '.9em', margin: '0 .15em', whiteSpace: 'nowrap', background: styles.highlightBackground, color: styles.color, border: '1px solid', borderColor: styles.highlightBorderColor }), @@ -1168,15 +1222,15 @@ const styleClasses = { ensureCSS('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values. ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), // Generic styling. - ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); + ensureCSS('html', { backgroundColor: 'var(--backgroundColor)', color: 'var(--color)' }); +ensureCSS('*', { fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box' }); ensureCSS('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" }); ensureCSS('table td, table th', { padding: '.15em .25em' }); ensureCSS('.pad', { padding: '.5em' }); ensureCSS('iframe', { border: 0 }); ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); -ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); -ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); -ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] }); +ensureCSS('a', { color: styles.linkColor }); +ensureCSS('a:visited', { color: styles.linkVisitedColor }); // For message view with multiple inline elements (often a single text and multiple messages). ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); @@ -6781,6 +6835,9 @@ const init = async () => { else { selectLayout(layoutElem.value); } + if (window.moxBeforeDisplay) { + moxBeforeDisplay(webmailroot); + } dom._kids(page, webmailroot); checkMsglistWidth(); window.addEventListener('resize', function () { diff --git a/webmail/webmail.ts b/webmail/webmail.ts index 0798c14..ea265a8 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -133,6 +133,8 @@ declare let page: HTMLElement declare let moxversion: string declare let moxgoos: string declare let moxgoarch: string +// From customization script. +declare let moxBeforeDisplay: (root: HTMLElement) => void // All logging goes through log() instead of console.log, except "should not happen" logging. let log: (...args: any[]) => void = () => {} @@ -7057,6 +7059,9 @@ const init = async () => { } else { selectLayout(layoutElem.value) } + if ((window as any).moxBeforeDisplay) { + moxBeforeDisplay(webmailroot) + } dom._kids(page, webmailroot) checkMsglistWidth()