add ability to include custom css & js in web interface (webmail, webaccount, webadmin), and use css variables in webmail for easier customization

if files {webmail,webaccount,webadmin}.{css,js} exist in the configdir (where
the mox.conf file lives), their contents are included in the web apps.

the webmail now uses css variables, mostly for colors. so you can write a
custom webmail.css that changes the variables, e.g.:

	:root {
		--color: blue
	}

you can also look at css class names and override their styles.

in the future, we may want to make some css variables configurable in the
per-user settings in the webmail. should reduce the number of variables first.

any custom javascript is loaded first. if it defines a global function
"moxBeforeDisplay", that is called each time a page loads (after
authentication) with the DOM element of the page content as parameter. the
webmail is a single persistent page. this can be used to make some changes to
the DOM, e.g. inserting some elements. we'll have to see how well this works in
practice. perhaps some patterns emerge (e.g. adding a logo), and we can make
those use-cases easier to achieve.

helps partially with issue #114, and based on questions from laura-lilly on
matrix.
This commit is contained in:
Mechiel Lukkien 2024-11-29 10:17:07 +01:00
parent 9e8c8ca583
commit 96d86ad6f1
No known key found for this signature in database
20 changed files with 838 additions and 418 deletions

View file

@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@ -25,6 +26,7 @@ import (
type WebappFile struct { type WebappFile struct {
HTML, JS []byte // Embedded html/js data. HTML, JS []byte // Embedded html/js data.
HTMLPath, JSPath string // Paths to load html/js from during development. 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 sync.Mutex
combined []byte 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) gz := AcceptsGzip(r)
var out []byte var out []byte
var mtime time.Time var mtime time.Time
var origSize int64 var origSize int64
func() { ok := func() bool {
a.Lock() a.Lock()
defer a.Unlock() defer a.Unlock()
if refreshdisk || a.combined == nil { if refreshdisk || a.combined == nil {
script := []byte(`<script>/* placeholder */</script>`) var customCSS, customJS []byte
index := bytes.Index(html, script) var err error
if index < 0 { if haveCustomCSS {
a.serverError(log, w, errors.New("script not found"), "generating combined html") customCSS, err = os.ReadFile(ConfigDirPath(a.CustomStem + ".css"))
return 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 var b bytes.Buffer
b.Write(html[:index]) b.Write(html[:cssi])
fmt.Fprintf(&b, "<script>\n// Javascript is generated from typescript, don't modify the javascript because changes will be lost.\nconst moxversion = \"%s\";\nconst moxgoos = \"%s\";\nconst moxgoarch = \"%s\";\n", moxvar.Version, runtime.GOOS, runtime.GOARCH) fmt.Fprintf(&b, "/* Custom CSS by admin from $configdir/%s.css: */\n", a.CustomStem)
b.Write(customCSS)
b.Write(html[cssi+len(cssp) : jsi])
fmt.Fprintf(&b, "// Custom JS by admin from $configdir/%s.js:\n", a.CustomStem)
b.Write(customJS)
fmt.Fprintf(&b, "\n// Javascript is generated from typescript, don't modify the javascript because changes will be lost.\nconst moxversion = \"%s\";\nconst moxgoos = \"%s\";\nconst moxgoarch = \"%s\";\n", moxvar.Version, runtime.GOOS, runtime.GOARCH)
b.Write(js) b.Write(js)
b.WriteString("\t\t</script>") b.Write(html[jsi+len(jsp):])
b.Write(html[index+len(script):])
out = b.Bytes() out = b.Bytes()
a.combined = out a.combined = out
if refreshdisk { if refreshdisk {
@ -152,7 +208,7 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri
} }
if err != nil { if err != nil {
a.serverError(log, w, err, "gzipping combined html") a.serverError(log, w, err, "gzipping combined html")
return return false
} }
a.combinedGzip = b.Bytes() a.combinedGzip = b.Bytes()
} }
@ -160,7 +216,11 @@ func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWri
out = a.combinedGzip out = a.combinedGzip
} }
mtime = a.mtime mtime = a.mtime
return true
}() }()
if !ok {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeContent(gzipInjector{w, gz, origSize}, r, "", mtime, bytes.NewReader(out)) http.ServeContent(gzipInjector{w, gz, origSize}, r, "", mtime, bytes.NewReader(out))

View file

@ -51,10 +51,11 @@ var accountHTML []byte
var accountJS []byte var accountJS []byte
var webaccountFile = &mox.WebappFile{ var webaccountFile = &mox.WebappFile{
HTML: accountHTML, HTML: accountHTML,
JS: accountJS, JS: accountJS,
HTMLPath: filepath.FromSlash("webaccount/account.html"), HTMLPath: filepath.FromSlash("webaccount/account.html"),
JSPath: filepath.FromSlash("webaccount/account.js"), JSPath: filepath.FromSlash("webaccount/account.js"),
CustomStem: "webaccount",
} }
var accountDoc = mustParseAPI("account", accountapiJSON) var accountDoc = mustParseAPI("account", accountapiJSON)

View file

@ -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 { display: inline-grid; max-width: 90vw; }
.autosize.input { grid-area: 1 / 2; } .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; } .autosize::after { content: attr(data-value); margin-right: 1em; line-height: 0; visibility: hidden; white-space: pre-wrap; overflow-x: hidden; }
/* css placeholder */
</style> </style>
</head> </head>
<body> <body>
<div id="page"><div style="padding: 1em; text-align: center">Loading...</div></div> <div id="page"><div style="padding: 1em; text-align: center">Loading...</div></div>
<script>/* placeholder */</script> <script>
/* js placeholder */
</script>
</body> </body>
</html> </html>

View file

@ -1396,7 +1396,7 @@ const index = async () => {
body.setAttribute('rows', '' + Math.min(40, (body.value.split('\n').length + 1))); body.setAttribute('rows', '' + Math.min(40, (body.value.split('\n').length + 1)));
onchange(); 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(); e.preventDefault();
await check(fullNameFieldset, client.AccountSaveFullName(fullName.value)); await check(fullNameFieldset, client.AccountSaveFullName(fullName.value));
fullName.setAttribute('value', fullName.value); fullName.setAttribute('value', fullName.value);
@ -1590,33 +1590,36 @@ const index = async () => {
mailboxPrefixHint.style.display = ''; 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. })), 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); importProgress = dom.div(style({ display: 'none' })), dom.br(), footer);
// Try to show the progress of an earlier import session. The user may have just (async () => {
// refreshed the browser. // Try to show the progress of an earlier import session. The user may have just
let importToken; // refreshed the browser.
try { let importToken;
importToken = window.sessionStorage.getItem('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';
} }
}) catch (err) {
.finally(() => { console.log('looking up ImportToken in session storage', { err });
importFieldset.disabled = false; 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 destination = async (name) => {
const [acc] = await client.Account(); const [acc] = await client.Account();
@ -1692,7 +1695,7 @@ const destination = async (name) => {
let fullName; let fullName;
let saveButton; let saveButton;
const addresses = [name, ...Object.keys(acc.Destinations || {}).filter(a => !a.startsWith('@') && a !== name)]; 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. <name\\.lists\\.example\\.org>.')), 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. <name\\.lists\\.example\\.org>.')), 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({ addRulesetsRow({
SMTPMailFromRegexp: '', SMTPMailFromRegexp: '',
MsgFromRegexp: '', MsgFromRegexp: '',
@ -1743,15 +1746,20 @@ const init = async () => {
const t = h.split('/'); const t = h.split('/');
page.classList.add('loading'); page.classList.add('loading');
try { try {
let root;
if (h === '') { if (h === '') {
await index(); root = await index();
} }
else if (t[0] === 'destinations' && t.length === 2) { else if (t[0] === 'destinations' && t.length === 2) {
await destination(t[1]); root = await destination(t[1]);
} }
else { else {
dom._kids(page, 'page not found'); root = dom.div('page not found');
} }
if (window.moxBeforeDisplay) {
moxBeforeDisplay(root);
}
dom._kids(page, root);
} }
catch (err) { catch (err) {
console.log({ err }); console.log({ err });

View file

@ -5,6 +5,8 @@ declare let page: HTMLElement
declare let moxversion: string declare let moxversion: string
declare let moxgoos: string declare let moxgoos: string
declare let moxgoarch: string declare let moxgoarch: string
// From customization script.
declare let moxBeforeDisplay: (webmailroot: HTMLElement) => void
const login = async (reason: string) => { const login = async (reason: string) => {
return new Promise<string>((resolve: (v: string) => void, _) => { return new Promise<string>((resolve: (v: string) => void, _) => {
@ -737,7 +739,7 @@ const index = async () => {
onchange() onchange()
} }
dom._kids(page, const root = dom.div(
crumbs('Mox Account'), crumbs('Mox Account'),
dom.div( dom.div(
'Default domain: ', 'Default domain: ',
@ -1390,36 +1392,40 @@ const index = async () => {
footer, footer,
) )
// Try to show the progress of an earlier import session. The user may have just ;(async () => {
// refreshed the browser. // Try to show the progress of an earlier import session. The user may have just
let importToken: string // refreshed the browser.
try { let importToken: string
importToken = window.sessionStorage.getItem('ImportToken') || '' try {
} catch (err) { importToken = window.sessionStorage.getItem('ImportToken') || ''
console.log('looking up ImportToken in session storage', {err}) } catch (err) {
return 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'
} }
}) if (!importToken) {
.finally(() => { return
importFieldset.disabled = false }
}) 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) => { 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)] const addresses = [name, ...Object.keys(acc.Destinations || {}).filter(a => !a.startsWith('@') && a !== name)]
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Account', '#'), crumblink('Mox Account', '#'),
'Destination ' + name, 'Destination ' + name,
@ -1664,13 +1670,18 @@ const init = async () => {
const t = h.split('/') const t = h.split('/')
page.classList.add('loading') page.classList.add('loading')
try { try {
let root: HTMLElement
if (h === '') { if (h === '') {
await index() root = await index()
} else if (t[0] === 'destinations' && t.length === 2) { } else if (t[0] === 'destinations' && t.length === 2) {
await destination(t[1]) root = await destination(t[1])
} else { } 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) { } catch (err) {
console.log({err}) console.log({err})
window.alert('Error: ' + errmsg(err)) window.alert('Error: ' + errmsg(err))

View file

@ -80,10 +80,11 @@ var adminHTML []byte
var adminJS []byte var adminJS []byte
var webadminFile = &mox.WebappFile{ var webadminFile = &mox.WebappFile{
HTML: adminHTML, HTML: adminHTML,
JS: adminJS, JS: adminJS,
HTMLPath: filepath.FromSlash("webadmin/admin.html"), HTMLPath: filepath.FromSlash("webadmin/admin.html"),
JSPath: filepath.FromSlash("webadmin/admin.js"), JSPath: filepath.FromSlash("webadmin/admin.js"),
CustomStem: "webadmin",
} }
var adminDoc = mustParseAPI("admin", adminapiJSON) var adminDoc = mustParseAPI("admin", adminapiJSON)

View file

@ -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; } #page.loading, .loadstart { opacity: 0.1; animation: fadeout 1s ease-out; }
@keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } } @keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } }
@keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } } @keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } }
/* css placeholder */
</style> </style>
</head> </head>
<body> <body>
<div id="page"><div style="padding: 1em; text-align: center">Loading...</div></div> <div id="page"><div style="padding: 1em; text-align: center">Loading...</div></div>
<script>/* placeholder */</script> <script>
/* js placeholder */
</script>
</body> </body>
</html> </html>

View file

@ -1921,7 +1921,7 @@ const index = async () => {
let recvIDFieldset; let recvIDFieldset;
let recvID; let recvID;
let cidElem; 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) { 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -1942,11 +1942,11 @@ const globalRoutes = async () => {
client.Transports(), client.Transports(),
client.Config(), 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 config = async () => {
const [staticPath, dynamicPath, staticText, dynamicText] = await client.ConfigFiles(); 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 = async () => {
const loglevels = await client.LogLevels(); const loglevels = await client.LogLevels();
@ -1955,7 +1955,7 @@ const loglevels = async () => {
let fieldset; let fieldset;
let pkg; let pkg;
let level; 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; 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) { 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(); e.preventDefault();
@ -2000,7 +2000,7 @@ const accounts = async () => {
let domain; let domain;
let account; let account;
let accountModified = false; 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) { 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -2134,7 +2134,7 @@ const account = async (name) => {
} }
return v * mult; 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; let v = k;
const t = k.split('@'); const t = k.split('@');
if (t.length > 1) { if (t.length > 1) {
@ -2338,7 +2338,7 @@ const domain = async (d) => {
window.location.reload(); // todo: reload only dkim section 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.<domain>.'), 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'))))); }, 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.<domain>.'), 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(); 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.')) { 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; return;
@ -2550,7 +2550,7 @@ const domainAlias = async (d, aliasLocalpart) => {
let addFieldset; let addFieldset;
let addAddress; let addAddress;
let delFieldset; 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
check(aliasFieldset, client.AliasUpdate(aliasLocalpart, d, postPublic.checked, listMembers.checked, allowMsgFrom.checked)); check(aliasFieldset, client.AliasUpdate(aliasLocalpart, d, postPublic.checked, listMembers.checked, allowMsgFrom.checked));
@ -2580,7 +2580,7 @@ const domainDNSRecords = async (d) => {
client.DomainRecords(d), client.DomainRecords(d),
client.ParseDomain(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 domainDNSCheck = async (d) => {
const [checks, dnsdomain] = await Promise.all([ const [checks, dnsdomain] = await Promise.all([
@ -2668,16 +2668,16 @@ const domainDNSCheck = async (d) => {
const detailsAutodiscover = !checks.Autodiscover.Records ? [] : [ 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.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 () => { 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 dmarcReports = async () => {
const end = new Date(); const end = new Date();
const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000);
const summaries = await client.DMARCSummaries(start, end, ""); 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) => { const renderDMARCSummaries = (summaries) => {
return [ return [
@ -2702,7 +2702,7 @@ const dmarcEvaluations = async () => {
let until; let until;
let comment; let comment;
const nextmonth = new Date(new Date().getTime() + 31 * 24 * 3600 * 1000); 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.stopPropagation();
e.preventDefault(); e.preventDefault();
await check(fieldset, client.DMARCSuppressAdd(reportingAddress.value, new Date(until.value), comment.value)); await check(fieldset, client.DMARCSuppressAdd(reportingAddress.value, new Date(until.value), comment.value));
@ -2752,7 +2752,7 @@ const dmarcEvaluationsDomain = async (domain) => {
}); });
return r; 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)); await check(e.target, client.DMARCRemoveEvaluations(domain));
window.location.reload(); // todo: only clear the table? 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 => { })), 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), 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. // 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 => { 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; const m = r.ReportMetadata;
let policy = []; let policy = [];
@ -2912,10 +2912,10 @@ const domainDMARCReport = async (d, reportID) => {
client.DMARCReportID(d, reportID), client.DMARCReportID(d, reportID),
client.Domain(d), 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 () => { 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 tlsrptResults = async () => {
const [results, suppressAddresses] = await Promise.all([ const [results, suppressAddresses] = await Promise.all([
@ -2928,7 +2928,7 @@ const tlsrptResults = async () => {
let until; let until;
let comment; let comment;
const nextmonth = new Date(new Date().getTime() + 31 * 24 * 3600 * 1000); 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) { if (a.DayUTC !== b.DayUTC) {
return a.DayUTC < b.DayUTC ? -1 : 1; 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 [d, tlsresults] = await client.TLSRPTResultsDomain(isrcptdom, domain);
const recordPromise = client.LookupTLSRPTRecord(domain); const recordPromise = client.LookupTLSRPTRecord(domain);
let recordBox; 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(); e.preventDefault();
await check(e.target, client.TLSRPTRemoveResults(isrcptdom, domain, '')); await check(e.target, client.TLSRPTRemoveResults(isrcptdom, domain, ''));
window.location.reload(); // todo: only clear the table? window.location.reload(); // todo: only clear the table?
@ -2999,12 +2999,13 @@ const tlsrptResultsPolicyDomain = async (isrcptdom, domain) => {
} }
dom._kids(recordBox, l); dom._kids(recordBox, l);
})(); })();
return root;
}; };
const tlsrptReports = async () => { const tlsrptReports = async () => {
const end = new Date(); const end = new Date();
const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000);
const summaries = await client.TLSRPTSummaries(start, end, ''); 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) => { const renderTLSRPTSummaries = (summaries) => {
return [ return [
@ -3030,7 +3031,7 @@ const domainTLSRPT = async (d) => {
} }
return s; 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 => { 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; const r = record.Report;
let nrows = 0; let nrows = 0;
@ -3080,11 +3081,11 @@ const domainTLSRPTID = async (d, reportID) => {
client.TLSReportID(d, reportID), client.TLSReportID(d, reportID),
client.ParseDomain(d), 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 mtasts = async () => {
const policies = await client.MTASTSPolicies(); 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) => { const formatMTASTSMX = (mx) => {
return mx.map(e => { return mx.map(e => {
@ -3131,7 +3132,7 @@ const dnsbl = async () => {
const url = (ip) => 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html'; const url = (ip) => 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html';
let fieldset; let fieldset;
let monitorTextarea; 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; 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]))))); 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 ? })), !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.alert('' + n + ' message(s) updated');
window.location.reload(); // todo: reload less 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
const pr = { const pr = {
@ -3406,7 +3407,7 @@ const retiredList = async () => {
tbody = ntbody; tbody = ntbody;
}; };
render(); 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. // Filtering.
filterForm = dom.form(attr.id('queuefilter'), // Referenced by input elements in table row. filterForm = dom.form(attr.id('queuefilter'), // Referenced by input elements in table row.
async function submit(e) { async function submit(e) {
@ -3531,7 +3532,7 @@ const hooksList = async () => {
window.alert('' + n + ' hook(s) updated'); window.alert('' + n + ' hook(s) updated');
window.location.reload(); // todo: reload less 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(); filterForm.requestSubmit();
}, dom.option(''), }, dom.option(''),
// note: outgoing hook events are in ../webhook/webhook.go, ../mox-/config.go ../webadmin/admin.ts and ../webapi/gendoc.sh. keep in sync. // 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; tbody = ntbody;
}; };
render(); 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(); filterForm.requestSubmit();
}, dom.option(''), }, dom.option(''),
// note: outgoing hook events are in ../webhook/webhook.go, ../mox-/config.go ../webadmin/admin.ts and ../webapi/gendoc.sh. keep in sync. // 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: '' }]); const row = redirectRow([{ ASCII: '', Unicode: '' }, { ASCII: '', Unicode: '' }]);
redirectsTbody.appendChild(row.root); redirectsTbody.appendChild(row.root);
noredirect.style.display = redirectRows.length ? 'none' : ''; noredirect.style.display = redirectRows.length ? 'none' : '';
@ -4027,96 +4028,101 @@ const init = async () => {
const t = h.split('/'); const t = h.split('/');
page.classList.add('loading'); page.classList.add('loading');
try { try {
let root;
if (h == '') { if (h == '') {
await index(); root = await index();
} }
else if (h === 'config') { else if (h === 'config') {
await config(); root = await config();
} }
else if (h === 'loglevels') { else if (h === 'loglevels') {
await loglevels(); root = await loglevels();
} }
else if (h === 'accounts') { else if (h === 'accounts') {
await accounts(); root = await accounts();
} }
else if (t[0] === 'accounts' && t.length === 2) { 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) { 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') { 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') { 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])) { 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') { 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') { else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnsrecords') {
await domainDNSRecords(t[1]); root = await domainDNSRecords(t[1]);
} }
else if (h === 'queue') { else if (h === 'queue') {
await queueList(); root = await queueList();
} }
else if (h === 'queue/retired') { else if (h === 'queue/retired') {
await retiredList(); root = await retiredList();
} }
else if (h === 'webhookqueue') { else if (h === 'webhookqueue') {
await hooksList(); root = await hooksList();
} }
else if (h === 'webhookqueue/retired') { else if (h === 'webhookqueue/retired') {
await hooksRetiredList(); root = await hooksRetiredList();
} }
else if (h === 'tlsrpt') { else if (h === 'tlsrpt') {
await tlsrptIndex(); root = await tlsrptIndex();
} }
else if (h === 'tlsrpt/reports') { else if (h === 'tlsrpt/reports') {
await tlsrptReports(); root = await tlsrptReports();
} }
else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 3) { 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])) { 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') { 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) { 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') { else if (h === 'dmarc') {
await dmarcIndex(); root = await dmarcIndex();
} }
else if (h === 'dmarc/reports') { else if (h === 'dmarc/reports') {
await dmarcReports(); root = await dmarcReports();
} }
else if (h === 'dmarc/evaluations') { else if (h === 'dmarc/evaluations') {
await dmarcEvaluations(); root = await dmarcEvaluations();
} }
else if (t[0] == 'dmarc' && t[1] == 'evaluations' && t.length === 3) { else if (t[0] == 'dmarc' && t[1] == 'evaluations' && t.length === 3) {
await dmarcEvaluationsDomain(t[2]); root = await dmarcEvaluationsDomain(t[2]);
} }
else if (h === 'mtasts') { else if (h === 'mtasts') {
await mtasts(); root = await mtasts();
} }
else if (h === 'dnsbl') { else if (h === 'dnsbl') {
await dnsbl(); root = await dnsbl();
} }
else if (h === 'routes') { else if (h === 'routes') {
await globalRoutes(); root = await globalRoutes();
} }
else if (h === 'webserver') { else if (h === 'webserver') {
await webserver(); root = await webserver();
} }
else { else {
dom._kids(page, 'page not found'); root = dom.div('page not found');
} }
if (window.moxBeforeDisplay) {
moxBeforeDisplay(root);
}
dom._kids(page, root);
} }
catch (err) { catch (err) {
console.log('error', err); console.log('error', err);

View file

@ -5,6 +5,8 @@ declare let page: HTMLElement
declare let moxversion: string declare let moxversion: string
declare let moxgoos: string declare let moxgoos: string
declare let moxgoarch: string declare let moxgoarch: string
// From customization script.
declare let moxBeforeDisplay: (webmailroot: HTMLElement) => void
const login = async (reason: string) => { const login = async (reason: string) => {
return new Promise<string>((resolve: (v: string) => void, _) => { return new Promise<string>((resolve: (v: string) => void, _) => {
@ -346,7 +348,7 @@ const index = async () => {
let recvID: HTMLInputElement let recvID: HTMLInputElement
let cidElem: HTMLSpanElement let cidElem: HTMLSpanElement
dom._kids(page, return dom.div(
crumbs('Mox Admin'), 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'))), 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.p(
@ -439,7 +441,7 @@ const globalRoutes = async () => {
client.Config(), client.Config(),
]) ])
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'Routes', 'Routes',
@ -451,7 +453,7 @@ const globalRoutes = async () => {
const config = async () => { const config = async () => {
const [staticPath, dynamicPath, staticText, dynamicText] = await client.ConfigFiles() const [staticPath, dynamicPath, staticText, dynamicText] = await client.ConfigFiles()
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'Config', 'Config',
@ -473,7 +475,7 @@ const loglevels = async () => {
let pkg: HTMLInputElement let pkg: HTMLInputElement
let level: HTMLSelectElement let level: HTMLSelectElement
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'Log levels', 'Log levels',
@ -584,7 +586,7 @@ const accounts = async () => {
let account: HTMLInputElement let account: HTMLInputElement
let accountModified = false let accountModified = false
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'Accounts', 'Accounts',
@ -803,7 +805,7 @@ const account = async (name: string) => {
return v*mult return v*mult
} }
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('Accounts', '#accounts'), crumblink('Accounts', '#accounts'),
@ -1219,7 +1221,7 @@ const domain = async (d: string) => {
) )
} }
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'Domain ' + domainString(dnsdomain), 'Domain ' + domainString(dnsdomain),
@ -1794,7 +1796,7 @@ const domainAlias = async (d: string, aliasLocalpart: string) => {
let delFieldset: HTMLFieldSetElement let delFieldset: HTMLFieldSetElement
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('Domain ' + domainString(domain.Domain), '#domains/'+d), crumblink('Domain ' + domainString(domain.Domain), '#domains/'+d),
@ -1901,7 +1903,7 @@ const domainDNSRecords = async (d: string) => {
client.ParseDomain(d), client.ParseDomain(d),
]) ])
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d),
@ -2056,7 +2058,7 @@ const domainDNSCheck = async (d: string) => {
), ),
] ]
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d),
@ -2082,7 +2084,7 @@ const domainDNSCheck = async (d: string) => {
} }
const dmarcIndex = async () => { const dmarcIndex = async () => {
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'DMARC', 'DMARC',
@ -2103,7 +2105,7 @@ const dmarcReports = async () => {
const start = new Date(new Date().getTime() - 30*24*3600*1000) const start = new Date(new Date().getTime() - 30*24*3600*1000)
const summaries = await client.DMARCSummaries(start, end, "") const summaries = await client.DMARCSummaries(start, end, "")
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('DMARC', '#dmarc'), crumblink('DMARC', '#dmarc'),
@ -2165,7 +2167,7 @@ const dmarcEvaluations = async () => {
const nextmonth = new Date(new Date().getTime()+31*24*3600*1000) const nextmonth = new Date(new Date().getTime()+31*24*3600*1000)
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('DMARC', '#dmarc'), crumblink('DMARC', '#dmarc'),
@ -2305,7 +2307,7 @@ const dmarcEvaluationsDomain = async (domain: string) => {
return r return r
} }
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('DMARC', '#dmarc'), 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. // 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( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d),
@ -2578,7 +2580,7 @@ const domainDMARCReport = async (d: string, reportID: number) => {
client.Domain(d), client.Domain(d),
]) ])
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d),
@ -2591,7 +2593,7 @@ const domainDMARCReport = async (d: string, reportID: number) => {
} }
const tlsrptIndex = async () => { const tlsrptIndex = async () => {
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'TLSRPT', 'TLSRPT',
@ -2621,7 +2623,7 @@ const tlsrptResults = async () => {
let comment: HTMLInputElement let comment: HTMLInputElement
const nextmonth = new Date(new Date().getTime()+31*24*3600*1000) const nextmonth = new Date(new Date().getTime()+31*24*3600*1000)
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('TLSRPT', '#tlsrpt'), crumblink('TLSRPT', '#tlsrpt'),
@ -2758,7 +2760,7 @@ const tlsrptResultsPolicyDomain = async (isrcptdom: boolean, domain: string) =>
const recordPromise = client.LookupTLSRPTRecord(domain) const recordPromise = client.LookupTLSRPTRecord(domain)
let recordBox: HTMLElement let recordBox: HTMLElement
dom._kids(page, const root = dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('TLSRPT', '#tlsrpt'), crumblink('TLSRPT', '#tlsrpt'),
@ -2808,6 +2810,8 @@ const tlsrptResultsPolicyDomain = async (isrcptdom: boolean, domain: string) =>
} }
dom._kids(recordBox, l) dom._kids(recordBox, l)
})() })()
return root
} }
const tlsrptReports = async () => { const tlsrptReports = async () => {
@ -2815,7 +2819,7 @@ const tlsrptReports = async () => {
const start = new Date(new Date().getTime() - 30*24*3600*1000) const start = new Date(new Date().getTime() - 30*24*3600*1000)
const summaries = await client.TLSRPTSummaries(start, end, '') const summaries = await client.TLSRPTSummaries(start, end, '')
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('TLSRPT', '#tlsrpt'), crumblink('TLSRPT', '#tlsrpt'),
@ -2872,7 +2876,7 @@ const domainTLSRPT = async (d: string) => {
return s return s
} }
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('TLSRPT', '#tlsrpt'), crumblink('TLSRPT', '#tlsrpt'),
@ -2968,7 +2972,7 @@ const domainTLSRPTID = async (d: string, reportID: number) => {
client.ParseDomain(d), client.ParseDomain(d),
]) ])
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('TLSRPT', '#tlsrpt'), crumblink('TLSRPT', '#tlsrpt'),
@ -2984,7 +2988,7 @@ const domainTLSRPTID = async (d: string, reportID: number) => {
const mtasts = async () => { const mtasts = async () => {
const policies = await client.MTASTSPolicies() const policies = await client.MTASTSPolicies()
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'MTA-STS policies', 'MTA-STS policies',
@ -3056,7 +3060,7 @@ const dnsbl = async () => {
let fieldset: HTMLFieldSetElement let fieldset: HTMLFieldSetElement
let monitorTextarea: HTMLTextAreaElement let monitorTextarea: HTMLTextAreaElement
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'DNS blocklist status for IPs', 'DNS blocklist status for IPs',
@ -3256,7 +3260,7 @@ const queueList = async () => {
window.location.reload() // todo: reload less window.location.reload() // todo: reload less
}) })
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'Queue', 'Queue',
@ -3696,7 +3700,7 @@ const retiredList = async () => {
} }
render() render()
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('Queue', '#queue'), crumblink('Queue', '#queue'),
@ -3978,7 +3982,7 @@ const hooksList = async () => {
window.location.reload() // todo: reload less window.location.reload() // todo: reload less
}) })
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'Webhook queue', 'Webhook queue',
@ -4266,7 +4270,7 @@ const hooksRetiredList = async () => {
} }
render() render()
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
crumblink('Webhook queue', '#webhookqueue'), crumblink('Webhook queue', '#webhookqueue'),
@ -5039,7 +5043,7 @@ const webserver = async () => {
] ]
} }
dom._kids(page, return dom.div(
crumbs( crumbs(
crumblink('Mox Admin', '#'), crumblink('Mox Admin', '#'),
'Webserver config', 'Webserver config',
@ -5121,67 +5125,72 @@ const init = async () => {
const t = h.split('/') const t = h.split('/')
page.classList.add('loading') page.classList.add('loading')
try { try {
let root: HTMLElement
if (h == '') { if (h == '') {
await index() root = await index()
} else if (h === 'config') { } else if (h === 'config') {
await config() root = await config()
} else if (h === 'loglevels') { } else if (h === 'loglevels') {
await loglevels() root = await loglevels()
} else if (h === 'accounts') { } else if (h === 'accounts') {
await accounts() root = await accounts()
} else if (t[0] === 'accounts' && t.length === 2) { } 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) { } 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') { } 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') { } 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])) { } 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') { } 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') { } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnsrecords') {
await domainDNSRecords(t[1]) root = await domainDNSRecords(t[1])
} else if (h === 'queue') { } else if (h === 'queue') {
await queueList() root = await queueList()
} else if (h === 'queue/retired') { } else if (h === 'queue/retired') {
await retiredList() root = await retiredList()
} else if (h === 'webhookqueue') { } else if (h === 'webhookqueue') {
await hooksList() root = await hooksList()
} else if (h === 'webhookqueue/retired') { } else if (h === 'webhookqueue/retired') {
await hooksRetiredList() root = await hooksRetiredList()
} else if (h === 'tlsrpt') { } else if (h === 'tlsrpt') {
await tlsrptIndex() root = await tlsrptIndex()
} else if (h === 'tlsrpt/reports') { } else if (h === 'tlsrpt/reports') {
await tlsrptReports() root = await tlsrptReports()
} else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 3) { } 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])) { } 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') { } 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) { } 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') { } else if (h === 'dmarc') {
await dmarcIndex() root = await dmarcIndex()
} else if (h === 'dmarc/reports') { } else if (h === 'dmarc/reports') {
await dmarcReports() root = await dmarcReports()
} else if (h === 'dmarc/evaluations') { } else if (h === 'dmarc/evaluations') {
await dmarcEvaluations() root = await dmarcEvaluations()
} else if (t[0] == 'dmarc' && t[1] == 'evaluations' && t.length === 3) { } else if (t[0] == 'dmarc' && t[1] == 'evaluations' && t.length === 3) {
await dmarcEvaluationsDomain(t[2]) root = await dmarcEvaluationsDomain(t[2])
} else if (h === 'mtasts') { } else if (h === 'mtasts') {
await mtasts() root = await mtasts()
} else if (h === 'dnsbl') { } else if (h === 'dnsbl') {
await dnsbl() root = await dnsbl()
} else if (h === 'routes') { } else if (h === 'routes') {
await globalRoutes() root = await globalRoutes()
} else if (h === 'webserver') { } else if (h === 'webserver') {
await webserver() root = await webserver()
} else { } 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) { } catch (err) {
console.log('error', err) console.log('error', err)
window.alert('Error: ' + errmsg(err)) window.alert('Error: ' + errmsg(err))

View file

@ -9,11 +9,11 @@
// We keep the default/regular styles and dark-mode styles in separate stylesheets. // We keep the default/regular styles and dark-mode styles in separate stylesheets.
const cssStyle = dom.style(attr.type('text/css')) const cssStyle = dom.style(attr.type('text/css'))
document.head.appendChild(cssStyle) document.head.prepend(cssStyle)
const styleSheet = cssStyle.sheet! const styleSheet = cssStyle.sheet!
const cssStyleDark = dom.style(attr.type('text/css')) const cssStyleDark = dom.style(attr.type('text/css'))
document.head.appendChild(cssStyleDark) document.head.prepend(cssStyleDark)
const styleSheetDark = cssStyleDark.sheet! const styleSheetDark = cssStyleDark.sheet!
styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}') styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}')
const darkModeRule = styleSheetDark.cssRules[0] as CSSMediaRule const darkModeRule = styleSheetDark.cssRules[0] as CSSMediaRule
@ -42,8 +42,11 @@ const ensureCSS = (selector: string, styles: { [prop: string]: string | number |
let darkst: CSSStyleDeclaration | undefined let darkst: CSSStyleDeclaration | undefined
for (let [k, v] of Object.entries(styles)) { 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] = // 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. // value". It is more convenient as object keys. So convert to kebab-case, but only
k = k.replace(/[A-Z]/g, s => '-'+s.toLowerCase()) // if this is not a css property.
if (!k.startsWith('--')) {
k = k.replace(/[A-Z]/g, s => '-'+s.toLowerCase())
}
if (Array.isArray(v)) { if (Array.isArray(v)) {
if (v.length !== 2) { if (v.length !== 2) {
throw new Error('2 elements required for light/dark mode style, got '+v.length) 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: 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: add the standard padding and border-radius, perhaps more.
// todo: could make some of these {prop: value} objects and pass them directly to css() // We define css variables, making them easy to override.
const styles = { ensureCSS(':root', {
color: ['black', '#ddd'], '--color': ['black', '#ddd'],
colorMild: ['#555', '#bbb'], '--colorMild': ['#555', '#bbb'],
colorMilder: ['#666', '#aaa'], '--colorMilder': ['#666', '#aaa'],
backgroundColor: ['white', '#222'], '--backgroundColor': ['white', '#222'],
backgroundColorMild: ['#f8f8f8', '#080808'], '--backgroundColorMild': ['#f8f8f8', '#080808'],
backgroundColorMilder: ['#999', '#777'], '--backgroundColorMilder': ['#999', '#777'],
borderColor: ['#ccc', '#333'], '--borderColor': ['#ccc', '#333'],
mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'],
msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], '--msglistBackgroundColor': ['#f5ffff', '#04130d'],
boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'],
buttonBackground: ['#eee', '#222'], '--buttonBackground': ['#eee', '#222'],
buttonBorderColor: ['#888', '#666'], '--buttonBorderColor': ['#888', '#666'],
buttonHoverBackground: ['#ddd', '#333'], '--buttonHoverBackground': ['#ddd', '#333'],
overlayOpaqueBackgroundColor: ['#eee', '#011'], '--overlayOpaqueBackgroundColor': ['#eee', '#011'],
overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'],
popupColor: ['black', 'white'], '--popupColor': ['black', 'white'],
popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], '--popupBackgroundColor': ['white', '#313233'],
popupBorderColor: ['#ccc', '#555'], '--popupBorderColor': ['#ccc', '#555'],
highlightBackground: ['gold', '#a70167'], '--highlightBackground': ['gold', '#a70167'],
highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], '--highlightBorderColor': ['#8c7600', '#fd1fa7'],
highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], '--highlightBackgroundHover': ['#ffbd21', '#710447'],
mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'],
mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], '--mailboxHoverBackgroundColor': ['#eee', '#421f15'],
msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'],
msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], '--msgItemHoverBackgroundColor': ['#eee', '#073348'],
msgItemFocusBorderColor: ['#2685ff', '#2685ff'], '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'],
buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'],
buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], '--buttonTristateOffBackground': ['#ffb192', '#bf410f'],
warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], '--warningBackgroundColor': ['#ffca91', '#a85700'],
successBackground: ['#d2f791', '#1fa204'], '--successBackground': ['#d2f791', '#1fa204'],
emphasisBackground: ['#666', '#aaa'], '--emphasisBackground': ['#666', '#aaa'],
// For authentication/security results. // For authentication/security results.
underlineGreen: '#50c40f', '--underlineGreen': '#50c40f',
underlineRed: '#e15d1c', '--underlineRed': '#e15d1c',
underlineBlue: '#09f', '--underlineBlue': '#09f',
underlineGrey: '#888', '--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 = { const styleClasses = {
// For quoted text, with multiple levels of indentations. // For quoted text, with multiple levels of indentations.
quoted: [ quoted: [
css('quoted1', {color: ['#03828f', '#71f2ff']}), // red css('quoted1', {color: styles.quoted1Color}),
css('quoted2', {color: ['#c7445c', 'rgb(236, 76, 76)']}), // green css('quoted2', {color: styles.quoted2Color}),
css('quoted3', {color: ['#417c10', 'rgb(115, 230, 20)']}), // blue css('quoted3', {color: styles.quoted3Color}),
], ],
// When text switches between unicode scripts. // 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}), textMild: css('textMild', {color: styles.colorMild}),
// For keywords (also known as flags/labels/tags) on messages. // 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}), 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}), ensureCSS('.keyword.keywordCollapsed', {opacity: .75}),
// Generic styling. // 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('*', {fontSize: 'inherit', fontFamily: "'ubuntu', 'lato', sans-serif", margin: 0, padding: 0, boxSizing: 'border-box'})
ensureCSS('.mono, .mono *', {fontFamily: "'ubuntu mono', monospace"}) ensureCSS('.mono, .mono *', {fontFamily: "'ubuntu mono', monospace"})
ensureCSS('table td, table th', {padding: '.15em .25em'}) ensureCSS('table td, table th', {padding: '.15em .25em'})
ensureCSS('.pad', {padding: '.5em'}) ensureCSS('.pad', {padding: '.5em'})
ensureCSS('iframe', {border: 0}) ensureCSS('iframe', {border: 0})
ensureCSS('img, embed, video, iframe', {backgroundColor: 'white', color: 'black'}) ensureCSS('img, embed, video, iframe', {backgroundColor: 'white', color: 'black'})
ensureCSS(':root', {backgroundColor: styles.backgroundColor, color: styles.color}) ensureCSS('a', {color: styles.linkColor})
ensureCSS('a', {color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)']}) ensureCSS('a:visited', {color: styles.linkVisitedColor})
ensureCSS('a:visited', {color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)']})
// For message view with multiple inline elements (often a single text and multiple messages). // For message view with multiple inline elements (often a single text and multiple messages).
ensureCSS('.textmulti > *:nth-child(even)', {backgroundColor: ['#f4f4f4', '#141414']}) ensureCSS('.textmulti > *:nth-child(even)', {backgroundColor: ['#f4f4f4', '#141414']})

View file

@ -4,10 +4,17 @@
<title>Message</title> <title>Message</title>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
/* css placeholder */
</style>
</head> </head>
<body> <body>
<div id="page"><div style="padding: 1em">Loading...</div></div> <div id="page"><div style="padding: 1em">Loading...</div></div>
<script>
/* js placeholder */
</script>
<!-- Load message data synchronously like in text.html, which needs it to generate a meaningful 'loaded' event, used for updating the iframe height. --> <!-- Load message data synchronously like in text.html, which needs it to generate a meaningful 'loaded' event, used for updating the iframe height. -->
<script src="parsedmessage.js"></script> <script src="parsedmessage.js"></script>

View file

@ -1055,10 +1055,10 @@ var api;
// instances of a class. // instances of a class.
// We keep the default/regular styles and dark-mode styles in separate stylesheets. // We keep the default/regular styles and dark-mode styles in separate stylesheets.
const cssStyle = dom.style(attr.type('text/css')); const cssStyle = dom.style(attr.type('text/css'));
document.head.appendChild(cssStyle); document.head.prepend(cssStyle);
const styleSheet = cssStyle.sheet; const styleSheet = cssStyle.sheet;
const cssStyleDark = dom.style(attr.type('text/css')); const cssStyleDark = dom.style(attr.type('text/css'));
document.head.appendChild(cssStyleDark); document.head.prepend(cssStyleDark);
const styleSheetDark = cssStyleDark.sheet; const styleSheetDark = cssStyleDark.sheet;
styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}');
const darkModeRule = styleSheetDark.cssRules[0]; const darkModeRule = styleSheetDark.cssRules[0];
@ -1085,8 +1085,11 @@ const ensureCSS = (selector, styles, important) => {
let darkst; let darkst;
for (let [k, v] of Object.entries(styles)) { 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] = // 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. // value". It is more convenient as object keys. So convert to kebab-case, but only
k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); // if this is not a css property.
if (!k.startsWith('--')) {
k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase());
}
if (Array.isArray(v)) { if (Array.isArray(v)) {
if (v.length !== 2) { if (v.length !== 2) {
throw new Error('2 elements required for light/dark mode style, got ' + v.length); 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: 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: add the standard padding and border-radius, perhaps more.
// todo: could make some of these {prop: value} objects and pass them directly to css() // We define css variables, making them easy to override.
const styles = { ensureCSS(':root', {
color: ['black', '#ddd'], '--color': ['black', '#ddd'],
colorMild: ['#555', '#bbb'], '--colorMild': ['#555', '#bbb'],
colorMilder: ['#666', '#aaa'], '--colorMilder': ['#666', '#aaa'],
backgroundColor: ['white', '#222'], '--backgroundColor': ['white', '#222'],
backgroundColorMild: ['#f8f8f8', '#080808'], '--backgroundColorMild': ['#f8f8f8', '#080808'],
backgroundColorMilder: ['#999', '#777'], '--backgroundColorMilder': ['#999', '#777'],
borderColor: ['#ccc', '#333'], '--borderColor': ['#ccc', '#333'],
mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'],
msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], '--msglistBackgroundColor': ['#f5ffff', '#04130d'],
boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'],
buttonBackground: ['#eee', '#222'], '--buttonBackground': ['#eee', '#222'],
buttonBorderColor: ['#888', '#666'], '--buttonBorderColor': ['#888', '#666'],
buttonHoverBackground: ['#ddd', '#333'], '--buttonHoverBackground': ['#ddd', '#333'],
overlayOpaqueBackgroundColor: ['#eee', '#011'], '--overlayOpaqueBackgroundColor': ['#eee', '#011'],
overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'],
popupColor: ['black', 'white'], '--popupColor': ['black', 'white'],
popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], '--popupBackgroundColor': ['white', '#313233'],
popupBorderColor: ['#ccc', '#555'], '--popupBorderColor': ['#ccc', '#555'],
highlightBackground: ['gold', '#a70167'], '--highlightBackground': ['gold', '#a70167'],
highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], '--highlightBorderColor': ['#8c7600', '#fd1fa7'],
highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], '--highlightBackgroundHover': ['#ffbd21', '#710447'],
mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'],
mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], '--mailboxHoverBackgroundColor': ['#eee', '#421f15'],
msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'],
msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], '--msgItemHoverBackgroundColor': ['#eee', '#073348'],
msgItemFocusBorderColor: ['#2685ff', '#2685ff'], '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'],
buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'],
buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], '--buttonTristateOffBackground': ['#ffb192', '#bf410f'],
warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], '--warningBackgroundColor': ['#ffca91', '#a85700'],
successBackground: ['#d2f791', '#1fa204'], '--successBackground': ['#d2f791', '#1fa204'],
emphasisBackground: ['#666', '#aaa'], '--emphasisBackground': ['#666', '#aaa'],
// For authentication/security results. // For authentication/security results.
underlineGreen: '#50c40f', '--underlineGreen': '#50c40f',
underlineRed: '#e15d1c', '--underlineRed': '#e15d1c',
underlineBlue: '#09f', '--underlineBlue': '#09f',
underlineGrey: '#888', '--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 = { const styleClasses = {
// For quoted text, with multiple levels of indentations. // For quoted text, with multiple levels of indentations.
quoted: [ quoted: [
css('quoted1', { color: ['#03828f', '#71f2ff'] }), css('quoted1', { color: styles.quoted1Color }),
css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), css('quoted2', { color: styles.quoted2Color }),
css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue css('quoted3', { color: styles.quoted3Color }),
], ],
// When text switches between unicode scripts. // 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 }), textMild: css('textMild', { color: styles.colorMild }),
// For keywords (also known as flags/labels/tags) on messages. // 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 }), 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('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values.
ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }),
// Generic styling. // 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('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" });
ensureCSS('table td, table th', { padding: '.15em .25em' }); ensureCSS('table td, table th', { padding: '.15em .25em' });
ensureCSS('.pad', { padding: '.5em' }); ensureCSS('.pad', { padding: '.5em' });
ensureCSS('iframe', { border: 0 }); ensureCSS('iframe', { border: 0 });
ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' });
ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); ensureCSS('a', { color: styles.linkColor });
ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); ensureCSS('a:visited', { color: styles.linkVisitedColor });
ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] });
// For message view with multiple inline elements (often a single text and multiple messages). // For message view with multiple inline elements (often a single text and multiple messages).
ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] });
ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ });
@ -1417,13 +1471,17 @@ const init = () => {
iframepath += '?sameorigin=true'; iframepath += '?sameorigin=true';
let iframe; let iframe;
const page = document.getElementById('page'); 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. // 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'; iframe.style.height = iframe.contentDocument.documentElement.scrollHeight + 'px';
if (window.location.hash === '#print') { if (window.location.hash === '#print') {
window.print(); window.print();
} }
})); }));
if (typeof moxBeforeDisplay !== 'undefined') {
moxBeforeDisplay(root);
}
dom._kids(page, root);
}; };
try { try {
init(); init();

View file

@ -2,6 +2,8 @@
// Loaded from synchronous javascript. // Loaded from synchronous javascript.
declare let messageItem: api.MessageItem declare let messageItem: api.MessageItem
// From customization script.
declare let moxBeforeDisplay: (root: HTMLElement) => void
const init = () => { const init = () => {
const mi = api.parser.MessageItem(messageItem) const mi = api.parser.MessageItem(messageItem)
@ -40,7 +42,7 @@ const init = () => {
let iframe: HTMLIFrameElement let iframe: HTMLIFrameElement
const page = document.getElementById('page')! const page = document.getElementById('page')!
dom._kids(page, const root = dom.div(
dom.div( dom.div(
css('msgMeta', {backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor}), css('msgMeta', {backgroundColor: styles.backgroundColorMild, borderBottom: '1px solid', borderBottomColor: styles.borderColor}),
msgheaderview, msgheaderview,
@ -59,6 +61,10 @@ const init = () => {
}, },
) )
) )
if (typeof moxBeforeDisplay !== 'undefined') {
moxBeforeDisplay(root)
}
dom._kids(page, root)
} }
try { try {

View file

@ -3,10 +3,17 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
/* css placeholder */
</style>
</head> </head>
<body> <body>
<div id="page" style="opacity: .1">Loading...</div> <div id="page" style="opacity: .1">Loading...</div>
<script>
/* js placeholder */
</script>
<!-- Load message data synchronously to generate a meaningful 'loaded' event, used by webmailmsg.html for updating the iframe height . --> <!-- Load message data synchronously to generate a meaningful 'loaded' event, used by webmailmsg.html for updating the iframe height . -->
<script src="parsedmessage.js"></script> <script src="parsedmessage.js"></script>

View file

@ -1055,10 +1055,10 @@ var api;
// instances of a class. // instances of a class.
// We keep the default/regular styles and dark-mode styles in separate stylesheets. // We keep the default/regular styles and dark-mode styles in separate stylesheets.
const cssStyle = dom.style(attr.type('text/css')); const cssStyle = dom.style(attr.type('text/css'));
document.head.appendChild(cssStyle); document.head.prepend(cssStyle);
const styleSheet = cssStyle.sheet; const styleSheet = cssStyle.sheet;
const cssStyleDark = dom.style(attr.type('text/css')); const cssStyleDark = dom.style(attr.type('text/css'));
document.head.appendChild(cssStyleDark); document.head.prepend(cssStyleDark);
const styleSheetDark = cssStyleDark.sheet; const styleSheetDark = cssStyleDark.sheet;
styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}');
const darkModeRule = styleSheetDark.cssRules[0]; const darkModeRule = styleSheetDark.cssRules[0];
@ -1085,8 +1085,11 @@ const ensureCSS = (selector, styles, important) => {
let darkst; let darkst;
for (let [k, v] of Object.entries(styles)) { 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] = // 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. // value". It is more convenient as object keys. So convert to kebab-case, but only
k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); // if this is not a css property.
if (!k.startsWith('--')) {
k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase());
}
if (Array.isArray(v)) { if (Array.isArray(v)) {
if (v.length !== 2) { if (v.length !== 2) {
throw new Error('2 elements required for light/dark mode style, got ' + v.length); 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: 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: add the standard padding and border-radius, perhaps more.
// todo: could make some of these {prop: value} objects and pass them directly to css() // We define css variables, making them easy to override.
const styles = { ensureCSS(':root', {
color: ['black', '#ddd'], '--color': ['black', '#ddd'],
colorMild: ['#555', '#bbb'], '--colorMild': ['#555', '#bbb'],
colorMilder: ['#666', '#aaa'], '--colorMilder': ['#666', '#aaa'],
backgroundColor: ['white', '#222'], '--backgroundColor': ['white', '#222'],
backgroundColorMild: ['#f8f8f8', '#080808'], '--backgroundColorMild': ['#f8f8f8', '#080808'],
backgroundColorMilder: ['#999', '#777'], '--backgroundColorMilder': ['#999', '#777'],
borderColor: ['#ccc', '#333'], '--borderColor': ['#ccc', '#333'],
mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'],
msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], '--msglistBackgroundColor': ['#f5ffff', '#04130d'],
boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'],
buttonBackground: ['#eee', '#222'], '--buttonBackground': ['#eee', '#222'],
buttonBorderColor: ['#888', '#666'], '--buttonBorderColor': ['#888', '#666'],
buttonHoverBackground: ['#ddd', '#333'], '--buttonHoverBackground': ['#ddd', '#333'],
overlayOpaqueBackgroundColor: ['#eee', '#011'], '--overlayOpaqueBackgroundColor': ['#eee', '#011'],
overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'],
popupColor: ['black', 'white'], '--popupColor': ['black', 'white'],
popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], '--popupBackgroundColor': ['white', '#313233'],
popupBorderColor: ['#ccc', '#555'], '--popupBorderColor': ['#ccc', '#555'],
highlightBackground: ['gold', '#a70167'], '--highlightBackground': ['gold', '#a70167'],
highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], '--highlightBorderColor': ['#8c7600', '#fd1fa7'],
highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], '--highlightBackgroundHover': ['#ffbd21', '#710447'],
mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'],
mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], '--mailboxHoverBackgroundColor': ['#eee', '#421f15'],
msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'],
msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], '--msgItemHoverBackgroundColor': ['#eee', '#073348'],
msgItemFocusBorderColor: ['#2685ff', '#2685ff'], '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'],
buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'],
buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], '--buttonTristateOffBackground': ['#ffb192', '#bf410f'],
warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], '--warningBackgroundColor': ['#ffca91', '#a85700'],
successBackground: ['#d2f791', '#1fa204'], '--successBackground': ['#d2f791', '#1fa204'],
emphasisBackground: ['#666', '#aaa'], '--emphasisBackground': ['#666', '#aaa'],
// For authentication/security results. // For authentication/security results.
underlineGreen: '#50c40f', '--underlineGreen': '#50c40f',
underlineRed: '#e15d1c', '--underlineRed': '#e15d1c',
underlineBlue: '#09f', '--underlineBlue': '#09f',
underlineGrey: '#888', '--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 = { const styleClasses = {
// For quoted text, with multiple levels of indentations. // For quoted text, with multiple levels of indentations.
quoted: [ quoted: [
css('quoted1', { color: ['#03828f', '#71f2ff'] }), css('quoted1', { color: styles.quoted1Color }),
css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), css('quoted2', { color: styles.quoted2Color }),
css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue css('quoted3', { color: styles.quoted3Color }),
], ],
// When text switches between unicode scripts. // 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 }), textMild: css('textMild', { color: styles.colorMild }),
// For keywords (also known as flags/labels/tags) on messages. // 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 }), 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('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values.
ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }),
// Generic styling. // 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('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" });
ensureCSS('table td, table th', { padding: '.15em .25em' }); ensureCSS('table td, table th', { padding: '.15em .25em' });
ensureCSS('.pad', { padding: '.5em' }); ensureCSS('.pad', { padding: '.5em' });
ensureCSS('iframe', { border: 0 }); ensureCSS('iframe', { border: 0 });
ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' });
ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); ensureCSS('a', { color: styles.linkColor });
ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); ensureCSS('a:visited', { color: styles.linkVisitedColor });
ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] });
// For message view with multiple inline elements (often a single text and multiple messages). // For message view with multiple inline elements (often a single text and multiple messages).
ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] });
ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ });
@ -1392,10 +1446,14 @@ const loadMsgheaderView = (msgheaderelem, mi, moreHeaders, refineKeyword, allAdd
const init = async () => { const init = async () => {
const pm = api.parser.ParsedMessage(parsedMessage); const pm = api.parser.ParsedMessage(parsedMessage);
const mi = api.parser.MessageItem(messageItem); 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('.'); 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 })))); 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() init()
.catch((err) => { .catch((err) => {

View file

@ -3,11 +3,13 @@
// Loaded from synchronous javascript. // Loaded from synchronous javascript.
declare let messageItem: api.MessageItem declare let messageItem: api.MessageItem
declare let parsedMessage: api.ParsedMessage declare let parsedMessage: api.ParsedMessage
// From customization script.
declare let moxBeforeDisplay: (root: HTMLElement) => void
const init = async () => { const init = async () => {
const pm = api.parser.ParsedMessage(parsedMessage) const pm = api.parser.ParsedMessage(parsedMessage)
const mi = api.parser.MessageItem(messageItem) const mi = api.parser.MessageItem(messageItem)
dom._kids(document.body, const root = dom.div(
dom.div(dom._class('pad', 'mono', 'textmulti'), dom.div(dom._class('pad', 'mono', 'textmulti'),
css('msgTextPreformatted', {whiteSpace: 'pre-wrap'}), css('msgTextPreformatted', {whiteSpace: 'pre-wrap'}),
(pm.Texts || []).map(t => renderText(t.replace(/\r\n/g, '\n'))), (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() init()

View file

@ -12,6 +12,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log/slog" "log/slog"
"mime" "mime"
"net/http" "net/http"
@ -21,6 +22,7 @@ import (
"runtime/debug" "runtime/debug"
"strconv" "strconv"
"strings" "strings"
"time"
_ "embed" _ "embed"
@ -147,27 +149,62 @@ func xdbread(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) {
} }
var webmailFile = &mox.WebappFile{ var webmailFile = &mox.WebappFile{
HTML: webmailHTML, HTML: webmailHTML,
JS: webmailJS, JS: webmailJS,
HTMLPath: filepath.FromSlash("webmail/webmail.html"), HTMLPath: filepath.FromSlash("webmail/webmail.html"),
JSPath: filepath.FromSlash("webmail/webmail.js"), JSPath: filepath.FromSlash("webmail/webmail.js"),
CustomStem: "webmail",
} }
// Serve content, either from a file, or return the fallback data. Caller func customization() (css, js []byte, err error) {
// should already have set the content-type. We use this to return a file from if css, err = os.ReadFile(mox.ConfigDirPath("webmail.css")); err != nil && !errors.Is(err, fs.ErrNotExist) {
// the local file system (during development), or embedded in the binary (when return nil, nil, err
// deployed). }
func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request, path string, fallback []byte) { 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) f, err := os.Open(path)
if err == nil { if err == nil {
defer f.Close() defer f.Close()
st, err := f.Stat() st, err := f.Stat()
if err == nil { if err == nil {
http.ServeContent(w, r, "", st.ModTime(), f) serve(st.ModTime(), f)
return return
} }
} }
http.ServeContent(w, r, "", mox.FallbackMtime(log), bytes.NewReader(fallback)) serve(mox.FallbackMtime(log), bytes.NewReader(fallback))
} }
func init() { 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") w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
serveContentFallback(log, w, r, path, fallback) serveContentFallback(log, w, r, path, fallback, false)
return return
} }
@ -621,7 +658,7 @@ func handle(apiHandler http.Handler, isForwarded bool, accountPath string, w htt
path := filepath.FromSlash("webmail/msg.html") path := filepath.FromSlash("webmail/msg.html")
fallback := webmailmsgHTML fallback := webmailmsgHTML
serveContentFallback(log, w, r, path, fallback) serveContentFallback(log, w, r, path, fallback, true)
case len(t) == 2 && t[1] == "parsedmessage.js": case len(t) == 2 && t[1] == "parsedmessage.js":
// Used by msg.html, for the msg* endpoints, for the data needed to show all data // 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. // from disk.
path := filepath.FromSlash("webmail/text.html") path := filepath.FromSlash("webmail/text.html")
fallback := webmailtextHTML 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"): case len(t) == 2 && (t[1] == "html" || t[1] == "htmlexternal"):
// Returns the first HTML part, with "cid:" URIs replaced with an inlined datauri // Returns the first HTML part, with "cid:" URIs replaced with an inlined datauri

View file

@ -14,10 +14,14 @@ fieldset { border: 0; }
@keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } } @keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } }
@keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } } @keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } }
.invert { filter: invert(100%); } .invert { filter: invert(100%); }
/* css placeholder */
</style> </style>
</head> </head>
<body> <body>
<div id="page"><div style="padding: 1em; text-align: center">Loading...</div></div> <div id="page"><div style="padding: 1em; text-align: center">Loading...</div></div>
<script>/* placeholder */</script> <script>
/* js placeholder */
</script>
</body> </body>
</html> </html>

View file

@ -1055,10 +1055,10 @@ var api;
// instances of a class. // instances of a class.
// We keep the default/regular styles and dark-mode styles in separate stylesheets. // We keep the default/regular styles and dark-mode styles in separate stylesheets.
const cssStyle = dom.style(attr.type('text/css')); const cssStyle = dom.style(attr.type('text/css'));
document.head.appendChild(cssStyle); document.head.prepend(cssStyle);
const styleSheet = cssStyle.sheet; const styleSheet = cssStyle.sheet;
const cssStyleDark = dom.style(attr.type('text/css')); const cssStyleDark = dom.style(attr.type('text/css'));
document.head.appendChild(cssStyleDark); document.head.prepend(cssStyleDark);
const styleSheetDark = cssStyleDark.sheet; const styleSheetDark = cssStyleDark.sheet;
styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}'); styleSheetDark.insertRule('@media (prefers-color-scheme: dark) {}');
const darkModeRule = styleSheetDark.cssRules[0]; const darkModeRule = styleSheetDark.cssRules[0];
@ -1085,8 +1085,11 @@ const ensureCSS = (selector, styles, important) => {
let darkst; let darkst;
for (let [k, v] of Object.entries(styles)) { 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] = // 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. // value". It is more convenient as object keys. So convert to kebab-case, but only
k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase()); // if this is not a css property.
if (!k.startsWith('--')) {
k = k.replace(/[A-Z]/g, s => '-' + s.toLowerCase());
}
if (Array.isArray(v)) { if (Array.isArray(v)) {
if (v.length !== 2) { if (v.length !== 2) {
throw new Error('2 elements required for light/dark mode style, got ' + v.length); 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: 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: add the standard padding and border-radius, perhaps more.
// todo: could make some of these {prop: value} objects and pass them directly to css() // We define css variables, making them easy to override.
const styles = { ensureCSS(':root', {
color: ['black', '#ddd'], '--color': ['black', '#ddd'],
colorMild: ['#555', '#bbb'], '--colorMild': ['#555', '#bbb'],
colorMilder: ['#666', '#aaa'], '--colorMilder': ['#666', '#aaa'],
backgroundColor: ['white', '#222'], '--backgroundColor': ['white', '#222'],
backgroundColorMild: ['#f8f8f8', '#080808'], '--backgroundColorMild': ['#f8f8f8', '#080808'],
backgroundColorMilder: ['#999', '#777'], '--backgroundColorMilder': ['#999', '#777'],
borderColor: ['#ccc', '#333'], '--borderColor': ['#ccc', '#333'],
mailboxesTopBackgroundColor: ['#fdfdf1', 'rgb(26, 18, 0)'], '--mailboxesTopBackgroundColor': ['#fdfdf1', '#1a1200'],
msglistBackgroundColor: ['#f5ffff', 'rgb(4, 19, 13)'], '--msglistBackgroundColor': ['#f5ffff', '#04130d'],
boxShadow: ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'], '--boxShadow': ['0 0 20px rgba(0, 0, 0, 0.1)', '0px 0px 20px #000'],
buttonBackground: ['#eee', '#222'], '--buttonBackground': ['#eee', '#222'],
buttonBorderColor: ['#888', '#666'], '--buttonBorderColor': ['#888', '#666'],
buttonHoverBackground: ['#ddd', '#333'], '--buttonHoverBackground': ['#ddd', '#333'],
overlayOpaqueBackgroundColor: ['#eee', '#011'], '--overlayOpaqueBackgroundColor': ['#eee', '#011'],
overlayBackgroundColor: ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'], '--overlayBackgroundColor': ['rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)'],
popupColor: ['black', 'white'], '--popupColor': ['black', 'white'],
popupBackgroundColor: ['white', 'rgb(49, 50, 51)'], '--popupBackgroundColor': ['white', '#313233'],
popupBorderColor: ['#ccc', '#555'], '--popupBorderColor': ['#ccc', '#555'],
highlightBackground: ['gold', '#a70167'], '--highlightBackground': ['gold', '#a70167'],
highlightBorderColor: ['#8c7600', 'rgb(253, 31, 167)'], '--highlightBorderColor': ['#8c7600', '#fd1fa7'],
highlightBackgroundHover: ['#ffbd21', 'rgb(113, 4, 71)'], '--highlightBackgroundHover': ['#ffbd21', '#710447'],
mailboxActiveBackground: ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, rgb(182, 61, 0) 0%, rgb(140, 90, 13) 100%)'], '--mailboxActiveBackground': ['linear-gradient(135deg, #ffc7ab 0%, #ffdeab 100%)', 'linear-gradient(135deg, #b63d00 0%, #8c5a0d 100%)'],
mailboxHoverBackgroundColor: ['#eee', 'rgb(66, 31, 21)'], '--mailboxHoverBackgroundColor': ['#eee', '#421f15'],
msgItemActiveBackground: ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, rgb(4, 92, 172) 0%, rgb(2, 123, 160) 100%)'], '--msgItemActiveBackground': ['linear-gradient(135deg, #8bc8ff 0%, #8ee5ff 100%)', 'linear-gradient(135deg, #045cac 0%, #027ba0 100%)'],
msgItemHoverBackgroundColor: ['#eee', 'rgb(7, 51, 72)'], '--msgItemHoverBackgroundColor': ['#eee', '#073348'],
msgItemFocusBorderColor: ['#2685ff', '#2685ff'], '--msgItemFocusBorderColor': ['#2685ff', '#2685ff'],
buttonTristateOnBackground: ['#c4ffa9', 'rgb(39, 126, 0)'], '--buttonTristateOnBackground': ['#c4ffa9', '#277e00'],
buttonTristateOffBackground: ['#ffb192', 'rgb(191, 65, 15)'], '--buttonTristateOffBackground': ['#ffb192', '#bf410f'],
warningBackgroundColor: ['#ffca91', 'rgb(168, 87, 0)'], '--warningBackgroundColor': ['#ffca91', '#a85700'],
successBackground: ['#d2f791', '#1fa204'], '--successBackground': ['#d2f791', '#1fa204'],
emphasisBackground: ['#666', '#aaa'], '--emphasisBackground': ['#666', '#aaa'],
// For authentication/security results. // For authentication/security results.
underlineGreen: '#50c40f', '--underlineGreen': '#50c40f',
underlineRed: '#e15d1c', '--underlineRed': '#e15d1c',
underlineBlue: '#09f', '--underlineBlue': '#09f',
underlineGrey: '#888', '--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 = { const styleClasses = {
// For quoted text, with multiple levels of indentations. // For quoted text, with multiple levels of indentations.
quoted: [ quoted: [
css('quoted1', { color: ['#03828f', '#71f2ff'] }), css('quoted1', { color: styles.quoted1Color }),
css('quoted2', { color: ['#c7445c', 'rgb(236, 76, 76)'] }), css('quoted2', { color: styles.quoted2Color }),
css('quoted3', { color: ['#417c10', 'rgb(115, 230, 20)'] }), // blue css('quoted3', { color: styles.quoted3Color }),
], ],
// When text switches between unicode scripts. // 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 }), textMild: css('textMild', { color: styles.colorMild }),
// For keywords (also known as flags/labels/tags) on messages. // 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 }), 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('.msgHeaders td', { wordBreak: 'break-word' }); // Prevent horizontal scroll bar for long header values.
ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }), ensureCSS('.keyword.keywordCollapsed', { opacity: .75 }),
// Generic styling. // 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('.mono, .mono *', { fontFamily: "'ubuntu mono', monospace" });
ensureCSS('table td, table th', { padding: '.15em .25em' }); ensureCSS('table td, table th', { padding: '.15em .25em' });
ensureCSS('.pad', { padding: '.5em' }); ensureCSS('.pad', { padding: '.5em' });
ensureCSS('iframe', { border: 0 }); ensureCSS('iframe', { border: 0 });
ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' }); ensureCSS('img, embed, video, iframe', { backgroundColor: 'white', color: 'black' });
ensureCSS(':root', { backgroundColor: styles.backgroundColor, color: styles.color }); ensureCSS('a', { color: styles.linkColor });
ensureCSS('a', { color: ['rgb(9, 107, 194)', 'rgb(99, 182, 255)'] }); ensureCSS('a:visited', { color: styles.linkVisitedColor });
ensureCSS('a:visited', { color: ['rgb(7, 4, 193)', 'rgb(199, 99, 255)'] });
// For message view with multiple inline elements (often a single text and multiple messages). // For message view with multiple inline elements (often a single text and multiple messages).
ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] }); ensureCSS('.textmulti > *:nth-child(even)', { backgroundColor: ['#f4f4f4', '#141414'] });
ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ }); ensureCSS('.textmulti > *', { padding: '2ex .5em', margin: '-.5em' /* compensate pad */ });
@ -6781,6 +6835,9 @@ const init = async () => {
else { else {
selectLayout(layoutElem.value); selectLayout(layoutElem.value);
} }
if (window.moxBeforeDisplay) {
moxBeforeDisplay(webmailroot);
}
dom._kids(page, webmailroot); dom._kids(page, webmailroot);
checkMsglistWidth(); checkMsglistWidth();
window.addEventListener('resize', function () { window.addEventListener('resize', function () {

View file

@ -133,6 +133,8 @@ declare let page: HTMLElement
declare let moxversion: string declare let moxversion: string
declare let moxgoos: string declare let moxgoos: string
declare let moxgoarch: 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. // All logging goes through log() instead of console.log, except "should not happen" logging.
let log: (...args: any[]) => void = () => {} let log: (...args: any[]) => void = () => {}
@ -7057,6 +7059,9 @@ const init = async () => {
} else { } else {
selectLayout(layoutElem.value) selectLayout(layoutElem.value)
} }
if ((window as any).moxBeforeDisplay) {
moxBeforeDisplay(webmailroot)
}
dom._kids(page, webmailroot) dom._kids(page, webmailroot)
checkMsglistWidth() checkMsglistWidth()