From a9940f9855d430760c888a43001f347064340d51 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sun, 31 Dec 2023 11:55:22 +0100 Subject: [PATCH] change javascript into typescript for webaccount and webadmin interface all ui frontend code is now in typescript. we no longer need jshint, and we build the frontend code during "make build". this also changes tlsrpt types for a Report, not encoding field names with dashes, but to keep them valid identifiers in javascript. this makes it more conveniently to work with in the frontend, and works around a sherpats limitation. --- Makefile | 36 +- apidiff/v0.0.9.txt | 4 +- config/config.go | 2 +- develop.txt | 26 + gentestdata.go | 5 +- lib.ts | 218 ++ main.go | 9 +- mox-/webappfile.go | 208 ++ package-lock.json | 291 --- package.json | 1 - smtpserver/analyze.go | 7 +- tlsrpt/examples_test.go | 4 +- tlsrpt/report.go | 112 +- tlsrpt/report_test.go | 2 +- tlsrptdb/report_test.go | 13 +- tlsrptsend/send.go | 2 +- tlsrptsend/send_test.go | 4 +- tsc.sh | 5 +- webaccount/account.go | 46 +- webaccount/account.html | 709 +---- webaccount/account.js | 1065 ++++++++ webaccount/account.ts | 699 +++++ webaccount/account_test.go | 5 +- webaccount/{accountapi.json => api.json} | 26 + webaccount/api.ts | 525 ++++ webadmin/admin.go | 50 +- webadmin/admin.html | 2863 +-------------------- webadmin/admin.js | 2978 +++++++++++++++++++++ webadmin/admin.ts | 2982 ++++++++++++++++++++++ webadmin/{adminapi.json => api.json} | 78 +- webadmin/api.ts | 1879 ++++++++++++++ webmail/lib.ts | 212 -- webmail/msg.js | 436 ++-- webmail/text.js | 436 ++-- webmail/view.go | 2 +- webmail/webmail.go | 182 +- webmail/webmail.js | 436 ++-- 37 files changed, 11539 insertions(+), 5019 deletions(-) create mode 100644 lib.ts create mode 100644 mox-/webappfile.go create mode 100644 webaccount/account.js create mode 100644 webaccount/account.ts rename webaccount/{accountapi.json => api.json} (89%) create mode 100644 webaccount/api.ts create mode 100644 webadmin/admin.js create mode 100644 webadmin/admin.ts rename webadmin/{adminapi.json => api.json} (98%) create mode 100644 webadmin/api.ts diff --git a/Makefile b/Makefile index f8c7799..d9ffd64 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,22 @@ default: build -build: +build: build0 frontend build1 + +build0: # build early to catch syntax errors CGO_ENABLED=0 go build CGO_ENABLED=0 go vet ./... CGO_ENABLED=0 go vet -tags integration ./gendoc.sh - (cd webadmin && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Admin) >webadmin/adminapi.json - (cd webaccount && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Account) >webaccount/accountapi.json + (cd webadmin && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Admin) >webadmin/api.json + (cd webaccount && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Account) >webaccount/api.json (cd webmail && CGO_ENABLED=0 go run ../vendor/github.com/mjl-/sherpadoc/cmd/sherpadoc/*.go -adjust-function-names none Webmail) >webmail/api.json - go run vendor/github.com/mjl-/sherpats/cmd/sherpats/main.go -bytes-to-string -slices-nullable -maps-nullable -nullable-optional -namespace api api webmail/api.ts - # build again, api json files above are embedded + ./gents.sh webadmin/api.json webadmin/api.ts + ./gents.sh webaccount/api.json webaccount/api.ts + ./gents.sh webmail/api.json webmail/api.ts + +build1: + # build again, api json files above are embedded and new frontend code generated CGO_ENABLED=0 go build test: @@ -75,7 +81,7 @@ fmt: gofmt -w -s *.go */*.go jswatch: - bash -c 'while true; do inotifywait -q -e close_write webadmin/*.html webaccount/*.html webmail/*.ts; make frontend; done' + bash -c 'while true; do inotifywait -q -e close_write *.ts webadmin/*.ts webaccount/*.ts webmail/*.ts; make frontend; done' jsinstall: -mkdir -p node_modules/.bin @@ -83,24 +89,24 @@ jsinstall: jsinstall0: -mkdir -p node_modules/.bin - npm install --save-dev --save-exact jshint@2.13.6 typescript@5.1.6 + npm install --save-dev --save-exact typescript@5.1.6 -webmail/webmail.js: webmail/api.ts webmail/lib.ts webmail/webmail.ts +webmail/webmail.js: lib.ts webmail/api.ts webmail/lib.ts webmail/webmail.ts ./tsc.sh $@ $^ -webmail/msg.js: webmail/api.ts webmail/lib.ts webmail/msg.ts +webmail/msg.js: lib.ts webmail/api.ts webmail/lib.ts webmail/msg.ts ./tsc.sh $@ $^ -webmail/text.js: webmail/api.ts webmail/lib.ts webmail/text.ts +webmail/text.js: lib.ts webmail/api.ts webmail/lib.ts webmail/text.ts ./tsc.sh $@ $^ -webadmin/admin.htmlx: - ./node_modules/.bin/jshint --extract always webadmin/admin.html | ./fixjshintlines.sh +webadmin/admin.js: lib.ts webadmin/api.ts webadmin/admin.ts + ./tsc.sh $@ $^ -webaccount/account.htmlx: - ./node_modules/.bin/jshint --extract always webaccount/account.html | ./fixjshintlines.sh +webaccount/account.js: lib.ts webaccount/api.ts webaccount/account.ts + ./tsc.sh $@ $^ -frontend: webadmin/admin.htmlx webaccount/account.htmlx webmail/webmail.js webmail/msg.js webmail/text.js +frontend: webadmin/admin.js webaccount/account.js webmail/webmail.js webmail/msg.js webmail/text.js genapidiff: # needs file next.txt containing next version number, and golang.org/x/exp/cmd/apidiff@v0.0.0-20231206192017-f3f8817b8deb installed diff --git a/apidiff/v0.0.9.txt b/apidiff/v0.0.9.txt index e949119..799d0ce 100644 --- a/apidiff/v0.0.9.txt +++ b/apidiff/v0.0.9.txt @@ -71,8 +71,10 @@ Below are the incompatible changes between v0.0.8 and v0.0.9, per package. - Verify: changed from func(*github.com/mjl-/mox/mlog.Log, io.ReaderAt, []byte, time.Duration) error to func(*golang.org/x/exp/slog.Logger, io.ReaderAt, []byte, time.Duration) error # tlsrpt +- (*TLSRPTDateRange).UnmarshalJSON: removed - Lookup: changed from func(context.Context, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (*Record, string, error) to func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain) (*Record, string, error) -- ParseMessage: changed from func(*github.com/mjl-/mox/mlog.Log, io.ReaderAt) (*Report, error) to func(*golang.org/x/exp/slog.Logger, io.ReaderAt) (*Report, error) +- Parse: changed from func(io.Reader) (*Report, error) to func(io.Reader) (*ReportJSON, error) +- ParseMessage: changed from func(*github.com/mjl-/mox/mlog.Log, io.ReaderAt) (*Report, error) to func(*golang.org/x/exp/slog.Logger, io.ReaderAt) (*ReportJSON, error) # updates - Check: changed from func(context.Context, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, Version, string, []byte) (Version, *Record, *Changelog, error) to func(context.Context, *golang.org/x/exp/slog.Logger, github.com/mjl-/mox/dns.Resolver, github.com/mjl-/mox/dns.Domain, Version, string, []byte) (Version, *Record, *Changelog, error) diff --git a/config/config.go b/config/config.go index ee49ad5..ae685bc 100644 --- a/config/config.go +++ b/config/config.go @@ -112,7 +112,7 @@ type Dynamic struct { WebHandlers []WebHandler `sconf:"optional" sconf-doc:"Handle webserver requests by serving static files, redirecting or reverse-proxying HTTP(s). The first matching WebHandler will handle the request. Built-in handlers, e.g. for account, admin, autoconfig and mta-sts always run first. If no handler matches, the response status code is file not found (404). If functionality you need is missng, simply forward the requests to an application that can provide the needed functionality."` Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates account routes, domain routes and finally these global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."` - WebDNSDomainRedirects map[dns.Domain]dns.Domain `sconf:"-"` + WebDNSDomainRedirects map[dns.Domain]dns.Domain `sconf:"-" json:"-"` } type ACME struct { diff --git a/develop.txt b/develop.txt index d001fdc..37170d3 100644 --- a/develop.txt +++ b/develop.txt @@ -28,6 +28,32 @@ with bad API. Third party users aren't affected too seriously due to Go's minimal version selection. The reusable packages are in apidiff/packages.txt. We generate the incompatible changes with each release. +# Web interfaces/frontend + +The web interface frontends (for webmail/, webadmin/ and webaccount/) are +written in strict TypeScript. The web API is a simple self-documenting +HTTP/JSON RPC API mechanism called sherpa, +https://www.ueber.net/who/mjl/sherpa/. The web API exposes types and functions +as implemented in Go, using https://github.com/mjl-/sherpa. API definitions in +JSON form are generated with https://github.com/mjl-/sherpadoc. Those API +definitions are used to generate TypeScript clients with by +https://github.com/mjl-/sherpats/. + +The JavaScript that is generated from the TypeScript is included in the +repository. This makes it available for inclusion in the binary, which is +practical for users, and desirable given Go's reproducible builds. When +developing, run "make" to also build the frontend code. Run "make jsinstall" +once to install the TypeScript compiler into ./node_modules/. + +There are no other external (runtime or devtime) frontend dependencies. A +light-weight abstraction over the DOM is provided by ./lib.ts. A bit more +manual UI state management must be done compared to "frameworks", but it is +little code, and this allows JavaScript/TypeScript developer to quickly get +started. UI state is often encapsulated in a JavaScript object with a +TypeScript interface exposing a "root" HTMLElement that is added to the DOM, +and functions for accessing/changing the internal state, keeping the UI +managable. + # TLS certificates https://github.com/cloudflare/cfssl is useful for testing with TLS diff --git a/gentestdata.go b/gentestdata.go index ef5d40b..f1d9441 100644 --- a/gentestdata.go +++ b/gentestdata.go @@ -214,9 +214,10 @@ Accounts: // Populate tlsrpt.db. err = tlsrptdb.Init() xcheckf(err, "tlsrptdb init") - tlsr, err := tlsrpt.Parse(strings.NewReader(tlsReport)) + tlsreportJSON, err := tlsrpt.Parse(strings.NewReader(tlsReport)) xcheckf(err, "parsing tls report") - err = tlsrptdb.AddReport(ctxbg, c.log, dns.Domain{ASCII: "mox.example"}, "tlsrpt@mox.example", false, tlsr) + tlsr := tlsreportJSON.Convert() + err = tlsrptdb.AddReport(ctxbg, c.log, dns.Domain{ASCII: "mox.example"}, "tlsrpt@mox.example", false, &tlsr) xcheckf(err, "adding tls report") // Populate queue, with a message. diff --git a/lib.ts b/lib.ts new file mode 100644 index 0000000..87f59f0 --- /dev/null +++ b/lib.ts @@ -0,0 +1,218 @@ +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. + +type ElemArg = string | String | Element | Function | {_class: string[]} | {_attrs: {[k: string]: string}} | {_styles: {[k: string]: string | number}} | {_props: {[k: string]: any}} | {root: HTMLElement} | ElemArg[] + +const [dom, style, attr, prop] = (function() { + +// Start of unicode block (rough approximation of script), from https://www.unicode.org/Public/UNIDATA/Blocks.txt +const scriptblocks = [0x0000, 0x0080, 0x0100, 0x0180, 0x0250, 0x02B0, 0x0300, 0x0370, 0x0400, 0x0500, 0x0530, 0x0590, 0x0600, 0x0700, 0x0750, 0x0780, 0x07C0, 0x0800, 0x0840, 0x0860, 0x0870, 0x08A0, 0x0900, 0x0980, 0x0A00, 0x0A80, 0x0B00, 0x0B80, 0x0C00, 0x0C80, 0x0D00, 0x0D80, 0x0E00, 0x0E80, 0x0F00, 0x1000, 0x10A0, 0x1100, 0x1200, 0x1380, 0x13A0, 0x1400, 0x1680, 0x16A0, 0x1700, 0x1720, 0x1740, 0x1760, 0x1780, 0x1800, 0x18B0, 0x1900, 0x1950, 0x1980, 0x19E0, 0x1A00, 0x1A20, 0x1AB0, 0x1B00, 0x1B80, 0x1BC0, 0x1C00, 0x1C50, 0x1C80, 0x1C90, 0x1CC0, 0x1CD0, 0x1D00, 0x1D80, 0x1DC0, 0x1E00, 0x1F00, 0x2000, 0x2070, 0x20A0, 0x20D0, 0x2100, 0x2150, 0x2190, 0x2200, 0x2300, 0x2400, 0x2440, 0x2460, 0x2500, 0x2580, 0x25A0, 0x2600, 0x2700, 0x27C0, 0x27F0, 0x2800, 0x2900, 0x2980, 0x2A00, 0x2B00, 0x2C00, 0x2C60, 0x2C80, 0x2D00, 0x2D30, 0x2D80, 0x2DE0, 0x2E00, 0x2E80, 0x2F00, 0x2FF0, 0x3000, 0x3040, 0x30A0, 0x3100, 0x3130, 0x3190, 0x31A0, 0x31C0, 0x31F0, 0x3200, 0x3300, 0x3400, 0x4DC0, 0x4E00, 0xA000, 0xA490, 0xA4D0, 0xA500, 0xA640, 0xA6A0, 0xA700, 0xA720, 0xA800, 0xA830, 0xA840, 0xA880, 0xA8E0, 0xA900, 0xA930, 0xA960, 0xA980, 0xA9E0, 0xAA00, 0xAA60, 0xAA80, 0xAAE0, 0xAB00, 0xAB30, 0xAB70, 0xABC0, 0xAC00, 0xD7B0, 0xD800, 0xDB80, 0xDC00, 0xE000, 0xF900, 0xFB00, 0xFB50, 0xFE00, 0xFE10, 0xFE20, 0xFE30, 0xFE50, 0xFE70, 0xFF00, 0xFFF0, 0x10000, 0x10080, 0x10100, 0x10140, 0x10190, 0x101D0, 0x10280, 0x102A0, 0x102E0, 0x10300, 0x10330, 0x10350, 0x10380, 0x103A0, 0x10400, 0x10450, 0x10480, 0x104B0, 0x10500, 0x10530, 0x10570, 0x10600, 0x10780, 0x10800, 0x10840, 0x10860, 0x10880, 0x108E0, 0x10900, 0x10920, 0x10980, 0x109A0, 0x10A00, 0x10A60, 0x10A80, 0x10AC0, 0x10B00, 0x10B40, 0x10B60, 0x10B80, 0x10C00, 0x10C80, 0x10D00, 0x10E60, 0x10E80, 0x10EC0, 0x10F00, 0x10F30, 0x10F70, 0x10FB0, 0x10FE0, 0x11000, 0x11080, 0x110D0, 0x11100, 0x11150, 0x11180, 0x111E0, 0x11200, 0x11280, 0x112B0, 0x11300, 0x11400, 0x11480, 0x11580, 0x11600, 0x11660, 0x11680, 0x11700, 0x11800, 0x118A0, 0x11900, 0x119A0, 0x11A00, 0x11A50, 0x11AB0, 0x11AC0, 0x11B00, 0x11C00, 0x11C70, 0x11D00, 0x11D60, 0x11EE0, 0x11F00, 0x11FB0, 0x11FC0, 0x12000, 0x12400, 0x12480, 0x12F90, 0x13000, 0x13430, 0x14400, 0x16800, 0x16A40, 0x16A70, 0x16AD0, 0x16B00, 0x16E40, 0x16F00, 0x16FE0, 0x17000, 0x18800, 0x18B00, 0x18D00, 0x1AFF0, 0x1B000, 0x1B100, 0x1B130, 0x1B170, 0x1BC00, 0x1BCA0, 0x1CF00, 0x1D000, 0x1D100, 0x1D200, 0x1D2C0, 0x1D2E0, 0x1D300, 0x1D360, 0x1D400, 0x1D800, 0x1DF00, 0x1E000, 0x1E030, 0x1E100, 0x1E290, 0x1E2C0, 0x1E4D0, 0x1E7E0, 0x1E800, 0x1E900, 0x1EC70, 0x1ED00, 0x1EE00, 0x1F000, 0x1F030, 0x1F0A0, 0x1F100, 0x1F200, 0x1F300, 0x1F600, 0x1F650, 0x1F680, 0x1F700, 0x1F780, 0x1F800, 0x1F900, 0x1FA00, 0x1FA70, 0x1FB00, 0x20000, 0x2A700, 0x2B740, 0x2B820, 0x2CEB0, 0x2F800, 0x30000, 0x31350, 0xE0000, 0xE0100, 0xF0000, 0x100000] + +// Find block code belongs in. +const findBlock = (code: number): number => { + let s = 0 + let e = scriptblocks.length + while (s < e-1) { + let i = Math.floor((s+e)/2) + if (code < scriptblocks[i]) { + e = i + } else { + s = i + } + } + return s +} + +// formatText adds s to element e, in a way that makes switching unicode scripts +// clear, with alternating DOM TextNode and span elements with a "switchscript" +// class. Useful for highlighting look alikes, e.g. a (ascii 0x61) and а (cyrillic +// 0x430). +// +// This is only called one string at a time, so the UI can still display strings +// without highlighting switching scripts, by calling formatText on the parts. +const formatText = (e: HTMLElement, s: string): void => { + // Handle some common cases quickly. + if (!s) { + return + } + let ascii = true + for (const c of s) { + const cp = c.codePointAt(0) // For typescript, to check for undefined. + if (cp !== undefined && cp >= 0x0080) { + ascii = false + break + } + } + if (ascii) { + e.appendChild(document.createTextNode(s)) + return + } + + // todo: handle grapheme clusters? wait for Intl.Segmenter? + + let n = 0 // Number of text/span parts added. + let str = '' // Collected so far. + let block = -1 // Previous block/script. + let mod = 1 + const put = (nextblock: number) => { + if (n === 0 && nextblock === 0) { + // Start was non-ascii, second block is ascii, we'll start marked as switched. + mod = 0 + } + if (n % 2 === mod) { + const x = document.createElement('span') + x.classList.add('scriptswitch') + x.appendChild(document.createTextNode(str)) + e.appendChild(x) + } else { + e.appendChild(document.createTextNode(str)) + } + n++ + str = '' + } + for (const c of s) { + // Basic whitespace does not switch blocks. Will probably need to extend with more + // punctuation in the future. Possibly for digits too. But perhaps not in all + // scripts. + if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { + str += c + continue + } + const code: number = c.codePointAt(0) as number + if (block < 0 || !(code >= scriptblocks[block] && (code < scriptblocks[block+1] || block === scriptblocks.length-1))) { + const nextblock = code < 0x0080 ? 0 : findBlock(code) + if (block >= 0) { + put(nextblock) + } + block = nextblock + } + str += c + } + put(-1) +} + +const _domKids = (e: T, l: ElemArg[]): T => { + l.forEach((c) => { + const xc = c as {[k: string]: any} + if (typeof c === 'string') { + formatText(e, c) + } else if (c instanceof String) { + // String is an escape-hatch for text that should not be formatted with + // unicode-block-change-highlighting, e.g. for textarea values. + e.appendChild(document.createTextNode(''+c)) + } else if (c instanceof Element) { + e.appendChild(c) + } else if (c instanceof Function) { + if (!c.name) { + throw new Error('function without name') + } + e.addEventListener(c.name as string, c as EventListener) + } else if (Array.isArray(xc)) { + _domKids(e, c as ElemArg[]) + } else if (xc._class) { + for (const s of xc._class) { + e.classList.toggle(s, true) + } + } else if (xc._attrs) { + for (const k in xc._attrs) { + e.setAttribute(k, xc._attrs[k]) + } + } else if (xc._styles) { + for (const k in xc._styles) { + const estyle: {[k: string]: any} = e.style + estyle[k as string] = xc._styles[k] + } + } else if (xc._props) { + for (const k in xc._props) { + const eprops: {[k: string]: any} = e + eprops[k] = xc._props[k] + } + } else if (xc.root) { + e.appendChild(xc.root) + } else { + console.log('bad kid', c) + throw new Error('bad kid') + } + }) + return e +} +const dom = { + _kids: function(e: HTMLElement, ...kl: ElemArg[]) { + while(e.firstChild) { + e.removeChild(e.firstChild) + } + _domKids(e, kl) + }, + _attrs: (x: {[k: string]: string}) => { return {_attrs: x}}, + _class: (...x: string[]) => { return {_class: x}}, + // The createElement calls are spelled out so typescript can derive function + // signatures with a specific HTML*Element return type. + div: (...l: ElemArg[]) => _domKids(document.createElement('div'), l), + span: (...l: ElemArg[]) => _domKids(document.createElement('span'), l), + a: (...l: ElemArg[]) => _domKids(document.createElement('a'), l), + input: (...l: ElemArg[]) => _domKids(document.createElement('input'), l), + textarea: (...l: ElemArg[]) => _domKids(document.createElement('textarea'), l), + select: (...l: ElemArg[]) => _domKids(document.createElement('select'), l), + option: (...l: ElemArg[]) => _domKids(document.createElement('option'), l), + clickbutton: (...l: ElemArg[]) => _domKids(document.createElement('button'), [attr.type('button'), ...l]), + submitbutton: (...l: ElemArg[]) => _domKids(document.createElement('button'), [attr.type('submit'), ...l]), + form: (...l: ElemArg[]) => _domKids(document.createElement('form'), l), + fieldset: (...l: ElemArg[]) => _domKids(document.createElement('fieldset'), l), + table: (...l: ElemArg[]) => _domKids(document.createElement('table'), l), + thead: (...l: ElemArg[]) => _domKids(document.createElement('thead'), l), + tbody: (...l: ElemArg[]) => _domKids(document.createElement('tbody'), l), + tfoot: (...l: ElemArg[]) => _domKids(document.createElement('tfoot'), l), + tr: (...l: ElemArg[]) => _domKids(document.createElement('tr'), l), + td: (...l: ElemArg[]) => _domKids(document.createElement('td'), l), + th: (...l: ElemArg[]) => _domKids(document.createElement('th'), l), + datalist: (...l: ElemArg[]) => _domKids(document.createElement('datalist'), l), + h1: (...l: ElemArg[]) => _domKids(document.createElement('h1'), l), + h2: (...l: ElemArg[]) => _domKids(document.createElement('h2'), l), + h3: (...l: ElemArg[]) => _domKids(document.createElement('h3'), l), + br: (...l: ElemArg[]) => _domKids(document.createElement('br'), l), + hr: (...l: ElemArg[]) => _domKids(document.createElement('hr'), l), + pre: (...l: ElemArg[]) => _domKids(document.createElement('pre'), l), + label: (...l: ElemArg[]) => _domKids(document.createElement('label'), l), + ul: (...l: ElemArg[]) => _domKids(document.createElement('ul'), l), + li: (...l: ElemArg[]) => _domKids(document.createElement('li'), l), + iframe: (...l: ElemArg[]) => _domKids(document.createElement('iframe'), l), + b: (...l: ElemArg[]) => _domKids(document.createElement('b'), l), + img: (...l: ElemArg[]) => _domKids(document.createElement('img'), l), + style: (...l: ElemArg[]) => _domKids(document.createElement('style'), l), + search: (...l: ElemArg[]) => _domKids(document.createElement('search'), l), + p: (...l: ElemArg[]) => _domKids(document.createElement('p'), l), +} +const _attr = (k: string, v: string) => { const o: {[key: string]: string} = {}; o[k] = v; return {_attrs: o} } +const attr = { + title: (s: string) => _attr('title', s), + value: (s: string) => _attr('value', s), + type: (s: string) => _attr('type', s), + tabindex: (s: string) => _attr('tabindex', s), + src: (s: string) => _attr('src', s), + placeholder: (s: string) => _attr('placeholder', s), + href: (s: string) => _attr('href', s), + checked: (s: string) => _attr('checked', s), + selected: (s: string) => _attr('selected', s), + id: (s: string) => _attr('id', s), + datalist: (s: string) => _attr('datalist', s), + rows: (s: string) => _attr('rows', s), + target: (s: string) => _attr('target', s), + rel: (s: string) => _attr('rel', s), + required: (s: string) => _attr('required', s), + multiple: (s: string) => _attr('multiple', s), + download: (s: string) => _attr('download', s), + disabled: (s: string) => _attr('disabled', s), + draggable: (s: string) => _attr('draggable', s), + rowspan: (s: string) => _attr('rowspan', s), + colspan: (s: string) => _attr('colspan', s), + for: (s: string) => _attr('for', s), + role: (s: string) => _attr('role', s), + arialabel: (s: string) => _attr('aria-label', s), + arialive: (s: string) => _attr('aria-live', s), + name: (s: string) => _attr('name', s), + min: (s: string) => _attr('min', s), + max: (s: string) => _attr('max', s), +} +const style = (x: {[k: string]: string | number}) => { return {_styles: x}} +const prop = (x: {[k: string]: any}) => { return {_props: x}} +return [dom, style, attr, prop] +})() diff --git a/main.go b/main.go index 31d1738..0dcff24 100644 --- a/main.go +++ b/main.go @@ -2599,12 +2599,12 @@ The report is printed in formatted JSON. for _, arg := range args { f, err := os.Open(arg) xcheckf(err, "open %q", arg) - report, err := tlsrpt.ParseMessage(c.log.Logger, f) + reportJSON, err := tlsrpt.ParseMessage(c.log.Logger, f) xcheckf(err, "parse report in %q", arg) // todo future: only print the highlights? enc := json.NewEncoder(os.Stdout) enc.SetIndent("", "\t") - err = enc.Encode(report) + err = enc.Encode(reportJSON) xcheckf(err, "write report") } } @@ -2754,11 +2754,12 @@ func cmdTLSRPTDBAddReport(c *cmd) { from := part.Envelope.From[0] domain := xparseDomain(from.Host, "domain") - report, err := tlsrpt.ParseMessage(c.log.Logger, bytes.NewReader(buf)) + reportJSON, err := tlsrpt.ParseMessage(c.log.Logger, bytes.NewReader(buf)) xcheckf(err, "parsing tls report in message") mailfrom := from.User + "@" + from.Host // todo future: should escape and such - err = tlsrptdb.AddReport(context.Background(), c.log, domain, mailfrom, hostReport, report) + report := reportJSON.Convert() + err = tlsrptdb.AddReport(context.Background(), c.log, domain, mailfrom, hostReport, &report) xcheckf(err, "add tls report to database") } diff --git a/mox-/webappfile.go b/mox-/webappfile.go new file mode 100644 index 0000000..3545580 --- /dev/null +++ b/mox-/webappfile.go @@ -0,0 +1,208 @@ +package mox + +import ( + "bytes" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" + + "golang.org/x/exp/slog" + + "github.com/mjl-/mox/mlog" + "github.com/mjl-/mox/moxvar" +) + +// WebappFile serves a merged HTML and JS webapp as a single compressed, cacheable +// file. It merges the JS into the HTML at first load, caches a gzipped version +// that is generated on first need, and responds with a Last-Modified header. +type WebappFile struct { + HTML, JS []byte // Embedded html/js data. + HTMLPath, JSPath string // Paths to load html/js from during development. + + sync.Mutex + combined []byte + combinedGzip []byte + mtime time.Time // For Last-Modified and conditional request. +} + +// FallbackMtime returns a time to use for the Last-Modified header in case we +// cannot find a file, e.g. when used in production. +func FallbackMtime(log mlog.Log) time.Time { + p, err := os.Executable() + log.Check(err, "finding executable for mtime") + if err == nil { + st, err := os.Stat(p) + log.Check(err, "stat on executable for mtime") + if err == nil { + return st.ModTime() + } + } + log.Info("cannot find executable for webappfile mtime, using current time") + return time.Now() +} + +func (a *WebappFile) serverError(log mlog.Log, w http.ResponseWriter, err error, action string) { + log.Errorx("serve webappfile", err, slog.String("msg", action)) + http.Error(w, "500 - internal server error", http.StatusInternalServerError) +} + +// Serve serves a combined file, with headers for caching and possibly gzipped. +func (a *WebappFile) Serve(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *http.Request) { + // We typically return the embedded file, but during development it's handy + // to load from disk. + fhtml, _ := os.Open(a.HTMLPath) + if fhtml != nil { + defer fhtml.Close() + } + fjs, _ := os.Open(a.JSPath) + if fjs != nil { + defer fjs.Close() + } + + html := a.HTML + js := a.JS + + var diskmtime time.Time + var refreshdisk bool + if fhtml != nil && fjs != nil { + sth, err := fhtml.Stat() + if err != nil { + a.serverError(log, w, err, "stat html") + return + } + stj, err := fjs.Stat() + if err != nil { + a.serverError(log, w, err, "stat js") + return + } + + maxmtime := sth.ModTime() + if stj.ModTime().After(maxmtime) { + maxmtime = stj.ModTime() + } + + a.Lock() + refreshdisk = maxmtime.After(a.mtime) || a.combined == nil + a.Unlock() + + if refreshdisk { + html, err = io.ReadAll(fhtml) + if err != nil { + a.serverError(log, w, err, "reading html") + return + } + js, err = io.ReadAll(fjs) + if err != nil { + a.serverError(log, w, err, "reading js") + return + } + diskmtime = maxmtime + } + } + + gz := AcceptsGzip(r) + var out []byte + var mtime time.Time + var origSize int64 + + func() { + a.Lock() + defer a.Unlock() + + if refreshdisk || a.combined == nil { + script := []byte(``) + index := bytes.Index(html, script) + if index < 0 { + a.serverError(log, w, errors.New("script not found"), "generating combined html") + return + } + var b bytes.Buffer + b.Write(html[:index]) + fmt.Fprintf(&b, "") + b.Write(html[index+len(script):]) + out = b.Bytes() + a.combined = out + if refreshdisk { + a.mtime = diskmtime + } else { + a.mtime = FallbackMtime(log) + } + a.combinedGzip = nil + } else { + out = a.combined + } + if gz { + if a.combinedGzip == nil { + var b bytes.Buffer + gzw, err := gzip.NewWriterLevel(&b, gzip.BestCompression) + if err == nil { + _, err = gzw.Write(out) + } + if err == nil { + err = gzw.Close() + } + if err != nil { + a.serverError(log, w, err, "gzipping combined html") + return + } + a.combinedGzip = b.Bytes() + } + origSize = int64(len(out)) + out = a.combinedGzip + } + mtime = a.mtime + }() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + http.ServeContent(gzipInjector{w, gz, origSize}, r, "", mtime, bytes.NewReader(out)) +} + +// gzipInjector is a http.ResponseWriter that optionally injects a +// Content-Encoding: gzip header, only in case of status 200 OK. Used with +// http.ServeContent to serve gzipped content if the client supports it. We cannot +// just unconditionally add the content-encoding header, because we don't know +// enough if we will be sending data: http.ServeContent may be sending a "not +// modified" response, and possibly others. +type gzipInjector struct { + http.ResponseWriter // Keep most methods. + gz bool + origSize int64 +} + +// WriteHeader adds a Content-Encoding: gzip header before actually writing the +// headers and status. +func (w gzipInjector) WriteHeader(statusCode int) { + if w.gz && statusCode == http.StatusOK { + w.ResponseWriter.Header().Set("Content-Encoding", "gzip") + if lw, ok := w.ResponseWriter.(interface{ SetUncompressedSize(int64) }); ok { + lw.SetUncompressedSize(w.origSize) + } + } + w.ResponseWriter.WriteHeader(statusCode) +} + +// AcceptsGzip returns whether the client accepts gzipped responses. +func AcceptsGzip(r *http.Request) bool { + s := r.Header.Get("Accept-Encoding") + t := strings.Split(s, ",") + for _, e := range t { + e = strings.TrimSpace(e) + tt := strings.Split(e, ";") + if len(tt) > 1 && t[1] == "q=0" { + continue + } + if tt[0] == "gzip" { + return true + } + } + return false +} diff --git a/package-lock.json b/package-lock.json index d520d9f..0d1aa25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,294 +5,9 @@ "packages": { "": { "devDependencies": { - "jshint": "2.13.6", "typescript": "5.1.6" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/cli": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz", - "integrity": "sha512-41U72MB56TfUMGndAKK8vJ78eooOD4Z5NOL4xEfjc0c23s+6EYKXlXsmACBVclLP1yOfWCgEganVzddVrSNoTg==", - "dev": true, - "dependencies": { - "exit": "0.1.2", - "glob": "^7.1.1" - }, - "engines": { - "node": ">=0.2.5" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/console-browserify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", - "integrity": "sha512-duS7VP5pvfsNLDvL1O4VOEbw37AI3A4ZUQYemvDlnpGrNu9tprR7BYWpDYwC0Xia0Zxz5ZupdiIrUp0GH1aXfg==", - "dev": true, - "dependencies": { - "date-now": "^0.1.4" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true - }, - "node_modules/date-now": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", - "integrity": "sha512-AsElvov3LoNB7tf5k37H2jYSB+ZZPMT5sG2QjJCcdlV5chIv6htBUBUui2IKRjgtKAKtCBN7Zbwa+MtwLjSeNw==", - "dev": true - }, - "node_modules/dom-serializer": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", - "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", - "dev": true, - "dependencies": { - "domelementtype": "^2.0.1", - "entities": "^2.0.0" - } - }, - "node_modules/dom-serializer/node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ] - }, - "node_modules/dom-serializer/node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "dev": true, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", - "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", - "dev": true - }, - "node_modules/domhandler": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz", - "integrity": "sha512-q9bUwjfp7Eif8jWxxxPSykdRZAb6GkguBGSgvvCrhI9wB71W2K/Kvv4E61CF/mcCfnVJDeDWx/Vb/uAqbDj6UQ==", - "dev": true, - "dependencies": { - "domelementtype": "1" - } - }, - "node_modules/domutils": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", - "integrity": "sha512-gSu5Oi/I+3wDENBsOWBiRK1eoGxcywYSqg3rR960/+EfY0CF4EX1VPkgHOZ3WiS/Jg2DtliF6BhWcHlfpYUcGw==", - "dev": true, - "dependencies": { - "dom-serializer": "0", - "domelementtype": "1" - } - }, - "node_modules/entities": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", - "integrity": "sha512-LbLqfXgJMmy81t+7c14mnulFHJ170cM6E+0vMXR9k/ZiZwgX8i5pNgjTCX3SO4VeUsFLV+8InixoretwU+MjBQ==", - "dev": true - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/htmlparser2": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", - "integrity": "sha512-hBxEg3CYXe+rPIua8ETe7tmG3XDn9B0edOE/e9wH2nLczxzgdu0m0aNHY+5wFZiviLWLdANPJTssa92dMcXQ5Q==", - "dev": true, - "dependencies": { - "domelementtype": "1", - "domhandler": "2.3", - "domutils": "1.5", - "entities": "1.0", - "readable-stream": "1.1" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true - }, - "node_modules/jshint": { - "version": "2.13.6", - "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.13.6.tgz", - "integrity": "sha512-IVdB4G0NTTeQZrBoM8C5JFVLjV2KtZ9APgybDA1MK73xb09qFs0jCXyQLnCOp1cSZZZbvhq/6mfXHUTaDkffuQ==", - "dev": true, - "dependencies": { - "cli": "~1.0.0", - "console-browserify": "1.1.x", - "exit": "0.1.x", - "htmlparser2": "3.8.x", - "lodash": "~4.17.21", - "minimatch": "~3.0.2", - "strip-json-comments": "1.0.x" - }, - "bin": { - "jshint": "bin/jshint" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true - }, - "node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", - "dev": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", - "dev": true - }, - "node_modules/strip-json-comments": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz", - "integrity": "sha512-AOPG8EBc5wAikaG1/7uFCNFJwnKOuQwFTpYBdTW6OvWHeZBQBrAA/amefHGrEiOnCPcLFZK6FUPtWVKpQVIRgg==", - "dev": true, - "bin": { - "strip-json-comments": "cli.js" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/typescript": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", @@ -305,12 +20,6 @@ "engines": { "node": ">=14.17" } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true } } } diff --git a/package.json b/package.json index 880fe1a..d664a3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,5 @@ { "devDependencies": { - "jshint": "2.13.6", "typescript": "5.1.6" } } diff --git a/smtpserver/analyze.go b/smtpserver/analyze.go index 0f22819..02097ec 100644 --- a/smtpserver/analyze.go +++ b/smtpserver/analyze.go @@ -257,12 +257,12 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver if !ok { log.Info("received mail to tlsrpt without acceptable DKIM signature, not processing as tls report") headers += "X-Mox-TLSReport-Error: no acceptable DKIM signature\r\n" - } else if report, err := tlsrpt.ParseMessage(log.Logger, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil { + } else if reportJSON, err := tlsrpt.ParseMessage(log.Logger, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil { log.Infox("parsing tls report", err) headers += "X-Mox-TLSReport-Error: could not parse TLS report\r\n" } else { var known bool - for _, p := range report.Policies { + for _, p := range reportJSON.Policies { log.Info("tlsrpt policy domain", slog.String("domain", p.Policy.Domain)) if d, err := dns.ParseDomain(p.Policy.Domain); err != nil { log.Infox("parsing domain in tls report", err) @@ -275,7 +275,8 @@ func analyze(ctx context.Context, log mlog.Log, resolver dns.Resolver, d deliver log.Info("tls report without one of configured domains, ignoring") headers += "X-Mox-TLSReport-Error: report for unknown domain\r\n" } else { - tlsReport = report + report := reportJSON.Convert() + tlsReport = &report } } } diff --git a/tlsrpt/examples_test.go b/tlsrpt/examples_test.go index 1bbdaa8..0fccb2a 100644 --- a/tlsrpt/examples_test.go +++ b/tlsrpt/examples_test.go @@ -59,10 +59,10 @@ hg9Oqa4bc7N46W67vwF2Eq+hDAYAAA== msg = strings.ReplaceAll(msg, "\n", "\r\n") // Parse the email message, and the TLSRPT report within. - report, err := tlsrpt.ParseMessage(slog.Default(), strings.NewReader(msg)) + reportJSON, err := tlsrpt.ParseMessage(slog.Default(), strings.NewReader(msg)) if err != nil { log.Fatalf("parsing tlsrpt report in message: %v", err) } - log.Printf("report: %#v", report) + log.Printf("report: %#v", reportJSON) } diff --git a/tlsrpt/report.go b/tlsrpt/report.go index ca6dbf2..00a7b05 100644 --- a/tlsrpt/report.go +++ b/tlsrpt/report.go @@ -31,13 +31,41 @@ var ErrNoReport = errors.New("no tlsrpt report found") // ../rfc/8460:628 -// Report is a TLSRPT report, transmitted in JSON format. +// Report is a TLSRPT report. type Report struct { - OrganizationName string `json:"organization-name"` - DateRange TLSRPTDateRange `json:"date-range"` - ContactInfo string `json:"contact-info"` // Email address. - ReportID string `json:"report-id"` - Policies []Result `json:"policies"` + OrganizationName string + DateRange TLSRPTDateRange + ContactInfo string + ReportID string + Policies []Result +} + +// ReportJSON is a TLS report with field names as used in the specification. These field names are inconvenient to use in JavaScript, so after parsing a ReportJSON is turned into a Report. +type ReportJSON struct { + OrganizationName string `json:"organization-name"` + DateRange TLSRPTDateRangeJSON `json:"date-range"` + ContactInfo string `json:"contact-info"` // Email address. + ReportID string `json:"report-id"` + Policies []ResultJSON `json:"policies"` +} + +func convertSlice[T interface{ Convert() S }, S any](l []T) []S { + if l == nil { + return nil + } + r := make([]S, len(l)) + for i, e := range l { + r[i] = e.Convert() + } + return r +} + +func (v Report) Convert() ReportJSON { + return ReportJSON{v.OrganizationName, v.DateRange.Convert(), v.ContactInfo, v.ReportID, convertSlice[Result, ResultJSON](v.Policies)} +} + +func (v ReportJSON) Convert() Report { + return Report{v.OrganizationName, v.DateRange.Convert(), v.ContactInfo, v.ReportID, convertSlice[ResultJSON, Result](v.Policies)} } // Merge combines the counts and failure details of results into the report. @@ -124,14 +152,27 @@ func MakeResult(policyType PolicyType, domain dns.Domain, fds ...FailureDetails) // note: with TLSRPT prefix to prevent clash in sherpadoc types. type TLSRPTDateRange struct { + Start time.Time + End time.Time +} + +func (v TLSRPTDateRange) Convert() TLSRPTDateRangeJSON { + return TLSRPTDateRangeJSON(v) +} + +type TLSRPTDateRangeJSON struct { Start time.Time `json:"start-datetime"` End time.Time `json:"end-datetime"` } +func (v TLSRPTDateRangeJSON) Convert() TLSRPTDateRange { + return TLSRPTDateRange(v) +} + // UnmarshalJSON is defined on the date range, not the individual time.Time fields // because it is easier to keep the unmodified time.Time fields stored in the // database. -func (dr *TLSRPTDateRange) UnmarshalJSON(buf []byte) error { +func (dr *TLSRPTDateRangeJSON) UnmarshalJSON(buf []byte) error { var v struct { Start xtime `json:"start-datetime"` End xtime `json:"end-datetime"` @@ -170,14 +211,35 @@ func (x *xtime) UnmarshalJSON(buf []byte) error { } type Result struct { - Policy ResultPolicy `json:"policy"` - Summary Summary `json:"summary"` - FailureDetails []FailureDetails `json:"failure-details"` + Policy ResultPolicy + Summary Summary + FailureDetails []FailureDetails +} + +func (r Result) Convert() ResultJSON { + return ResultJSON{ResultPolicyJSON(r.Policy), SummaryJSON(r.Summary), convertSlice[FailureDetails, FailureDetailsJSON](r.FailureDetails)} +} + +type ResultJSON struct { + Policy ResultPolicyJSON `json:"policy"` + Summary SummaryJSON `json:"summary"` + FailureDetails []FailureDetailsJSON `json:"failure-details"` +} + +func (r ResultJSON) Convert() Result { + return Result{ResultPolicy(r.Policy), Summary(r.Summary), convertSlice[FailureDetailsJSON, FailureDetails](r.FailureDetails)} } // todo spec: ../rfc/8460:437 says policy is a string, with rules for turning dane records into a single string. perhaps a remnant of an earlier version (for mtasts a single string would have made more sense). i doubt the intention is to always have a single element in policy-string (though the field name is singular). type ResultPolicy struct { + Type PolicyType + String []string + Domain string + MXHost []string +} + +type ResultPolicyJSON struct { Type PolicyType `json:"policy-type"` String []string `json:"policy-string"` Domain string `json:"policy-domain"` @@ -205,6 +267,11 @@ func (rp ResultPolicy) equal(orp ResultPolicy) bool { } type Summary struct { + TotalSuccessfulSessionCount int64 + TotalFailureSessionCount int64 +} + +type SummaryJSON struct { TotalSuccessfulSessionCount int64 `json:"total-successful-session-count"` TotalFailureSessionCount int64 `json:"total-failure-session-count"` } @@ -232,6 +299,19 @@ const ( // todo spec: ../rfc/8460:719 more of these fields should be optional. some sts failure details, like failed policy fetches, won't have an ip or mx, the failure happens earlier in the delivery process. type FailureDetails struct { + ResultType ResultType + SendingMTAIP string + ReceivingMXHostname string + ReceivingMXHelo string + ReceivingIP string + FailedSessionCount int64 + AdditionalInformation string + FailureReasonCode string +} + +func (v FailureDetails) Convert() FailureDetailsJSON { return FailureDetailsJSON(v) } + +type FailureDetailsJSON struct { ResultType ResultType `json:"result-type"` SendingMTAIP string `json:"sending-mta-ip"` ReceivingMXHostname string `json:"receiving-mx-hostname"` @@ -242,6 +322,8 @@ type FailureDetails struct { FailureReasonCode string `json:"failure-reason-code"` } +func (v FailureDetailsJSON) Convert() FailureDetails { return FailureDetails(v) } + // equalKey returns whether FailureDetails have the same values, expect for // FailedSessionCount. Useful for aggregating FailureDetails. func (fd FailureDetails) equalKey(ofd FailureDetails) bool { @@ -356,9 +438,9 @@ func TLSFailureDetails(err error) (ResultType, string) { // Parse parses a Report. // The maximum size is 20MB. -func Parse(r io.Reader) (*Report, error) { +func Parse(r io.Reader) (*ReportJSON, error) { r = &moxio.LimitReader{R: r, Limit: 20 * 1024 * 1024} - var report Report + var report ReportJSON if err := json.NewDecoder(r).Decode(&report); err != nil { return nil, err } @@ -369,7 +451,7 @@ func Parse(r io.Reader) (*Report, error) { // ParseMessage parses a Report from a mail message. // The maximum size of the message is 15MB, the maximum size of the // decompressed report is 20MB. -func ParseMessage(elog *slog.Logger, r io.ReaderAt) (*Report, error) { +func ParseMessage(elog *slog.Logger, r io.ReaderAt) (*ReportJSON, error) { log := mlog.New("tlsrpt", elog) // ../rfc/8460:905 @@ -384,7 +466,7 @@ func ParseMessage(elog *slog.Logger, r io.ReaderAt) (*Report, error) { return parseMessageReport(log, p, allow) } -func parseMessageReport(log mlog.Log, p message.Part, allow bool) (*Report, error) { +func parseMessageReport(log mlog.Log, p message.Part, allow bool) (*ReportJSON, error) { if p.MediaType != "MULTIPART" { if !allow { return nil, ErrNoReport @@ -412,7 +494,7 @@ func parseMessageReport(log mlog.Log, p message.Part, allow bool) (*Report, erro } } -func parseReport(p message.Part) (*Report, error) { +func parseReport(p message.Part) (*ReportJSON, error) { mt := strings.ToLower(p.MediaType + "/" + p.MediaSubType) switch mt { case "application/tlsrpt+json": diff --git a/tlsrpt/report_test.go b/tlsrpt/report_test.go index bbaa0f0..41a5cd2 100644 --- a/tlsrpt/report_test.go +++ b/tlsrpt/report_test.go @@ -115,7 +115,7 @@ Content-Disposition: attachment; func TestReport(t *testing.T) { // ../rfc/8460:1756 - var report Report + var report ReportJSON dec := json.NewDecoder(strings.NewReader(reportJSON)) dec.DisallowUnknownFields() if err := dec.Decode(&report); err != nil { diff --git a/tlsrptdb/report_test.go b/tlsrptdb/report_test.go index 5052003..efeb976 100644 --- a/tlsrptdb/report_test.go +++ b/tlsrptdb/report_test.go @@ -91,20 +91,23 @@ func TestReport(t *testing.T) { if err != nil { t.Fatalf("open %q: %s", file, err) } - report, err := tlsrpt.ParseMessage(pkglog.Logger, f) + reportJSON, err := tlsrpt.ParseMessage(pkglog.Logger, f) f.Close() if err != nil { t.Fatalf("parsing TLSRPT from message %q: %s", file.Name(), err) } - if err := AddReport(ctxbg, pkglog, dns.Domain{ASCII: "mox.example"}, "tlsrpt@mox.example", false, report); err != nil { + report := reportJSON.Convert() + if err := AddReport(ctxbg, pkglog, dns.Domain{ASCII: "mox.example"}, "tlsrpt@mox.example", false, &report); err != nil { t.Fatalf("adding report to database: %s", err) } } - report, err := tlsrpt.Parse(strings.NewReader(reportJSON)) + reportJSON, err := tlsrpt.Parse(strings.NewReader(reportJSON)) if err != nil { t.Fatalf("parsing report: %v", err) - } else if err := AddReport(ctxbg, pkglog, dns.Domain{ASCII: "company-y.example"}, "tlsrpt@company-y.example", false, report); err != nil { + } + report := reportJSON.Convert() + if err := AddReport(ctxbg, pkglog, dns.Domain{ASCII: "company-y.example"}, "tlsrpt@company-y.example", false, &report); err != nil { t.Fatalf("adding report to database: %s", err) } @@ -116,7 +119,7 @@ func TestReport(t *testing.T) { if r.FromDomain != "company-y.example" { continue } - if !reflect.DeepEqual(&r.Report, report) { + if !reflect.DeepEqual(r.Report, report) { t.Fatalf("report, got %#v, expected %#v", r.Report, report) } if _, err := RecordID(ctxbg, r.ID); err != nil { diff --git a/tlsrptsend/send.go b/tlsrptsend/send.go index 47bdf92..52bdbdc 100644 --- a/tlsrptsend/send.go +++ b/tlsrptsend/send.go @@ -486,7 +486,7 @@ func sendReportDomain(ctx context.Context, log mlog.Log, resolver dns.Resolver, enc := json.NewEncoder(gzw) enc.SetIndent("", "\t") if err == nil { - err = enc.Encode(report) + err = enc.Encode(report.Convert()) } if err == nil { err = gzw.Close() diff --git a/tlsrptsend/send_test.go b/tlsrptsend/send_test.go index a288c4f..28c110f 100644 --- a/tlsrptsend/send_test.go +++ b/tlsrptsend/send_test.go @@ -425,11 +425,11 @@ func TestSendReports(t *testing.T) { err = os.WriteFile(p, append(append([]byte{}, qm.MsgPrefix...), buf...), 0600) tcheckf(t, err, "write report message") - report, err := tlsrpt.ParseMessage(log.Logger, msgFile) + reportJSON, err := tlsrpt.ParseMessage(log.Logger, msgFile) tcheckf(t, err, "parsing generated report message") addr := qm.Recipient().String() - haveReports[addr] = append(haveReports[addr], *report) + haveReports[addr] = append(haveReports[addr], reportJSON.Convert()) return nil } diff --git a/tsc.sh b/tsc.sh index 5441933..827a242 100755 --- a/tsc.sh +++ b/tsc.sh @@ -1,4 +1,5 @@ -#!/bin/sh +#!/bin/bash +set -euo pipefail # - todo: get tsc to not emit semicolons except for the handful cases where it is needed. # - todo: get tsc to directly print unix line numbers without --pretty (which seems unaware of termcap). @@ -6,6 +7,6 @@ out=$1 shift -./node_modules/.bin/tsc --pretty false --newLine lf --strict --allowUnreachableCode false --allowUnusedLabels false --noFallthroughCasesInSwitch true --noImplicitReturns true --noUnusedLocals true --noImplicitThis true --noUnusedParameters true --target es2021 --module none --outFile $out.spaces "$@" | sed -E 's/^([^\(]+)\(([0-9]+),([0-9]+)\):/\1:\2:\3: /' +./node_modules/.bin/tsc --noEmitOnError true --pretty false --newLine lf --strict --allowUnreachableCode false --allowUnusedLabels false --noFallthroughCasesInSwitch true --noImplicitReturns true --noUnusedLocals true --noImplicitThis true --noUnusedParameters true --target es2022 --module none --outFile $out.spaces "$@" | sed -E 's/^([^\(]+)\(([0-9]+),([0-9]+)\):/\1:\2:\3: /' unexpand -t4 <$out.spaces >$out rm $out.spaces diff --git a/webaccount/account.go b/webaccount/account.go index 11132c5..f6d405b 100644 --- a/webaccount/account.go +++ b/webaccount/account.go @@ -15,6 +15,7 @@ import ( "net" "net/http" "os" + "path/filepath" "strings" "time" @@ -41,12 +42,22 @@ func init() { var pkglog = mlog.New("webaccount", nil) -//go:embed accountapi.json +//go:embed api.json var accountapiJSON []byte //go:embed account.html var accountHTML []byte +//go:embed account.js +var accountJS []byte + +var webaccountFile = &mox.WebappFile{ + HTML: accountHTML, + JS: accountJS, + HTMLPath: filepath.FromSlash("webaccount/account.html"), + JSPath: filepath.FromSlash("webaccount/account.js"), +} + var accountDoc = mustParseAPI("account", accountapiJSON) var accountSherpaHandler http.Handler @@ -225,21 +236,15 @@ func Handle(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/": - if r.Method != "GET" { - http.Error(w, "405 - method not allowed - get required", http.StatusMethodNotAllowed) + switch r.Method { + default: + http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed) return + case "GET", "HEAD": } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache; max-age=0") - // We typically return the embedded admin.html, but during development it's handy - // to load from disk. - f, err := os.Open("webaccount/account.html") - if err == nil { - defer f.Close() - _, _ = io.Copy(w, f) - } else { - _, _ = w.Write(accountHTML) - } + + webaccountFile.Serve(ctx, log, w, r) + return case "/mail-export-maildir.tgz", "/mail-export-maildir.zip", "/mail-export-mbox.tgz", "/mail-export-mbox.zip": maildir := strings.Contains(r.URL.Path, "maildir") @@ -322,7 +327,7 @@ func Handle(w http.ResponseWriter, r *http.Request) { tmpf = nil // importStart is now responsible for cleanup. w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"ImportToken": token}) + _ = json.NewEncoder(w).Encode(ImportProgress{Token: token}) default: if strings.HasPrefix(r.URL.Path, "/api/") { @@ -334,6 +339,12 @@ func Handle(w http.ResponseWriter, r *http.Request) { } } +// ImportProgress is returned after uploading a file to import. +type ImportProgress struct { + // For fetching progress, or cancelling an import. + Token string +} + type ctxKey string var authCtxKey ctxKey = "account" @@ -414,3 +425,8 @@ func (Account) ImportAbort(ctx context.Context, importToken string) error { importers.Abort <- req return <-req.Response } + +// Types exposes types not used in API method signatures, such as the import form upload. +func (Account) Types() (importProgress ImportProgress) { + return +} diff --git a/webaccount/account.html b/webaccount/account.html index f1fe0c8..787f454 100644 --- a/webaccount/account.html +++ b/webaccount/account.html @@ -20,718 +20,15 @@ table > tbody > tr:nth-child(odd) { background-color: #f8f8f8; } p { margin-bottom: 1em; max-width: 50em; } [title] { text-decoration: underline; text-decoration-style: dotted; } fieldset { border: 0; } +.scriptswitch { text-decoration: underline #dca053 2px; } #page { opacity: 1; animation: fadein 0.15s ease-in; } #page.loading { opacity: 0.1; animation: fadeout 1s ease-out; } @keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } } @keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } } - - -
Loading...
- - +
Loading...
+ diff --git a/webaccount/account.js b/webaccount/account.js new file mode 100644 index 0000000..c9288eb --- /dev/null +++ b/webaccount/account.js @@ -0,0 +1,1065 @@ +"use strict"; +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. +const [dom, style, attr, prop] = (function () { + // Start of unicode block (rough approximation of script), from https://www.unicode.org/Public/UNIDATA/Blocks.txt + const scriptblocks = [0x0000, 0x0080, 0x0100, 0x0180, 0x0250, 0x02B0, 0x0300, 0x0370, 0x0400, 0x0500, 0x0530, 0x0590, 0x0600, 0x0700, 0x0750, 0x0780, 0x07C0, 0x0800, 0x0840, 0x0860, 0x0870, 0x08A0, 0x0900, 0x0980, 0x0A00, 0x0A80, 0x0B00, 0x0B80, 0x0C00, 0x0C80, 0x0D00, 0x0D80, 0x0E00, 0x0E80, 0x0F00, 0x1000, 0x10A0, 0x1100, 0x1200, 0x1380, 0x13A0, 0x1400, 0x1680, 0x16A0, 0x1700, 0x1720, 0x1740, 0x1760, 0x1780, 0x1800, 0x18B0, 0x1900, 0x1950, 0x1980, 0x19E0, 0x1A00, 0x1A20, 0x1AB0, 0x1B00, 0x1B80, 0x1BC0, 0x1C00, 0x1C50, 0x1C80, 0x1C90, 0x1CC0, 0x1CD0, 0x1D00, 0x1D80, 0x1DC0, 0x1E00, 0x1F00, 0x2000, 0x2070, 0x20A0, 0x20D0, 0x2100, 0x2150, 0x2190, 0x2200, 0x2300, 0x2400, 0x2440, 0x2460, 0x2500, 0x2580, 0x25A0, 0x2600, 0x2700, 0x27C0, 0x27F0, 0x2800, 0x2900, 0x2980, 0x2A00, 0x2B00, 0x2C00, 0x2C60, 0x2C80, 0x2D00, 0x2D30, 0x2D80, 0x2DE0, 0x2E00, 0x2E80, 0x2F00, 0x2FF0, 0x3000, 0x3040, 0x30A0, 0x3100, 0x3130, 0x3190, 0x31A0, 0x31C0, 0x31F0, 0x3200, 0x3300, 0x3400, 0x4DC0, 0x4E00, 0xA000, 0xA490, 0xA4D0, 0xA500, 0xA640, 0xA6A0, 0xA700, 0xA720, 0xA800, 0xA830, 0xA840, 0xA880, 0xA8E0, 0xA900, 0xA930, 0xA960, 0xA980, 0xA9E0, 0xAA00, 0xAA60, 0xAA80, 0xAAE0, 0xAB00, 0xAB30, 0xAB70, 0xABC0, 0xAC00, 0xD7B0, 0xD800, 0xDB80, 0xDC00, 0xE000, 0xF900, 0xFB00, 0xFB50, 0xFE00, 0xFE10, 0xFE20, 0xFE30, 0xFE50, 0xFE70, 0xFF00, 0xFFF0, 0x10000, 0x10080, 0x10100, 0x10140, 0x10190, 0x101D0, 0x10280, 0x102A0, 0x102E0, 0x10300, 0x10330, 0x10350, 0x10380, 0x103A0, 0x10400, 0x10450, 0x10480, 0x104B0, 0x10500, 0x10530, 0x10570, 0x10600, 0x10780, 0x10800, 0x10840, 0x10860, 0x10880, 0x108E0, 0x10900, 0x10920, 0x10980, 0x109A0, 0x10A00, 0x10A60, 0x10A80, 0x10AC0, 0x10B00, 0x10B40, 0x10B60, 0x10B80, 0x10C00, 0x10C80, 0x10D00, 0x10E60, 0x10E80, 0x10EC0, 0x10F00, 0x10F30, 0x10F70, 0x10FB0, 0x10FE0, 0x11000, 0x11080, 0x110D0, 0x11100, 0x11150, 0x11180, 0x111E0, 0x11200, 0x11280, 0x112B0, 0x11300, 0x11400, 0x11480, 0x11580, 0x11600, 0x11660, 0x11680, 0x11700, 0x11800, 0x118A0, 0x11900, 0x119A0, 0x11A00, 0x11A50, 0x11AB0, 0x11AC0, 0x11B00, 0x11C00, 0x11C70, 0x11D00, 0x11D60, 0x11EE0, 0x11F00, 0x11FB0, 0x11FC0, 0x12000, 0x12400, 0x12480, 0x12F90, 0x13000, 0x13430, 0x14400, 0x16800, 0x16A40, 0x16A70, 0x16AD0, 0x16B00, 0x16E40, 0x16F00, 0x16FE0, 0x17000, 0x18800, 0x18B00, 0x18D00, 0x1AFF0, 0x1B000, 0x1B100, 0x1B130, 0x1B170, 0x1BC00, 0x1BCA0, 0x1CF00, 0x1D000, 0x1D100, 0x1D200, 0x1D2C0, 0x1D2E0, 0x1D300, 0x1D360, 0x1D400, 0x1D800, 0x1DF00, 0x1E000, 0x1E030, 0x1E100, 0x1E290, 0x1E2C0, 0x1E4D0, 0x1E7E0, 0x1E800, 0x1E900, 0x1EC70, 0x1ED00, 0x1EE00, 0x1F000, 0x1F030, 0x1F0A0, 0x1F100, 0x1F200, 0x1F300, 0x1F600, 0x1F650, 0x1F680, 0x1F700, 0x1F780, 0x1F800, 0x1F900, 0x1FA00, 0x1FA70, 0x1FB00, 0x20000, 0x2A700, 0x2B740, 0x2B820, 0x2CEB0, 0x2F800, 0x30000, 0x31350, 0xE0000, 0xE0100, 0xF0000, 0x100000]; + // Find block code belongs in. + const findBlock = (code) => { + let s = 0; + let e = scriptblocks.length; + while (s < e - 1) { + let i = Math.floor((s + e) / 2); + if (code < scriptblocks[i]) { + e = i; + } + else { + s = i; + } + } + return s; + }; + // formatText adds s to element e, in a way that makes switching unicode scripts + // clear, with alternating DOM TextNode and span elements with a "switchscript" + // class. Useful for highlighting look alikes, e.g. a (ascii 0x61) and а (cyrillic + // 0x430). + // + // This is only called one string at a time, so the UI can still display strings + // without highlighting switching scripts, by calling formatText on the parts. + const formatText = (e, s) => { + // Handle some common cases quickly. + if (!s) { + return; + } + let ascii = true; + for (const c of s) { + const cp = c.codePointAt(0); // For typescript, to check for undefined. + if (cp !== undefined && cp >= 0x0080) { + ascii = false; + break; + } + } + if (ascii) { + e.appendChild(document.createTextNode(s)); + return; + } + // todo: handle grapheme clusters? wait for Intl.Segmenter? + let n = 0; // Number of text/span parts added. + let str = ''; // Collected so far. + let block = -1; // Previous block/script. + let mod = 1; + const put = (nextblock) => { + if (n === 0 && nextblock === 0) { + // Start was non-ascii, second block is ascii, we'll start marked as switched. + mod = 0; + } + if (n % 2 === mod) { + const x = document.createElement('span'); + x.classList.add('scriptswitch'); + x.appendChild(document.createTextNode(str)); + e.appendChild(x); + } + else { + e.appendChild(document.createTextNode(str)); + } + n++; + str = ''; + }; + for (const c of s) { + // Basic whitespace does not switch blocks. Will probably need to extend with more + // punctuation in the future. Possibly for digits too. But perhaps not in all + // scripts. + if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { + str += c; + continue; + } + const code = c.codePointAt(0); + if (block < 0 || !(code >= scriptblocks[block] && (code < scriptblocks[block + 1] || block === scriptblocks.length - 1))) { + const nextblock = code < 0x0080 ? 0 : findBlock(code); + if (block >= 0) { + put(nextblock); + } + block = nextblock; + } + str += c; + } + put(-1); + }; + const _domKids = (e, l) => { + l.forEach((c) => { + const xc = c; + if (typeof c === 'string') { + formatText(e, c); + } + else if (c instanceof String) { + // String is an escape-hatch for text that should not be formatted with + // unicode-block-change-highlighting, e.g. for textarea values. + e.appendChild(document.createTextNode('' + c)); + } + else if (c instanceof Element) { + e.appendChild(c); + } + else if (c instanceof Function) { + if (!c.name) { + throw new Error('function without name'); + } + e.addEventListener(c.name, c); + } + else if (Array.isArray(xc)) { + _domKids(e, c); + } + else if (xc._class) { + for (const s of xc._class) { + e.classList.toggle(s, true); + } + } + else if (xc._attrs) { + for (const k in xc._attrs) { + e.setAttribute(k, xc._attrs[k]); + } + } + else if (xc._styles) { + for (const k in xc._styles) { + const estyle = e.style; + estyle[k] = xc._styles[k]; + } + } + else if (xc._props) { + for (const k in xc._props) { + const eprops = e; + eprops[k] = xc._props[k]; + } + } + else if (xc.root) { + e.appendChild(xc.root); + } + else { + console.log('bad kid', c); + throw new Error('bad kid'); + } + }); + return e; + }; + const dom = { + _kids: function (e, ...kl) { + while (e.firstChild) { + e.removeChild(e.firstChild); + } + _domKids(e, kl); + }, + _attrs: (x) => { return { _attrs: x }; }, + _class: (...x) => { return { _class: x }; }, + // The createElement calls are spelled out so typescript can derive function + // signatures with a specific HTML*Element return type. + div: (...l) => _domKids(document.createElement('div'), l), + span: (...l) => _domKids(document.createElement('span'), l), + a: (...l) => _domKids(document.createElement('a'), l), + input: (...l) => _domKids(document.createElement('input'), l), + textarea: (...l) => _domKids(document.createElement('textarea'), l), + select: (...l) => _domKids(document.createElement('select'), l), + option: (...l) => _domKids(document.createElement('option'), l), + clickbutton: (...l) => _domKids(document.createElement('button'), [attr.type('button'), ...l]), + submitbutton: (...l) => _domKids(document.createElement('button'), [attr.type('submit'), ...l]), + form: (...l) => _domKids(document.createElement('form'), l), + fieldset: (...l) => _domKids(document.createElement('fieldset'), l), + table: (...l) => _domKids(document.createElement('table'), l), + thead: (...l) => _domKids(document.createElement('thead'), l), + tbody: (...l) => _domKids(document.createElement('tbody'), l), + tfoot: (...l) => _domKids(document.createElement('tfoot'), l), + tr: (...l) => _domKids(document.createElement('tr'), l), + td: (...l) => _domKids(document.createElement('td'), l), + th: (...l) => _domKids(document.createElement('th'), l), + datalist: (...l) => _domKids(document.createElement('datalist'), l), + h1: (...l) => _domKids(document.createElement('h1'), l), + h2: (...l) => _domKids(document.createElement('h2'), l), + h3: (...l) => _domKids(document.createElement('h3'), l), + br: (...l) => _domKids(document.createElement('br'), l), + hr: (...l) => _domKids(document.createElement('hr'), l), + pre: (...l) => _domKids(document.createElement('pre'), l), + label: (...l) => _domKids(document.createElement('label'), l), + ul: (...l) => _domKids(document.createElement('ul'), l), + li: (...l) => _domKids(document.createElement('li'), l), + iframe: (...l) => _domKids(document.createElement('iframe'), l), + b: (...l) => _domKids(document.createElement('b'), l), + img: (...l) => _domKids(document.createElement('img'), l), + style: (...l) => _domKids(document.createElement('style'), l), + search: (...l) => _domKids(document.createElement('search'), l), + p: (...l) => _domKids(document.createElement('p'), l), + }; + const _attr = (k, v) => { const o = {}; o[k] = v; return { _attrs: o }; }; + const attr = { + title: (s) => _attr('title', s), + value: (s) => _attr('value', s), + type: (s) => _attr('type', s), + tabindex: (s) => _attr('tabindex', s), + src: (s) => _attr('src', s), + placeholder: (s) => _attr('placeholder', s), + href: (s) => _attr('href', s), + checked: (s) => _attr('checked', s), + selected: (s) => _attr('selected', s), + id: (s) => _attr('id', s), + datalist: (s) => _attr('datalist', s), + rows: (s) => _attr('rows', s), + target: (s) => _attr('target', s), + rel: (s) => _attr('rel', s), + required: (s) => _attr('required', s), + multiple: (s) => _attr('multiple', s), + download: (s) => _attr('download', s), + disabled: (s) => _attr('disabled', s), + draggable: (s) => _attr('draggable', s), + rowspan: (s) => _attr('rowspan', s), + colspan: (s) => _attr('colspan', s), + for: (s) => _attr('for', s), + role: (s) => _attr('role', s), + arialabel: (s) => _attr('aria-label', s), + arialive: (s) => _attr('aria-live', s), + name: (s) => _attr('name', s), + min: (s) => _attr('min', s), + max: (s) => _attr('max', s), + }; + const style = (x) => { return { _styles: x }; }; + const prop = (x) => { return { _props: x }; }; + return [dom, style, attr, prop]; +})(); +// NOTE: GENERATED by github.com/mjl-/sherpats, DO NOT MODIFY +var api; +(function (api) { + api.structTypes = { "Destination": true, "Domain": true, "ImportProgress": true, "Ruleset": true }; + api.stringsTypes = {}; + api.intsTypes = {}; + api.types = { + "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, + "Destination": { "Name": "Destination", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Rulesets", "Docs": "", "Typewords": ["[]", "Ruleset"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }] }, + "Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "HeadersRegexp", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListAllowDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "AcceptRejectsToMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, + "ImportProgress": { "Name": "ImportProgress", "Docs": "", "Fields": [{ "Name": "Token", "Docs": "", "Typewords": ["string"] }] }, + }; + api.parser = { + Domain: (v) => api.parse("Domain", v), + Destination: (v) => api.parse("Destination", v), + Ruleset: (v) => api.parse("Ruleset", v), + ImportProgress: (v) => api.parse("ImportProgress", v), + }; + // Account exports web API functions for the account web interface. All its + // methods are exported under api/. Function calls require valid HTTP + // Authentication credentials of a user. + let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true }; + class Client { + baseURL; + options; + constructor(baseURL = api.defaultBaseURL, options) { + this.baseURL = baseURL; + this.options = options; + if (!options) { + this.options = defaultOptions; + } + } + withOptions(options) { + return new Client(this.baseURL, { ...this.options, ...options }); + } + // SetPassword saves a new password for the account, invalidating the previous password. + // Sessions are not interrupted, and will keep working. New login attempts must use the new password. + // Password must be at least 8 characters. + async SetPassword(password) { + const fn = "SetPassword"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [password]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // Account returns information about the account: full name, the default domain, + // and the destinations (keys are email addresses, or localparts to the default + // domain). todo: replace with a function that returns the whole account, when + // sherpadoc understands unnamed struct fields. + async Account() { + const fn = "Account"; + const paramTypes = []; + const returnTypes = [["string"], ["Domain"], ["{}", "Destination"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + async AccountSaveFullName(fullName) { + const fn = "AccountSaveFullName"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [fullName]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DestinationSave updates a destination. + // OldDest is compared against the current destination. If it does not match, an + // error is returned. Otherwise newDest is saved and the configuration reloaded. + async DestinationSave(destName, oldDest, newDest) { + const fn = "DestinationSave"; + const paramTypes = [["string"], ["Destination"], ["Destination"]]; + const returnTypes = []; + const params = [destName, oldDest, newDest]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // ImportAbort aborts an import that is in progress. If the import exists and isn't + // finished, no changes will have been made by the import. + async ImportAbort(importToken) { + const fn = "ImportAbort"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [importToken]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // Types exposes types not used in API method signatures, such as the import form upload. + async Types() { + const fn = "Types"; + const paramTypes = []; + const returnTypes = [["ImportProgress"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + } + api.Client = Client; + api.defaultBaseURL = (function () { + let p = location.pathname; + if (p && p[p.length - 1] !== '/') { + let l = location.pathname.split('/'); + l = l.slice(0, l.length - 1); + p = '/' + l.join('/') + '/'; + } + return location.protocol + '//' + location.host + p + 'api/'; + })(); + // NOTE: code below is shared between github.com/mjl-/sherpaweb and github.com/mjl-/sherpats. + // KEEP IN SYNC. + api.supportedSherpaVersion = 1; + // verifyArg typechecks "v" against "typewords", returning a new (possibly modified) value for JSON-encoding. + // toJS indicate if the data is coming into JS. If so, timestamps are turned into JS Dates. Otherwise, JS Dates are turned into strings. + // allowUnknownKeys configures whether unknown keys in structs are allowed. + // types are the named types of the API. + api.verifyArg = (path, v, typewords, toJS, allowUnknownKeys, types, opts) => { + return new verifier(types, toJS, allowUnknownKeys, opts).verify(path, v, typewords); + }; + api.parse = (name, v) => api.verifyArg(name, v, [name], true, false, api.types, defaultOptions); + class verifier { + types; + toJS; + allowUnknownKeys; + opts; + constructor(types, toJS, allowUnknownKeys, opts) { + this.types = types; + this.toJS = toJS; + this.allowUnknownKeys = allowUnknownKeys; + this.opts = opts; + } + verify(path, v, typewords) { + typewords = typewords.slice(0); + const ww = typewords.shift(); + const error = (msg) => { + if (path != '') { + msg = path + ': ' + msg; + } + throw new Error(msg); + }; + if (typeof ww !== 'string') { + error('bad typewords'); + return; // should not be necessary, typescript doesn't see error always throws an exception? + } + const w = ww; + const ensure = (ok, expect) => { + if (!ok) { + error('got ' + JSON.stringify(v) + ', expected ' + expect); + } + return v; + }; + switch (w) { + case 'nullable': + if (v === null || v === undefined && this.opts.nullableOptional) { + return v; + } + return this.verify(path, v, typewords); + case '[]': + if (v === null && this.opts.slicesNullable || v === undefined && this.opts.slicesNullable && this.opts.nullableOptional) { + return v; + } + ensure(Array.isArray(v), "array"); + return v.map((e, i) => this.verify(path + '[' + i + ']', e, typewords)); + case '{}': + if (v === null && this.opts.mapsNullable || v === undefined && this.opts.mapsNullable && this.opts.nullableOptional) { + return v; + } + ensure(v !== null || typeof v === 'object', "object"); + const r = {}; + for (const k in v) { + r[k] = this.verify(path + '.' + k, v[k], typewords); + } + return r; + } + ensure(typewords.length == 0, "empty typewords"); + const t = typeof v; + switch (w) { + case 'any': + return v; + case 'bool': + ensure(t === 'boolean', 'bool'); + return v; + case 'int8': + case 'uint8': + case 'int16': + case 'uint16': + case 'int32': + case 'uint32': + case 'int64': + case 'uint64': + ensure(t === 'number' && Number.isInteger(v), 'integer'); + return v; + case 'float32': + case 'float64': + ensure(t === 'number', 'float'); + return v; + case 'int64s': + case 'uint64s': + ensure(t === 'number' && Number.isInteger(v) || t === 'string', 'integer fitting in float without precision loss, or string'); + return '' + v; + case 'string': + ensure(t === 'string', 'string'); + return v; + case 'timestamp': + if (this.toJS) { + ensure(t === 'string', 'string, with timestamp'); + const d = new Date(v); + if (d instanceof Date && !isNaN(d.getTime())) { + return d; + } + error('invalid date ' + v); + } + else { + ensure(t === 'object' && v !== null, 'non-null object'); + ensure(v.__proto__ === Date.prototype, 'Date'); + return v.toISOString(); + } + } + // We're left with named types. + const nt = this.types[w]; + if (!nt) { + error('unknown type ' + w); + } + if (v === null) { + error('bad value ' + v + ' for named type ' + w); + } + if (api.structTypes[nt.Name]) { + const t = nt; + if (typeof v !== 'object') { + error('bad value ' + v + ' for struct ' + w); + } + const r = {}; + for (const f of t.Fields) { + r[f.Name] = this.verify(path + '.' + f.Name, v[f.Name], f.Typewords); + } + // If going to JSON also verify no unknown fields are present. + if (!this.allowUnknownKeys) { + const known = {}; + for (const f of t.Fields) { + known[f.Name] = true; + } + Object.keys(v).forEach((k) => { + if (!known[k]) { + error('unknown key ' + k + ' for struct ' + w); + } + }); + } + return r; + } + else if (api.stringsTypes[nt.Name]) { + const t = nt; + if (typeof v !== 'string') { + error('mistyped value ' + v + ' for named strings ' + t.Name); + } + if (!t.Values || t.Values.length === 0) { + return v; + } + for (const sv of t.Values) { + if (sv.Value === v) { + return v; + } + } + error('unknkown value ' + v + ' for named strings ' + t.Name); + } + else if (api.intsTypes[nt.Name]) { + const t = nt; + if (typeof v !== 'number' || !Number.isInteger(v)) { + error('mistyped value ' + v + ' for named ints ' + t.Name); + } + if (!t.Values || t.Values.length === 0) { + return v; + } + for (const sv of t.Values) { + if (sv.Value === v) { + return v; + } + } + error('unknkown value ' + v + ' for named ints ' + t.Name); + } + else { + throw new Error('unexpected named type ' + nt); + } + } + } + const _sherpaCall = async (baseURL, options, paramTypes, returnTypes, name, params) => { + if (!options.skipParamCheck) { + if (params.length !== paramTypes.length) { + return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length }); + } + params = params.map((v, index) => api.verifyArg('params[' + index + ']', v, paramTypes[index], false, false, api.types, options)); + } + const simulate = async (json) => { + const config = JSON.parse(json || 'null') || {}; + const waitMinMsec = config.waitMinMsec || 0; + const waitMaxMsec = config.waitMaxMsec || 0; + const wait = Math.random() * (waitMaxMsec - waitMinMsec); + const failRate = config.failRate || 0; + return new Promise((resolve, reject) => { + if (options.aborter) { + options.aborter.abort = () => { + reject({ message: 'call to ' + name + ' aborted by user', code: 'sherpa:aborted' }); + reject = resolve = () => { }; + }; + } + setTimeout(() => { + const r = Math.random(); + if (r < failRate) { + reject({ message: 'injected failure on ' + name, code: 'server:injected' }); + } + else { + resolve(); + } + reject = resolve = () => { }; + }, waitMinMsec + wait); + }); + }; + // Only simulate when there is a debug string. Otherwise it would always interfere + // with setting options.aborter. + let json = ''; + try { + json = window.localStorage.getItem('sherpats-debug') || ''; + } + catch (err) { } + if (json) { + await simulate(json); + } + // Immediately create promise, so options.aborter is changed before returning. + const promise = new Promise((resolve, reject) => { + let resolve1 = (v) => { + resolve(v); + resolve1 = () => { }; + reject1 = () => { }; + }; + let reject1 = (v) => { + reject(v); + resolve1 = () => { }; + reject1 = () => { }; + }; + const url = baseURL + name; + const req = new window.XMLHttpRequest(); + if (options.aborter) { + options.aborter.abort = () => { + req.abort(); + reject1({ code: 'sherpa:aborted', message: 'request aborted' }); + }; + } + req.open('POST', url, true); + if (options.timeoutMsec) { + req.timeout = options.timeoutMsec; + } + req.onload = () => { + if (req.status !== 200) { + if (req.status === 404) { + reject1({ code: 'sherpa:badFunction', message: 'function does not exist' }); + } + else { + reject1({ code: 'sherpa:http', message: 'error calling function, HTTP status: ' + req.status }); + } + return; + } + let resp; + try { + resp = JSON.parse(req.responseText); + } + catch (err) { + reject1({ code: 'sherpa:badResponse', message: 'bad JSON from server' }); + return; + } + if (resp && resp.error) { + const err = resp.error; + reject1({ code: err.code, message: err.message }); + return; + } + else if (!resp || !resp.hasOwnProperty('result')) { + reject1({ code: 'sherpa:badResponse', message: "invalid sherpa response object, missing 'result'" }); + return; + } + if (options.skipReturnCheck) { + resolve1(resp.result); + return; + } + let result = resp.result; + try { + if (returnTypes.length === 0) { + if (result) { + throw new Error('function ' + name + ' returned a value while prototype says it returns "void"'); + } + } + else if (returnTypes.length === 1) { + result = api.verifyArg('result', result, returnTypes[0], true, true, api.types, options); + } + else { + if (result.length != returnTypes.length) { + throw new Error('wrong number of values returned by ' + name + ', saw ' + result.length + ' != expected ' + returnTypes.length); + } + result = result.map((v, index) => api.verifyArg('result[' + index + ']', v, returnTypes[index], true, true, api.types, options)); + } + } + catch (err) { + let errmsg = 'bad types'; + if (err instanceof Error) { + errmsg = err.message; + } + reject1({ code: 'sherpa:badTypes', message: errmsg }); + } + resolve1(result); + }; + req.onerror = () => { + reject1({ code: 'sherpa:connection', message: 'connection failed' }); + }; + req.ontimeout = () => { + reject1({ code: 'sherpa:timeout', message: 'request timeout' }); + }; + req.setRequestHeader('Content-Type', 'application/json'); + try { + req.send(JSON.stringify({ params: params })); + } + catch (err) { + reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' }); + } + }); + return await promise; + }; +})(api || (api = {})); +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. +const client = new api.Client(); +const link = (href, anchorOpt) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), anchorOpt || href); +const crumblink = (text, link) => dom.a(text, attr.href(link)); +const crumbs = (...l) => [dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])), dom.br()]; +const errmsg = (err) => '' + (err.message || '(no error message)'); +const footer = dom.div(style({ marginTop: '6ex', opacity: 0.75 }), link('https://github.com/mjl-/mox', 'mox'), ' ', moxversion); +const domainName = (d) => { + return d.Unicode || d.ASCII; +}; +const domainString = (d) => { + if (d.Unicode) { + return d.Unicode + " (" + d.ASCII + ")"; + } + return d.ASCII; +}; +const box = (color, ...l) => [ + dom.div(style({ + display: 'inline-block', + padding: '.25em .5em', + backgroundColor: color, + borderRadius: '3px', + margin: '.5ex 0', + }), l), + dom.br(), +]; +const green = '#1dea20'; +const yellow = '#ffe400'; +const red = '#ff7443'; +const blue = '#8bc8ff'; +const index = async () => { + const [accountFullName, domain, destinations] = await client.Account(); + let fullNameForm; + let fullNameFieldset; + let fullName; + let passwordForm; + let passwordFieldset; + let password1; + let password2; + let passwordHint; + let importForm; + let importFieldset; + let mailboxFileHint; + let mailboxPrefixHint; + let importProgress; + let importAbortBox; + const importTrack = async (token) => { + const importConnection = dom.div('Waiting for updates...'); + importProgress.appendChild(importConnection); + let countsTbody; + let counts = new Map(); // mailbox -> elem + let problems; // element + await new Promise((resolve, reject) => { + const eventSource = new window.EventSource('importprogress?token=' + encodeURIComponent(token)); + eventSource.addEventListener('open', function (e) { + console.log('eventsource open', { e }); + dom._kids(importConnection, dom.div('Waiting for updates, connected...')); + dom._kids(importAbortBox, dom.clickbutton('Abort import', attr.title('If the import is not yet finished, it can be aborted and no messages will have been imported.'), async function click() { + try { + await client.ImportAbort(token); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + } + // On success, the event source will get an aborted notification and shutdown the connection. + })); + }); + eventSource.addEventListener('error', function (e) { + console.log('eventsource error', { e }); + dom._kids(importConnection, box(red, 'Connection error')); + reject({ message: 'Connection error' }); + }); + eventSource.addEventListener('count', (e) => { + const data = JSON.parse(e.data); // {Mailbox: ..., Count: ...} + console.log('import count event', { e, data }); + if (!countsTbody) { + importProgress.appendChild(dom.div(dom.br(), dom.h3('Importing mailboxes and messages...'), dom.table(dom.thead(dom.tr(dom.th('Mailbox'), dom.th('Messages'))), countsTbody = dom.tbody()))); + } + let elem = counts.get(data.Mailbox); + if (!elem) { + countsTbody.appendChild(dom.tr(dom.td(data.Mailbox), elem = dom.td(style({ textAlign: 'right' }), '' + data.Count))); + counts.set(data.Mailbox, elem); + } + dom._kids(elem, '' + data.Count); + }); + eventSource.addEventListener('problem', (e) => { + const data = JSON.parse(e.data); // {Message: ...} + console.log('import problem event', { e, data }); + if (!problems) { + importProgress.appendChild(dom.div(dom.br(), dom.h3('Problems during import'), problems = dom.div())); + } + problems.appendChild(dom.div(box(yellow, data.Message))); + }); + eventSource.addEventListener('step', (e) => { + const data = JSON.parse(e.data); // {Title: ...} + console.log('import step event', { e, data }); + importProgress.appendChild(dom.div(dom.br(), box(blue, 'Step: ' + data.Title))); + }); + eventSource.addEventListener('done', (e) => { + console.log('import done event', { e }); + importProgress.appendChild(dom.div(dom.br(), box(blue, 'Import finished'))); + eventSource.close(); + dom._kids(importConnection); + dom._kids(importAbortBox); + window.sessionStorage.removeItem('ImportToken'); + resolve(null); + }); + eventSource.addEventListener('aborted', function (e) { + console.log('import aborted event', { e }); + importProgress.appendChild(dom.div(dom.br(), box(red, 'Import aborted, no message imported'))); + eventSource.close(); + dom._kids(importConnection); + dom._kids(importAbortBox); + window.sessionStorage.removeItem('ImportToken'); + reject({ message: 'Import aborted' }); + }); + }); + }; + dom._kids(page, crumbs('Mox Account'), dom.p('NOTE: Not all account settings can be configured through these pages yet. See the configuration file for more options.'), dom.div('Default domain: ', domain.ASCII ? domainString(domain) : '(none)'), dom.br(), fullNameForm = dom.form(fullNameFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Full name', dom.br(), fullName = dom.input(attr.value(accountFullName), 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(); + fullNameFieldset.disabled = true; + try { + await client.AccountSaveFullName(fullName.value); + fullName.setAttribute('value', fullName.value); + fullNameForm.reset(); + window.alert('Full name has been changed.'); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + } + finally { + fullNameFieldset.disabled = false; + } + }), dom.br(), dom.h2('Addresses'), dom.ul(Object.entries(destinations).sort().map(t => dom.li(dom.a(t[0], attr.href('#destinations/' + t[0])), t[0].startsWith('@') ? ' (catchall)' : []))), dom.br(), dom.h2('Change password'), passwordForm = dom.form(passwordFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'New password', dom.br(), password1 = dom.input(attr.type('password'), attr.required(''), function focus() { + passwordHint.style.display = ''; + })), ' ', dom.label(style({ display: 'inline-block' }), 'New password repeat', dom.br(), password2 = dom.input(attr.type('password'), attr.required(''))), ' ', dom.submitbutton('Change password')), passwordHint = dom.div(style({ display: 'none', marginTop: '.5ex' }), dom.clickbutton('Generate random password', function click(e) { + e.preventDefault(); + let b = new Uint8Array(1); + let s = ''; + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_;:,<.>/'; + while (s.length < 12) { + self.crypto.getRandomValues(b); + if (Math.ceil(b[0] / chars.length) * chars.length > 255) { + continue; // Prevent bias. + } + s += chars[b[0] % chars.length]; + } + password1.type = 'text'; + password2.type = 'text'; + password1.value = s; + password2.value = s; + }), dom.div(dom._class('text'), box(yellow, 'Important: Bots will try to bruteforce your password. Connections with failed authentication attempts will be rate limited but attackers WILL find weak passwords. If your account is compromised, spammers are likely to abuse your system, spamming your address and the wider internet in your name. So please pick a random, unguessable password, preferrably at least 12 characters.'))), async function submit(e) { + e.stopPropagation(); + e.preventDefault(); + if (!password1.value || password1.value !== password2.value) { + window.alert('Passwords do not match.'); + return; + } + passwordFieldset.disabled = true; + try { + await client.SetPassword(password1.value); + window.alert('Password has been changed.'); + passwordForm.reset(); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + } + finally { + passwordFieldset.disabled = false; + } + }), dom.br(), dom.h2('Export'), dom.p('Export all messages in all mailboxes. In maildir or mbox format, as .zip or .tgz file.'), dom.ul(dom.li(dom.a('mail-export-maildir.tgz', attr.href('mail-export-maildir.tgz'))), dom.li(dom.a('mail-export-maildir.zip', attr.href('mail-export-maildir.zip'))), dom.li(dom.a('mail-export-mbox.tgz', attr.href('mail-export-mbox.tgz'))), dom.li(dom.a('mail-export-mbox.zip', attr.href('mail-export-mbox.zip')))), dom.br(), dom.h2('Import'), dom.p('Import messages from a .zip or .tgz file with maildirs and/or mbox files.'), importForm = dom.form(async function submit(e) { + e.preventDefault(); + e.stopPropagation(); + const request = async () => { + return new Promise((resolve, reject) => { + // Browsers can do everything. Except show a progress bar while uploading... + let progressPercentage; + dom._kids(importProgress, dom.div(dom.div('Uploading... ', progressPercentage = dom.span()))); + importProgress.style.display = ''; + const xhr = new window.XMLHttpRequest(); + xhr.open('POST', 'import', true); + xhr.upload.addEventListener('progress', (e) => { + if (!e.lengthComputable) { + return; + } + const pct = Math.floor(100 * e.loaded / e.total); + dom._kids(progressPercentage, pct + '%'); + }); + xhr.addEventListener('load', () => { + console.log('upload done', { xhr: xhr, status: xhr.status }); + if (xhr.status !== 200) { + reject({ message: 'status ' + xhr.status }); + return; + } + let resp; + try { + resp = api.parser.ImportProgress(JSON.parse(xhr.responseText)); + } + catch (err) { + reject({ message: 'parsing response json: ' + errmsg(err) }); + return; + } + resolve(resp); + }); + xhr.addEventListener('error', (e) => reject({ message: 'upload error', event: e })); + xhr.addEventListener('abort', (e) => reject({ message: 'upload aborted', event: e })); + xhr.send(new window.FormData(importForm)); + }); + }; + try { + const p = request(); + importFieldset.disabled = true; + const result = await p; + try { + window.sessionStorage.setItem('ImportToken', result.Token); + } + catch (err) { + console.log('storing import token in session storage', { err }); + // Ignore error, could be some browser security thing like private browsing. + } + await importTrack(result.Token); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + } + finally { + importFieldset.disabled = false; + } + }, importFieldset = dom.fieldset(dom.div(style({ marginBottom: '1ex' }), dom.label(dom.div(style({ marginBottom: '.5ex' }), 'File'), dom.input(attr.type('file'), attr.required(''), attr.name('file'), function focus() { + mailboxFileHint.style.display = ''; + })), mailboxFileHint = dom.p(style({ display: 'none', fontStyle: 'italic', marginTop: '.5ex' }), 'This file must either be a zip file or a gzipped tar file with mbox and/or maildir mailboxes. For maildirs, an optional file "dovecot-keywords" is read additional keywords, like Forwarded/Junk/NotJunk. If an imported mailbox already exists by name, messages are added to the existing mailbox. If a mailbox does not yet exist it will be created.')), dom.div(style({ marginBottom: '1ex' }), dom.label(dom.div(style({ marginBottom: '.5ex' }), 'Skip mailbox prefix (optional)'), dom.input(attr.name('skipMailboxPrefix'), function focus() { + mailboxPrefixHint.style.display = ''; + })), mailboxPrefixHint = dom.p(style({ display: 'none', fontStyle: 'italic', marginTop: '.5ex' }), 'If set, any mbox/maildir path with this prefix will have it stripped before importing. For example, if all mailboxes are in a directory "Takeout", specify that path in the field above so mailboxes like "Takeout/Inbox.mbox" are imported into a mailbox called "Inbox" instead of "Takeout/Inbox".')), dom.div(dom.submitbutton('Upload and import'), dom.p(style({ fontStyle: 'italic', marginTop: '.5ex' }), 'The file is uploaded first, then its messages are imported, finally messages are matched for threading. Importing is done in a transaction, you can abort the entire import before it is finished.')))), importAbortBox = dom.div(), // Outside fieldset because it gets disabled, above progress because may be scrolling it down quickly with problems. + importProgress = dom.div(style({ display: 'none' })), footer); + // Try to show the progress of an earlier import session. The user may have just + // refreshed the browser. + let importToken; + try { + importToken = window.sessionStorage.getItem('ImportToken') || ''; + } + catch (err) { + console.log('looking up ImportToken in session storage', { err }); + return; + } + if (!importToken) { + return; + } + importFieldset.disabled = true; + dom._kids(importProgress, dom.div(dom.div('Reconnecting to import...'))); + importProgress.style.display = ''; + importTrack(importToken) + .catch(() => { + if (window.confirm('Error reconnecting to import. Remove this import session?')) { + window.sessionStorage.removeItem('ImportToken'); + dom._kids(importProgress); + importProgress.style.display = 'none'; + } + }) + .finally(() => { + importFieldset.disabled = false; + }); +}; +const destination = async (name) => { + const [_, domain, destinations] = await client.Account(); + let dest = destinations[name]; + if (!dest) { + throw new Error('destination not found'); + } + let rulesetsTbody = dom.tbody(); + let rulesetsRows = []; + const addRulesetsRow = (rs) => { + let row; + let headersCell = dom.td(); + const addHeader = (k, v) => { + let h; + let key; + let value; + const root = dom.div(key = dom.input(attr.value(k)), ' ', value = dom.input(attr.value(v)), ' ', dom.clickbutton('-', style({ width: '1.5em' }), function click() { + h.root.remove(); + row.headers = row.headers.filter(x => x !== h); + if (row.headers.length === 0) { + const b = dom.clickbutton('+', style({ width: '1.5em' }), function click() { + b.remove(); + addHeader('', ''); + }); + headersCell.appendChild(dom.div(style({ textAlign: 'right' }), b)); + } + }), ' ', dom.clickbutton('+', style({ width: '1.5em' }), function click() { + addHeader('', ''); + })); + h = { root: root, key: key, value: value }; + row.headers.push(h); + headersCell.appendChild(root); + }; + let smtpMailFromRegexp; + let verifiedDomain; + let isForward; // Checkbox + let listAllowDomain; + let acceptRejectsToMailbox; + let mailbox; + const root = dom.tr(dom.td(smtpMailFromRegexp = dom.input(attr.value(rs.SMTPMailFromRegexp || ''))), dom.td(verifiedDomain = dom.input(attr.value(rs.VerifiedDomain || ''))), headersCell, dom.td(dom.label(isForward = dom.input(attr.type('checkbox'), rs.IsForward ? attr.checked('') : []))), dom.td(listAllowDomain = dom.input(attr.value(rs.ListAllowDomain || ''))), dom.td(acceptRejectsToMailbox = dom.input(attr.value(rs.AcceptRejectsToMailbox || ''))), dom.td(mailbox = dom.input(attr.value(rs.Mailbox || ''))), dom.td(dom.clickbutton('Remove ruleset', function click() { + row.root.remove(); + rulesetsRows = rulesetsRows.filter(e => e !== row); + }))); + row = { + root: root, + smtpMailFromRegexp: smtpMailFromRegexp, + verifiedDomain: verifiedDomain, + headers: [], + isForward: isForward, + listAllowDomain: listAllowDomain, + acceptRejectsToMailbox: acceptRejectsToMailbox, + mailbox: mailbox, + }; + rulesetsRows.push(row); + Object.entries(rs.HeadersRegexp || {}).sort().forEach(t => addHeader(t[0], t[1])); + if (Object.entries(rs.HeadersRegexp || {}).length === 0) { + const b = dom.clickbutton('+', style({ width: '1.5em' }), function click() { + b.remove(); + addHeader('', ''); + }); + headersCell.appendChild(dom.div(style({ textAlign: 'right' }), b)); + } + rulesetsTbody.appendChild(row.root); + }; + (dest.Rulesets || []).forEach(rs => { + addRulesetsRow(rs); + }); + let defaultMailbox; + let fullName; + let saveButton; + const addresses = [name, ...Object.keys(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('Verified domain', attr.title('Matches if this domain matches an SPF- and/or DKIM-verified (sub)domain.')), dom.th('Headers regexp', attr.title('Matches if these header field/value regular expressions all match (substrings of) the message headers. Header fields and valuees are converted to lower case before matching. Whitespace is trimmed from the value before matching. A header field can occur multiple times in a message, only one instance has to match. For mailing lists, you could match on ^list-id$ with the value typically the mailing list address in angled brackets with @ replaced with a dot, e.g. .')), dom.th('Is Forward', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. Can only be used together with SMTPMailFromRegexp and VerifiedDomain. SMTPMailFromRegexp must be set to the address used to deliver the forwarded message, e.g. '^user(|\\+.*)@forward\\.example$'. Changes to junk analysis: 1. Messages are not rejects 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('Action'))), rulesetsTbody, dom.tfoot(dom.tr(dom.td(attr.colspan('7')), dom.td(dom.clickbutton('Add ruleset', function click() { + addRulesetsRow({ + SMTPMailFromRegexp: '', + VerifiedDomain: '', + HeadersRegexp: {}, + IsForward: false, + ListAllowDomain: '', + AcceptRejectsToMailbox: '', + Mailbox: '', + VerifiedDNSDomain: { ASCII: '', Unicode: '' }, + ListAllowDNSDomain: { ASCII: '', Unicode: '' }, + }); + }))))), dom.br(), saveButton = dom.clickbutton('Save', async function click() { + saveButton.disabled = true; + try { + const newDest = { + Mailbox: defaultMailbox.value, + FullName: fullName.value, + Rulesets: rulesetsRows.map(row => { + return { + SMTPMailFromRegexp: row.smtpMailFromRegexp.value, + VerifiedDomain: row.verifiedDomain.value, + HeadersRegexp: Object.fromEntries(row.headers.map(h => [h.key.value, h.value.value])), + IsForward: row.isForward.checked, + ListAllowDomain: row.listAllowDomain.value, + AcceptRejectsToMailbox: row.acceptRejectsToMailbox.value, + Mailbox: row.mailbox.value, + VerifiedDNSDomain: { ASCII: '', Unicode: '' }, + ListAllowDNSDomain: { ASCII: '', Unicode: '' }, + }; + }), + }; + page.classList.add('loading'); + await client.DestinationSave(name, dest, newDest); + window.location.reload(); // todo: only refresh part of ui + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + page.classList.remove('loading'); + return; + } + finally { + saveButton.disabled = false; + } + }), dom.br(), dom.br(), dom.br(), dom.p("Apple's mail applications don't do account autoconfiguration, and when adding an account it can choose defaults that don't work with modern email servers. Adding an account through a \"mobileconfig\" profile file can be more convenient: It contains the IMAP/SMTP settings such as host name, port, TLS, authentication mechanism and user name. This profile does not contain a login password. Opening the profile adds it under Profiles in System Preferences (macOS) or Settings (iOS), where you can install it. These profiles are not signed, so users will have to ignore the warnings about them being unsigned. ", dom.br(), dom.a(attr.href('https://autoconfig.' + domainName(domain) + '/profile.mobileconfig?addresses=' + encodeURIComponent(addresses.join(',')) + '&name=' + encodeURIComponent(dest.FullName)), attr.download(''), 'Download .mobileconfig email account profile'), dom.br(), dom.a(attr.href('https://autoconfig.' + domainName(domain) + '/profile.mobileconfig.qrcode.png?addresses=' + encodeURIComponent(addresses.join(',')) + '&name=' + encodeURIComponent(dest.FullName)), attr.download(''), 'Open QR-code with link to .mobileconfig profile'))); +}; +const init = async () => { + let curhash; + const hashChange = async () => { + if (curhash === window.location.hash) { + return; + } + let h = decodeURIComponent(window.location.hash); + if (h !== '' && h.substring(0, 1) == '#') { + h = h.substring(1); + } + const t = h.split('/'); + page.classList.add('loading'); + try { + if (h === '') { + await index(); + } + else if (t[0] === 'destinations' && t.length === 2) { + await destination(t[1]); + } + else { + dom._kids(page, 'page not found'); + } + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + window.location.hash = curhash || ''; + curhash = window.location.hash; + return; + } + curhash = window.location.hash; + page.classList.remove('loading'); + }; + window.addEventListener('hashchange', hashChange); + hashChange(); +}; +window.addEventListener('load', async () => { + try { + await init(); + } + catch (err) { + window.alert('Error: ' + errmsg(err)); + } +}); diff --git a/webaccount/account.ts b/webaccount/account.ts new file mode 100644 index 0000000..89a1da0 --- /dev/null +++ b/webaccount/account.ts @@ -0,0 +1,699 @@ +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. + +// From HTML. +declare let page: HTMLElement +declare let moxversion: string + +const client = new api.Client() + +const link = (href: string, anchorOpt: string) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), anchorOpt || href) + +const crumblink = (text: string, link: string) => dom.a(text, attr.href(link)) +const crumbs = (...l: ElemArg[]) => [dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])), dom.br()] + +const errmsg = (err: unknown) => ''+((err as any).message || '(no error message)') + +const footer = dom.div( + style({marginTop: '6ex', opacity: 0.75}), + link('https://github.com/mjl-/mox', 'mox'), + ' ', + moxversion, +) + +const domainName = (d: api.Domain) => { + return d.Unicode || d.ASCII +} + +const domainString = (d: api.Domain) => { + if (d.Unicode) { + return d.Unicode+" ("+d.ASCII+")" + } + return d.ASCII +} + +const box = (color: string, ...l: ElemArg[]) => [ + dom.div( + style({ + display: 'inline-block', + padding: '.25em .5em', + backgroundColor: color, + borderRadius: '3px', + margin: '.5ex 0', + }), + l, + ), + dom.br(), +] + +const green = '#1dea20' +const yellow = '#ffe400' +const red = '#ff7443' +const blue = '#8bc8ff' + +const index = async () => { + const [accountFullName, domain, destinations] = await client.Account() + + let fullNameForm: HTMLFormElement + let fullNameFieldset: HTMLFieldSetElement + let fullName: HTMLInputElement + let passwordForm: HTMLFormElement + let passwordFieldset: HTMLFieldSetElement + let password1: HTMLInputElement + let password2: HTMLInputElement + let passwordHint: HTMLElement + + let importForm: HTMLFormElement + let importFieldset: HTMLFieldSetElement + let mailboxFileHint: HTMLElement + let mailboxPrefixHint: HTMLElement + let importProgress: HTMLElement + let importAbortBox: HTMLElement + + const importTrack = async (token: string) => { + const importConnection = dom.div('Waiting for updates...') + importProgress.appendChild(importConnection) + + let countsTbody: HTMLElement + let counts = new Map() // mailbox -> elem + + let problems: HTMLElement // element + + await new Promise((resolve, reject) => { + const eventSource = new window.EventSource('importprogress?token=' + encodeURIComponent(token)) + eventSource.addEventListener('open', function(e) { + console.log('eventsource open', {e}) + dom._kids(importConnection, dom.div('Waiting for updates, connected...')) + + dom._kids(importAbortBox, + dom.clickbutton('Abort import', attr.title('If the import is not yet finished, it can be aborted and no messages will have been imported.'), async function click() { + try { + await client.ImportAbort(token) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + } + // On success, the event source will get an aborted notification and shutdown the connection. + }) + ) + }) + eventSource.addEventListener('error', function(e) { + console.log('eventsource error', {e}) + dom._kids(importConnection, box(red, 'Connection error')) + reject({message: 'Connection error'}) + }) + eventSource.addEventListener('count', (e) => { + const data = JSON.parse(e.data) // {Mailbox: ..., Count: ...} + console.log('import count event', {e, data}) + if (!countsTbody) { + importProgress.appendChild( + dom.div( + dom.br(), + dom.h3('Importing mailboxes and messages...'), + dom.table( + dom.thead( + dom.tr(dom.th('Mailbox'), dom.th('Messages')), + ), + countsTbody=dom.tbody(), + ), + ) + ) + } + let elem = counts.get(data.Mailbox) + if (!elem) { + countsTbody.appendChild( + dom.tr( + dom.td(data.Mailbox), + elem=dom.td(style({textAlign: 'right'}), ''+data.Count), + ), + ) + counts.set(data.Mailbox, elem) + } + dom._kids(elem, ''+data.Count) + }) + eventSource.addEventListener('problem', (e) => { + const data = JSON.parse(e.data) // {Message: ...} + console.log('import problem event', {e, data}) + if (!problems) { + importProgress.appendChild( + dom.div( + dom.br(), + dom.h3('Problems during import'), + problems=dom.div(), + ), + ) + } + problems.appendChild(dom.div(box(yellow, data.Message))) + }) + eventSource.addEventListener('step', (e) => { + const data = JSON.parse(e.data) // {Title: ...} + console.log('import step event', {e, data}) + importProgress.appendChild(dom.div(dom.br(), box(blue, 'Step: '+data.Title))) + }) + eventSource.addEventListener('done', (e) => { + console.log('import done event', {e}) + importProgress.appendChild(dom.div(dom.br(), box(blue, 'Import finished'))) + + eventSource.close() + dom._kids(importConnection) + dom._kids(importAbortBox) + window.sessionStorage.removeItem('ImportToken') + + resolve(null) + }) + eventSource.addEventListener('aborted', function(e) { + console.log('import aborted event', {e}) + + importProgress.appendChild(dom.div(dom.br(), box(red, 'Import aborted, no message imported'))) + + eventSource.close() + dom._kids(importConnection) + dom._kids(importAbortBox) + window.sessionStorage.removeItem('ImportToken') + + reject({message: 'Import aborted'}) + }) + }) + } + + dom._kids(page, + crumbs('Mox Account'), + dom.p('NOTE: Not all account settings can be configured through these pages yet. See the configuration file for more options.'), + dom.div( + 'Default domain: ', + domain.ASCII ? domainString(domain) : '(none)', + ), + dom.br(), + + fullNameForm=dom.form( + fullNameFieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + 'Full name', + dom.br(), + fullName=dom.input(attr.value(accountFullName), attr.title('Name to use in From header when composing messages. Can be overridden per configured address.')), + + ), + ' ', + dom.submitbutton('Save'), + ), + async function submit(e: SubmitEvent) { + e.preventDefault() + fullNameFieldset.disabled = true + try { + await client.AccountSaveFullName(fullName.value) + fullName.setAttribute('value', fullName.value) + fullNameForm.reset() + window.alert('Full name has been changed.') + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + } finally { + fullNameFieldset.disabled = false + } + }, + ), + dom.br(), + + dom.h2('Addresses'), + dom.ul( + Object.entries(destinations).sort().map(t => + dom.li( + dom.a(t[0], attr.href('#destinations/'+t[0])), + t[0].startsWith('@') ? ' (catchall)' : [], + ), + ), + ), + dom.br(), + dom.h2('Change password'), + passwordForm=dom.form( + passwordFieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + 'New password', + dom.br(), + password1=dom.input(attr.type('password'), attr.required(''), function focus() { + passwordHint.style.display = '' + }), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + 'New password repeat', + dom.br(), + password2=dom.input(attr.type('password'), attr.required('')), + ), + ' ', + dom.submitbutton('Change password'), + ), + passwordHint=dom.div( + style({display: 'none', marginTop: '.5ex'}), + dom.clickbutton('Generate random password', function click(e: MouseEvent) { + e.preventDefault() + let b = new Uint8Array(1) + let s = '' + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_;:,<.>/' + while (s.length < 12) { + self.crypto.getRandomValues(b) + if (Math.ceil(b[0]/chars.length)*chars.length > 255) { + continue // Prevent bias. + } + s += chars[b[0]%chars.length] + } + password1.type = 'text' + password2.type = 'text' + password1.value = s + password2.value = s + }), + dom.div(dom._class('text'), + box(yellow, 'Important: Bots will try to bruteforce your password. Connections with failed authentication attempts will be rate limited but attackers WILL find weak passwords. If your account is compromised, spammers are likely to abuse your system, spamming your address and the wider internet in your name. So please pick a random, unguessable password, preferrably at least 12 characters.'), + ), + ), + async function submit(e: SubmitEvent) { + e.stopPropagation() + e.preventDefault() + if (!password1.value || password1.value !== password2.value) { + window.alert('Passwords do not match.') + return + } + passwordFieldset.disabled = true + try { + await client.SetPassword(password1.value) + window.alert('Password has been changed.') + passwordForm.reset() + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + } finally { + passwordFieldset.disabled = false + } + }, + ), + dom.br(), + dom.h2('Export'), + dom.p('Export all messages in all mailboxes. In maildir or mbox format, as .zip or .tgz file.'), + dom.ul( + dom.li(dom.a('mail-export-maildir.tgz', attr.href('mail-export-maildir.tgz'))), + dom.li(dom.a('mail-export-maildir.zip', attr.href('mail-export-maildir.zip'))), + dom.li(dom.a('mail-export-mbox.tgz', attr.href('mail-export-mbox.tgz'))), + dom.li(dom.a('mail-export-mbox.zip', attr.href('mail-export-mbox.zip'))), + ), + dom.br(), + dom.h2('Import'), + dom.p('Import messages from a .zip or .tgz file with maildirs and/or mbox files.'), + importForm=dom.form( + async function submit(e: SubmitEvent) { + e.preventDefault() + e.stopPropagation() + + const request = async (): Promise => { + return new Promise((resolve, reject) => { + // Browsers can do everything. Except show a progress bar while uploading... + let progressPercentage: HTMLElement + dom._kids(importProgress, + dom.div( + dom.div('Uploading... ', progressPercentage=dom.span()), + ), + ) + importProgress.style.display = '' + + const xhr = new window.XMLHttpRequest() + xhr.open('POST', 'import', true) + xhr.upload.addEventListener('progress', (e) => { + if (!e.lengthComputable) { + return + } + const pct = Math.floor(100*e.loaded/e.total) + dom._kids(progressPercentage, pct+'%') + }) + xhr.addEventListener('load', () => { + console.log('upload done', {xhr: xhr, status: xhr.status}) + if (xhr.status !== 200) { + reject({message: 'status '+xhr.status}) + return + } + let resp: api.ImportProgress + try { + resp = api.parser.ImportProgress(JSON.parse(xhr.responseText)) + } catch (err) { + reject({message: 'parsing response json: '+errmsg(err)}) + return + } + resolve(resp) + }) + xhr.addEventListener('error', (e) => reject({message: 'upload error', event: e})) + xhr.addEventListener('abort', (e) => reject({message: 'upload aborted', event: e})) + xhr.send(new window.FormData(importForm)) + }) + } + try { + const p = request() + importFieldset.disabled = true + const result = await p + + try { + window.sessionStorage.setItem('ImportToken', result.Token) + } catch (err) { + console.log('storing import token in session storage', {err}) + // Ignore error, could be some browser security thing like private browsing. + } + + await importTrack(result.Token) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + } finally { + importFieldset.disabled = false + } + }, + importFieldset=dom.fieldset( + dom.div( + style({marginBottom: '1ex'}), + dom.label( + dom.div(style({marginBottom: '.5ex'}), 'File'), + dom.input(attr.type('file'), attr.required(''), attr.name('file'), function focus() { + mailboxFileHint.style.display = '' + }), + ), + mailboxFileHint=dom.p(style({display: 'none', fontStyle: 'italic', marginTop: '.5ex'}), 'This file must either be a zip file or a gzipped tar file with mbox and/or maildir mailboxes. For maildirs, an optional file "dovecot-keywords" is read additional keywords, like Forwarded/Junk/NotJunk. If an imported mailbox already exists by name, messages are added to the existing mailbox. If a mailbox does not yet exist it will be created.'), + ), + dom.div( + style({marginBottom: '1ex'}), + dom.label( + dom.div(style({marginBottom: '.5ex'}), 'Skip mailbox prefix (optional)'), + dom.input(attr.name('skipMailboxPrefix'), function focus() { + mailboxPrefixHint.style.display = '' + }), + ), + mailboxPrefixHint=dom.p(style({display: 'none', fontStyle: 'italic', marginTop: '.5ex'}), 'If set, any mbox/maildir path with this prefix will have it stripped before importing. For example, if all mailboxes are in a directory "Takeout", specify that path in the field above so mailboxes like "Takeout/Inbox.mbox" are imported into a mailbox called "Inbox" instead of "Takeout/Inbox".'), + ), + dom.div( + dom.submitbutton('Upload and import'), + dom.p(style({fontStyle: 'italic', marginTop: '.5ex'}), 'The file is uploaded first, then its messages are imported, finally messages are matched for threading. Importing is done in a transaction, you can abort the entire import before it is finished.'), + ), + ), + ), + importAbortBox=dom.div(), // Outside fieldset because it gets disabled, above progress because may be scrolling it down quickly with problems. + importProgress=dom.div( + style({display: 'none'}), + ), + footer, + ) + + // Try to show the progress of an earlier import session. The user may have just + // refreshed the browser. + let importToken: string + try { + importToken = window.sessionStorage.getItem('ImportToken') || '' + } catch (err) { + console.log('looking up ImportToken in session storage', {err}) + return + } + if (!importToken) { + return + } + importFieldset.disabled = true + dom._kids(importProgress, + dom.div( + dom.div('Reconnecting to import...'), + ), + ) + importProgress.style.display = '' + importTrack(importToken) + .catch(() => { + if (window.confirm('Error reconnecting to import. Remove this import session?')) { + window.sessionStorage.removeItem('ImportToken') + dom._kids(importProgress) + importProgress.style.display = 'none' + } + }) + .finally(() => { + importFieldset.disabled = false + }) +} + +const destination = async (name: string) => { + const [_, domain, destinations] = await client.Account() + let dest = destinations[name] + if (!dest) { + throw new Error('destination not found') + } + + type Header = { + root: HTMLElement + + key: HTMLInputElement + value: HTMLInputElement + } + + type Row = { + root: HTMLElement + + smtpMailFromRegexp: HTMLInputElement + verifiedDomain: HTMLInputElement + headers: Header[] + isForward: HTMLInputElement // Checkbox + listAllowDomain: HTMLInputElement + acceptRejectsToMailbox: HTMLInputElement + mailbox: HTMLInputElement + } + + let rulesetsTbody = dom.tbody() + let rulesetsRows: Row[] = [] + + const addRulesetsRow = (rs: api.Ruleset) => { + let row: Row + let headersCell = dom.td() + + const addHeader = (k: string, v: string) => { + let h: Header + let key: HTMLInputElement + let value: HTMLInputElement + + const root = dom.div( + key=dom.input(attr.value(k)), + ' ', + value=dom.input(attr.value(v)), + ' ', + dom.clickbutton('-', style({width: '1.5em'}), function click() { + h.root.remove() + row.headers = row.headers.filter(x => x !== h) + if (row.headers.length === 0) { + const b = dom.clickbutton('+', style({width: '1.5em'}), function click() { + b.remove() + addHeader('', '') + }) + headersCell.appendChild(dom.div(style({textAlign: 'right'}), b)) + } + }), + ' ', + dom.clickbutton('+', style({width: '1.5em'}), function click() { + addHeader('', '') + }), + ) + h = {root: root, key: key, value: value} + row.headers.push(h) + headersCell.appendChild(root) + } + + let smtpMailFromRegexp: HTMLInputElement + let verifiedDomain: HTMLInputElement + let isForward: HTMLInputElement // Checkbox + let listAllowDomain: HTMLInputElement + let acceptRejectsToMailbox: HTMLInputElement + let mailbox: HTMLInputElement + + const root = dom.tr( + dom.td(smtpMailFromRegexp=dom.input(attr.value(rs.SMTPMailFromRegexp || ''))), + dom.td(verifiedDomain=dom.input(attr.value(rs.VerifiedDomain || ''))), + headersCell, + dom.td(dom.label(isForward=dom.input(attr.type('checkbox'), rs.IsForward ? attr.checked('') : [] ))), + dom.td(listAllowDomain=dom.input(attr.value(rs.ListAllowDomain || ''))), + dom.td(acceptRejectsToMailbox=dom.input(attr.value(rs.AcceptRejectsToMailbox || ''))), + dom.td(mailbox=dom.input(attr.value(rs.Mailbox || ''))), + dom.td( + dom.clickbutton('Remove ruleset', function click() { + row.root.remove() + rulesetsRows = rulesetsRows.filter(e => e !== row) + }), + ), + ) + row = { + root: root, + smtpMailFromRegexp: smtpMailFromRegexp, + verifiedDomain: verifiedDomain, + headers: [], + isForward: isForward, + listAllowDomain: listAllowDomain, + acceptRejectsToMailbox: acceptRejectsToMailbox, + mailbox: mailbox, + } + rulesetsRows.push(row) + + Object.entries(rs.HeadersRegexp || {}).sort().forEach(t => + addHeader(t[0], t[1]) + ) + if (Object.entries(rs.HeadersRegexp || {}).length === 0) { + const b = dom.clickbutton('+', style({width: '1.5em'}), function click() { + b.remove() + addHeader('', '') + }) + headersCell.appendChild(dom.div(style({textAlign: 'right'}), b)) + } + + rulesetsTbody.appendChild(row.root) + } + + (dest.Rulesets || []).forEach(rs => { + addRulesetsRow(rs) + }) + + let defaultMailbox: HTMLInputElement + let fullName: HTMLInputElement + let saveButton: HTMLButtonElement + + const addresses = [name, ...Object.keys(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('Verified domain', attr.title('Matches if this domain matches an SPF- and/or DKIM-verified (sub)domain.')), + dom.th('Headers regexp', attr.title('Matches if these header field/value regular expressions all match (substrings of) the message headers. Header fields and valuees are converted to lower case before matching. Whitespace is trimmed from the value before matching. A header field can occur multiple times in a message, only one instance has to match. For mailing lists, you could match on ^list-id$ with the value typically the mailing list address in angled brackets with @ replaced with a dot, e.g. .')), + dom.th('Is Forward', attr.title("Influences spam filtering only, this option does not change whether a message matches this ruleset. Can only be used together with SMTPMailFromRegexp and VerifiedDomain. SMTPMailFromRegexp must be set to the address used to deliver the forwarded message, e.g. '^user(|\\+.*)@forward\\.example$'. Changes to junk analysis: 1. Messages are not rejects 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('Action'), + ) + ), + rulesetsTbody, + dom.tfoot( + dom.tr( + dom.td(attr.colspan('7')), + dom.td( + dom.clickbutton('Add ruleset', function click() { + addRulesetsRow({ + SMTPMailFromRegexp: '', + VerifiedDomain: '', + HeadersRegexp: {}, + IsForward: false, + ListAllowDomain: '', + AcceptRejectsToMailbox: '', + Mailbox: '', + VerifiedDNSDomain: {ASCII: '', Unicode: ''}, + ListAllowDNSDomain: {ASCII: '', Unicode: ''}, + }) + }), + ), + ), + ), + ), + dom.br(), + saveButton=dom.clickbutton('Save', async function click() { + saveButton.disabled = true + try { + const newDest = { + Mailbox: defaultMailbox.value, + FullName: fullName.value, + Rulesets: rulesetsRows.map(row => { + return { + SMTPMailFromRegexp: row.smtpMailFromRegexp.value, + VerifiedDomain: row.verifiedDomain.value, + HeadersRegexp: Object.fromEntries(row.headers.map(h => [h.key.value, h.value.value])), + IsForward: row.isForward.checked, + ListAllowDomain: row.listAllowDomain.value, + AcceptRejectsToMailbox: row.acceptRejectsToMailbox.value, + Mailbox: row.mailbox.value, + VerifiedDNSDomain: {ASCII: '', Unicode: ''}, + ListAllowDNSDomain: {ASCII: '', Unicode: ''}, + } + }), + } + page.classList.add('loading') + await client.DestinationSave(name, dest, newDest) + window.location.reload() // todo: only refresh part of ui + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + page.classList.remove('loading') + return + } finally { + saveButton.disabled = false + } + }), + dom.br(), + dom.br(), + dom.br(), + dom.p("Apple's mail applications don't do account autoconfiguration, and when adding an account it can choose defaults that don't work with modern email servers. Adding an account through a \"mobileconfig\" profile file can be more convenient: It contains the IMAP/SMTP settings such as host name, port, TLS, authentication mechanism and user name. This profile does not contain a login password. Opening the profile adds it under Profiles in System Preferences (macOS) or Settings (iOS), where you can install it. These profiles are not signed, so users will have to ignore the warnings about them being unsigned. ", + dom.br(), + dom.a(attr.href('https://autoconfig.'+domainName(domain)+'/profile.mobileconfig?addresses='+encodeURIComponent(addresses.join(','))+'&name='+encodeURIComponent(dest.FullName)), attr.download(''), 'Download .mobileconfig email account profile'), + dom.br(), + dom.a(attr.href('https://autoconfig.'+domainName(domain)+'/profile.mobileconfig.qrcode.png?addresses='+encodeURIComponent(addresses.join(','))+'&name='+encodeURIComponent(dest.FullName)), attr.download(''), 'Open QR-code with link to .mobileconfig profile'), + ), + ) +} + +const init = async () => { + let curhash: string | undefined + + const hashChange = async () => { + if (curhash === window.location.hash) { + return + } + let h = decodeURIComponent(window.location.hash) + if (h !== '' && h.substring(0, 1) == '#') { + h = h.substring(1) + } + const t = h.split('/') + page.classList.add('loading') + try { + if (h === '') { + await index() + } else if (t[0] === 'destinations' && t.length === 2) { + await destination(t[1]) + } else { + dom._kids(page, 'page not found') + } + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + window.location.hash = curhash || '' + curhash = window.location.hash + return + } + curhash = window.location.hash + page.classList.remove('loading') + } + window.addEventListener('hashchange', hashChange) + hashChange() +} + +window.addEventListener('load', async () => { + try { + await init() + } catch (err) { + window.alert('Error: ' + errmsg(err)) + } +}) diff --git a/webaccount/account_test.go b/webaccount/account_test.go index 0f32849..eaf7181 100644 --- a/webaccount/account_test.go +++ b/webaccount/account_test.go @@ -104,13 +104,12 @@ func TestAccount(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("import, got status code %d, expected 200: %s", w.Code, w.Body.Bytes()) } - m := map[string]string{} + var m ImportProgress if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil { t.Fatalf("parsing import response: %v", err) } - token := m["ImportToken"] - l := importListener{token, make(chan importEvent, 100), make(chan bool)} + l := importListener{m.Token, make(chan importEvent, 100), make(chan bool)} importers.Register <- &l if !<-l.Register { t.Fatalf("register failed") diff --git a/webaccount/accountapi.json b/webaccount/api.json similarity index 89% rename from webaccount/accountapi.json rename to webaccount/api.json index 4cb88dc..8837022 100644 --- a/webaccount/accountapi.json +++ b/webaccount/api.json @@ -91,6 +91,19 @@ } ], "Returns": [] + }, + { + "Name": "Types", + "Docs": "Types exposes types not used in API method signatures, such as the import form upload.", + "Params": [], + "Returns": [ + { + "Name": "importProgress", + "Typewords": [ + "ImportProgress" + ] + } + ] } ], "Sections": [], @@ -212,6 +225,19 @@ ] } ] + }, + { + "Name": "ImportProgress", + "Docs": "ImportProgress is returned after uploading a file to import.", + "Fields": [ + { + "Name": "Token", + "Docs": "For fetching progress, or cancelling an import.", + "Typewords": [ + "string" + ] + } + ] } ], "Ints": [], diff --git a/webaccount/api.ts b/webaccount/api.ts new file mode 100644 index 0000000..aae6d96 --- /dev/null +++ b/webaccount/api.ts @@ -0,0 +1,525 @@ +// NOTE: GENERATED by github.com/mjl-/sherpats, DO NOT MODIFY + +namespace api { + +// Domain is a domain name, with one or more labels, with at least an ASCII +// representation, and for IDNA non-ASCII domains a unicode representation. +// The ASCII string must be used for DNS lookups. The strings do not have a +// trailing dot. When using with StrictResolver, add the trailing dot. +export interface Domain { + ASCII: string // A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case. No trailing dot. + Unicode: string // Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot. +} + +export interface Destination { + Mailbox: string + Rulesets?: Ruleset[] | null + FullName: string +} + +export interface Ruleset { + SMTPMailFromRegexp: string + VerifiedDomain: string + HeadersRegexp?: { [key: string]: string } + IsForward: boolean // todo: once we implement ARC, we can use dkim domains that we cannot verify but that the arc-verified forwarding mail server was able to verify. + ListAllowDomain: string + AcceptRejectsToMailbox: string + Mailbox: string + VerifiedDNSDomain: Domain + ListAllowDNSDomain: Domain +} + +// ImportProgress is returned after uploading a file to import. +export interface ImportProgress { + Token: string // For fetching progress, or cancelling an import. +} + +export const structTypes: {[typename: string]: boolean} = {"Destination":true,"Domain":true,"ImportProgress":true,"Ruleset":true} +export const stringsTypes: {[typename: string]: boolean} = {} +export const intsTypes: {[typename: string]: boolean} = {} +export const types: TypenameMap = { + "Domain": {"Name":"Domain","Docs":"","Fields":[{"Name":"ASCII","Docs":"","Typewords":["string"]},{"Name":"Unicode","Docs":"","Typewords":["string"]}]}, + "Destination": {"Name":"Destination","Docs":"","Fields":[{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Rulesets","Docs":"","Typewords":["[]","Ruleset"]},{"Name":"FullName","Docs":"","Typewords":["string"]}]}, + "Ruleset": {"Name":"Ruleset","Docs":"","Fields":[{"Name":"SMTPMailFromRegexp","Docs":"","Typewords":["string"]},{"Name":"VerifiedDomain","Docs":"","Typewords":["string"]},{"Name":"HeadersRegexp","Docs":"","Typewords":["{}","string"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ListAllowDomain","Docs":"","Typewords":["string"]},{"Name":"AcceptRejectsToMailbox","Docs":"","Typewords":["string"]},{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"VerifiedDNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"ListAllowDNSDomain","Docs":"","Typewords":["Domain"]}]}, + "ImportProgress": {"Name":"ImportProgress","Docs":"","Fields":[{"Name":"Token","Docs":"","Typewords":["string"]}]}, +} + +export const parser = { + Domain: (v: any) => parse("Domain", v) as Domain, + Destination: (v: any) => parse("Destination", v) as Destination, + Ruleset: (v: any) => parse("Ruleset", v) as Ruleset, + ImportProgress: (v: any) => parse("ImportProgress", v) as ImportProgress, +} + +// Account exports web API functions for the account web interface. All its +// methods are exported under api/. Function calls require valid HTTP +// Authentication credentials of a user. +let defaultOptions: ClientOptions = {slicesNullable: true, mapsNullable: true, nullableOptional: true} + +export class Client { + constructor(private baseURL=defaultBaseURL, public options?: ClientOptions) { + if (!options) { + this.options = defaultOptions + } + } + + withOptions(options: ClientOptions): Client { + return new Client(this.baseURL, { ...this.options, ...options }) + } + + // SetPassword saves a new password for the account, invalidating the previous password. + // Sessions are not interrupted, and will keep working. New login attempts must use the new password. + // Password must be at least 8 characters. + async SetPassword(password: string): Promise { + const fn: string = "SetPassword" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [] + const params: any[] = [password] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // Account returns information about the account: full name, the default domain, + // and the destinations (keys are email addresses, or localparts to the default + // domain). todo: replace with a function that returns the whole account, when + // sherpadoc understands unnamed struct fields. + async Account(): Promise<[string, Domain, { [key: string]: Destination }]> { + const fn: string = "Account" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["string"],["Domain"],["{}","Destination"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [string, Domain, { [key: string]: Destination }] + } + + async AccountSaveFullName(fullName: string): Promise { + const fn: string = "AccountSaveFullName" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [] + const params: any[] = [fullName] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // DestinationSave updates a destination. + // OldDest is compared against the current destination. If it does not match, an + // error is returned. Otherwise newDest is saved and the configuration reloaded. + async DestinationSave(destName: string, oldDest: Destination, newDest: Destination): Promise { + const fn: string = "DestinationSave" + const paramTypes: string[][] = [["string"],["Destination"],["Destination"]] + const returnTypes: string[][] = [] + const params: any[] = [destName, oldDest, newDest] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // ImportAbort aborts an import that is in progress. If the import exists and isn't + // finished, no changes will have been made by the import. + async ImportAbort(importToken: string): Promise { + const fn: string = "ImportAbort" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [] + const params: any[] = [importToken] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // Types exposes types not used in API method signatures, such as the import form upload. + async Types(): Promise { + const fn: string = "Types" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["ImportProgress"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as ImportProgress + } +} + +export const defaultBaseURL = (function() { + let p = location.pathname + if (p && p[p.length - 1] !== '/') { + let l = location.pathname.split('/') + l = l.slice(0, l.length - 1) + p = '/' + l.join('/') + '/' + } + return location.protocol + '//' + location.host + p + 'api/' +})() + +// NOTE: code below is shared between github.com/mjl-/sherpaweb and github.com/mjl-/sherpats. +// KEEP IN SYNC. + +export const supportedSherpaVersion = 1 + +export interface Section { + Name: string + Docs: string + Functions: Function[] + Sections: Section[] + Structs: Struct[] + Ints: Ints[] + Strings: Strings[] + Version: string // only for top-level section + SherpaVersion: number // only for top-level section + SherpadocVersion: number // only for top-level section +} + +export interface Function { + Name: string + Docs: string + Params: Arg[] + Returns: Arg[] +} + +export interface Arg { + Name: string + Typewords: string[] +} + +export interface Struct { + Name: string + Docs: string + Fields: Field[] +} + +export interface Field { + Name: string + Docs: string + Typewords: string[] +} + +export interface Ints { + Name: string + Docs: string + Values: { + Name: string + Value: number + Docs: string + }[] | null +} + +export interface Strings { + Name: string + Docs: string + Values: { + Name: string + Value: string + Docs: string + }[] | null +} + +export type NamedType = Struct | Strings | Ints +export type TypenameMap = { [k: string]: NamedType } + +// verifyArg typechecks "v" against "typewords", returning a new (possibly modified) value for JSON-encoding. +// toJS indicate if the data is coming into JS. If so, timestamps are turned into JS Dates. Otherwise, JS Dates are turned into strings. +// allowUnknownKeys configures whether unknown keys in structs are allowed. +// types are the named types of the API. +export const verifyArg = (path: string, v: any, typewords: string[], toJS: boolean, allowUnknownKeys: boolean, types: TypenameMap, opts: ClientOptions): any => { + return new verifier(types, toJS, allowUnknownKeys, opts).verify(path, v, typewords) +} + +export const parse = (name: string, v: any): any => verifyArg(name, v, [name], true, false, types, defaultOptions) + +class verifier { + constructor(private types: TypenameMap, private toJS: boolean, private allowUnknownKeys: boolean, private opts: ClientOptions) { + } + + verify(path: string, v: any, typewords: string[]): any { + typewords = typewords.slice(0) + const ww = typewords.shift() + + const error = (msg: string) => { + if (path != '') { + msg = path + ': ' + msg + } + throw new Error(msg) + } + + if (typeof ww !== 'string') { + error('bad typewords') + return // should not be necessary, typescript doesn't see error always throws an exception? + } + const w: string = ww + + const ensure = (ok: boolean, expect: string): any => { + if (!ok) { + error('got ' + JSON.stringify(v) + ', expected ' + expect) + } + return v + } + + switch (w) { + case 'nullable': + if (v === null || v === undefined && this.opts.nullableOptional) { + return v + } + return this.verify(path, v, typewords) + case '[]': + if (v === null && this.opts.slicesNullable || v === undefined && this.opts.slicesNullable && this.opts.nullableOptional) { + return v + } + ensure(Array.isArray(v), "array") + return v.map((e: any, i: number) => this.verify(path + '[' + i + ']', e, typewords)) + case '{}': + if (v === null && this.opts.mapsNullable || v === undefined && this.opts.mapsNullable && this.opts.nullableOptional) { + return v + } + ensure(v !== null || typeof v === 'object', "object") + const r: any = {} + for (const k in v) { + r[k] = this.verify(path + '.' + k, v[k], typewords) + } + return r + } + + ensure(typewords.length == 0, "empty typewords") + const t = typeof v + switch (w) { + case 'any': + return v + case 'bool': + ensure(t === 'boolean', 'bool') + return v + case 'int8': + case 'uint8': + case 'int16': + case 'uint16': + case 'int32': + case 'uint32': + case 'int64': + case 'uint64': + ensure(t === 'number' && Number.isInteger(v), 'integer') + return v + case 'float32': + case 'float64': + ensure(t === 'number', 'float') + return v + case 'int64s': + case 'uint64s': + ensure(t === 'number' && Number.isInteger(v) || t === 'string', 'integer fitting in float without precision loss, or string') + return '' + v + case 'string': + ensure(t === 'string', 'string') + return v + case 'timestamp': + if (this.toJS) { + ensure(t === 'string', 'string, with timestamp') + const d = new Date(v) + if (d instanceof Date && !isNaN(d.getTime())) { + return d + } + error('invalid date ' + v) + } else { + ensure(t === 'object' && v !== null, 'non-null object') + ensure(v.__proto__ === Date.prototype, 'Date') + return v.toISOString() + } + } + + // We're left with named types. + const nt = this.types[w] + if (!nt) { + error('unknown type ' + w) + } + if (v === null) { + error('bad value ' + v + ' for named type ' + w) + } + + if (structTypes[nt.Name]) { + const t = nt as Struct + if (typeof v !== 'object') { + error('bad value ' + v + ' for struct ' + w) + } + + const r: any = {} + for (const f of t.Fields) { + r[f.Name] = this.verify(path + '.' + f.Name, v[f.Name], f.Typewords) + } + // If going to JSON also verify no unknown fields are present. + if (!this.allowUnknownKeys) { + const known: { [key: string]: boolean } = {} + for (const f of t.Fields) { + known[f.Name] = true + } + Object.keys(v).forEach((k) => { + if (!known[k]) { + error('unknown key ' + k + ' for struct ' + w) + } + }) + } + return r + } else if (stringsTypes[nt.Name]) { + const t = nt as Strings + if (typeof v !== 'string') { + error('mistyped value ' + v + ' for named strings ' + t.Name) + } + if (!t.Values || t.Values.length === 0) { + return v + } + for (const sv of t.Values) { + if (sv.Value === v) { + return v + } + } + error('unknkown value ' + v + ' for named strings ' + t.Name) + } else if (intsTypes[nt.Name]) { + const t = nt as Ints + if (typeof v !== 'number' || !Number.isInteger(v)) { + error('mistyped value ' + v + ' for named ints ' + t.Name) + } + if (!t.Values || t.Values.length === 0) { + return v + } + for (const sv of t.Values) { + if (sv.Value === v) { + return v + } + } + error('unknkown value ' + v + ' for named ints ' + t.Name) + } else { + throw new Error('unexpected named type ' + nt) + } + } +} + + +export interface ClientOptions { + aborter?: {abort?: () => void} + timeoutMsec?: number + skipParamCheck?: boolean + skipReturnCheck?: boolean + slicesNullable?: boolean + mapsNullable?: boolean + nullableOptional?: boolean +} + +const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise => { + if (!options.skipParamCheck) { + if (params.length !== paramTypes.length) { + return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length }) + } + params = params.map((v: any, index: number) => verifyArg('params[' + index + ']', v, paramTypes[index], false, false, types, options)) + } + const simulate = async (json: string) => { + const config = JSON.parse(json || 'null') || {} + const waitMinMsec = config.waitMinMsec || 0 + const waitMaxMsec = config.waitMaxMsec || 0 + const wait = Math.random() * (waitMaxMsec - waitMinMsec) + const failRate = config.failRate || 0 + return new Promise((resolve, reject) => { + if (options.aborter) { + options.aborter.abort = () => { + reject({ message: 'call to ' + name + ' aborted by user', code: 'sherpa:aborted' }) + reject = resolve = () => { } + } + } + setTimeout(() => { + const r = Math.random() + if (r < failRate) { + reject({ message: 'injected failure on ' + name, code: 'server:injected' }) + } else { + resolve() + } + reject = resolve = () => { } + }, waitMinMsec + wait) + }) + } + // Only simulate when there is a debug string. Otherwise it would always interfere + // with setting options.aborter. + let json: string = '' + try { + json = window.localStorage.getItem('sherpats-debug') || '' + } catch (err) {} + if (json) { + await simulate(json) + } + + // Immediately create promise, so options.aborter is changed before returning. + const promise = new Promise((resolve, reject) => { + let resolve1 = (v: { code: string, message: string }) => { + resolve(v) + resolve1 = () => { } + reject1 = () => { } + } + let reject1 = (v: { code: string, message: string }) => { + reject(v) + resolve1 = () => { } + reject1 = () => { } + } + + const url = baseURL + name + const req = new window.XMLHttpRequest() + if (options.aborter) { + options.aborter.abort = () => { + req.abort() + reject1({ code: 'sherpa:aborted', message: 'request aborted' }) + } + } + req.open('POST', url, true) + if (options.timeoutMsec) { + req.timeout = options.timeoutMsec + } + req.onload = () => { + if (req.status !== 200) { + if (req.status === 404) { + reject1({ code: 'sherpa:badFunction', message: 'function does not exist' }) + } else { + reject1({ code: 'sherpa:http', message: 'error calling function, HTTP status: ' + req.status }) + } + return + } + + let resp: any + try { + resp = JSON.parse(req.responseText) + } catch (err) { + reject1({ code: 'sherpa:badResponse', message: 'bad JSON from server' }) + return + } + if (resp && resp.error) { + const err = resp.error + reject1({ code: err.code, message: err.message }) + return + } else if (!resp || !resp.hasOwnProperty('result')) { + reject1({ code: 'sherpa:badResponse', message: "invalid sherpa response object, missing 'result'" }) + return + } + + if (options.skipReturnCheck) { + resolve1(resp.result) + return + } + let result = resp.result + try { + if (returnTypes.length === 0) { + if (result) { + throw new Error('function ' + name + ' returned a value while prototype says it returns "void"') + } + } else if (returnTypes.length === 1) { + result = verifyArg('result', result, returnTypes[0], true, true, types, options) + } else { + if (result.length != returnTypes.length) { + throw new Error('wrong number of values returned by ' + name + ', saw ' + result.length + ' != expected ' + returnTypes.length) + } + result = result.map((v: any, index: number) => verifyArg('result[' + index + ']', v, returnTypes[index], true, true, types, options)) + } + } catch (err) { + let errmsg = 'bad types' + if (err instanceof Error) { + errmsg = err.message + } + reject1({ code: 'sherpa:badTypes', message: errmsg }) + } + resolve1(result) + } + req.onerror = () => { + reject1({ code: 'sherpa:connection', message: 'connection failed' }) + } + req.ontimeout = () => { + reject1({ code: 'sherpa:timeout', message: 'request timeout' }) + } + req.setRequestHeader('Content-Type', 'application/json') + try { + req.send(JSON.stringify({ params: params })) + } catch (err) { + reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' }) + } + }) + return await promise +} + +} diff --git a/webadmin/admin.go b/webadmin/admin.go index 35ebbc2..5102c23 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -18,11 +18,11 @@ import ( "encoding/json" "errors" "fmt" - "io" "net" "net/http" "net/url" "os" + "path/filepath" "reflect" "runtime/debug" "sort" @@ -67,12 +67,22 @@ import ( var pkglog = mlog.New("webadmin", nil) -//go:embed adminapi.json +//go:embed api.json var adminapiJSON []byte //go:embed admin.html var adminHTML []byte +//go:embed admin.js +var adminJS []byte + +var webadminFile = &mox.WebappFile{ + HTML: adminHTML, + JS: adminJS, + HTMLPath: filepath.FromSlash("webadmin/admin.html"), + JSPath: filepath.FromSlash("webadmin/admin.js"), +} + var adminDoc = mustParseAPI("admin", adminapiJSON) var adminSherpaHandler http.Handler @@ -206,18 +216,15 @@ func Handle(w http.ResponseWriter, r *http.Request) { lw.AddAttr(slog.Bool("authadmin", true)) } - if r.Method == "GET" && r.URL.Path == "/" { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache; max-age=0") - // We typically return the embedded admin.html, but during development it's handy - // to load from disk. - f, err := os.Open("webadmin/admin.html") - if err == nil { - defer f.Close() - _, _ = io.Copy(w, f) - } else { - _, _ = w.Write(adminHTML) + if r.URL.Path == "/" { + switch r.Method { + default: + http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed) + return + case "GET", "HEAD": } + + webadminFile.Serve(ctx, pkglog.WithContext(ctx), w, r) return } adminSherpaHandler.ServeHTTP(w, r.WithContext(ctx)) @@ -338,7 +345,7 @@ type MTASTSCheckResult struct { } type SRVConfCheckResult struct { - SRVs map[string][]*net.SRV // Service (e.g. "_imaps") to records. + SRVs map[string][]net.SRV // Service (e.g. "_imaps") to records. Result } @@ -416,6 +423,17 @@ func (Admin) CheckDomain(ctx context.Context, domainName string) (r CheckResult) return checkDomain(nctx, resolver, dialer, domainName) } +func unptr[T any](l []*T) []T { + if l == nil { + return nil + } + r := make([]T, len(l)) + for i, e := range l { + r[i] = *e + } + return r +} + func checkDomain(ctx context.Context, resolver dns.Resolver, dialer *net.Dialer, domainName string) (r CheckResult) { log := pkglog.WithContext(ctx) @@ -1362,11 +1380,11 @@ When enabling MTA-STS, or updating a policy, always update the policy first (thr srvwg.Wait() instr := "Ensure DNS records like the following exist:\n\n" - r.SRVConf.SRVs = map[string][]*net.SRV{} + r.SRVConf.SRVs = map[string][]net.SRV{} for _, req := range reqs { name := req.name + "_.tcp." + domain.ASCII instr += fmt.Sprintf("\t%s._tcp.%-*s SRV 0 1 %d %s\n", req.name, len("_submissions")-len(req.name)+len(domain.ASCII+"."), domain.ASCII+".", req.port, req.host) - r.SRVConf.SRVs[req.name] = req.srvs + r.SRVConf.SRVs[req.name] = unptr(req.srvs) if err != nil { addf(&r.SRVConf.Errors, "Looking up SRV record %q: %s", name, err) } else if len(req.srvs) == 0 { diff --git a/webadmin/admin.html b/webadmin/admin.html index fa10daf..e45a39b 100644 --- a/webadmin/admin.html +++ b/webadmin/admin.html @@ -24,2872 +24,15 @@ table.hover > tbody > tr:hover { background-color: #f0f0f0; } p { margin-bottom: 1em; max-width: 50em; } [title] { text-decoration: underline; text-decoration-style: dotted; } fieldset { border: 0; } +.scriptswitch { text-decoration: underline #dca053 2px; } #page { opacity: 1; animation: fadein 0.15s ease-in; } #page.loading { opacity: 0.1; animation: fadeout 1s ease-out; } @keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } } @keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } } - - -
Loading...
- - +
Loading...
+ diff --git a/webadmin/admin.js b/webadmin/admin.js new file mode 100644 index 0000000..c4a9953 --- /dev/null +++ b/webadmin/admin.js @@ -0,0 +1,2978 @@ +"use strict"; +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. +const [dom, style, attr, prop] = (function () { + // Start of unicode block (rough approximation of script), from https://www.unicode.org/Public/UNIDATA/Blocks.txt + const scriptblocks = [0x0000, 0x0080, 0x0100, 0x0180, 0x0250, 0x02B0, 0x0300, 0x0370, 0x0400, 0x0500, 0x0530, 0x0590, 0x0600, 0x0700, 0x0750, 0x0780, 0x07C0, 0x0800, 0x0840, 0x0860, 0x0870, 0x08A0, 0x0900, 0x0980, 0x0A00, 0x0A80, 0x0B00, 0x0B80, 0x0C00, 0x0C80, 0x0D00, 0x0D80, 0x0E00, 0x0E80, 0x0F00, 0x1000, 0x10A0, 0x1100, 0x1200, 0x1380, 0x13A0, 0x1400, 0x1680, 0x16A0, 0x1700, 0x1720, 0x1740, 0x1760, 0x1780, 0x1800, 0x18B0, 0x1900, 0x1950, 0x1980, 0x19E0, 0x1A00, 0x1A20, 0x1AB0, 0x1B00, 0x1B80, 0x1BC0, 0x1C00, 0x1C50, 0x1C80, 0x1C90, 0x1CC0, 0x1CD0, 0x1D00, 0x1D80, 0x1DC0, 0x1E00, 0x1F00, 0x2000, 0x2070, 0x20A0, 0x20D0, 0x2100, 0x2150, 0x2190, 0x2200, 0x2300, 0x2400, 0x2440, 0x2460, 0x2500, 0x2580, 0x25A0, 0x2600, 0x2700, 0x27C0, 0x27F0, 0x2800, 0x2900, 0x2980, 0x2A00, 0x2B00, 0x2C00, 0x2C60, 0x2C80, 0x2D00, 0x2D30, 0x2D80, 0x2DE0, 0x2E00, 0x2E80, 0x2F00, 0x2FF0, 0x3000, 0x3040, 0x30A0, 0x3100, 0x3130, 0x3190, 0x31A0, 0x31C0, 0x31F0, 0x3200, 0x3300, 0x3400, 0x4DC0, 0x4E00, 0xA000, 0xA490, 0xA4D0, 0xA500, 0xA640, 0xA6A0, 0xA700, 0xA720, 0xA800, 0xA830, 0xA840, 0xA880, 0xA8E0, 0xA900, 0xA930, 0xA960, 0xA980, 0xA9E0, 0xAA00, 0xAA60, 0xAA80, 0xAAE0, 0xAB00, 0xAB30, 0xAB70, 0xABC0, 0xAC00, 0xD7B0, 0xD800, 0xDB80, 0xDC00, 0xE000, 0xF900, 0xFB00, 0xFB50, 0xFE00, 0xFE10, 0xFE20, 0xFE30, 0xFE50, 0xFE70, 0xFF00, 0xFFF0, 0x10000, 0x10080, 0x10100, 0x10140, 0x10190, 0x101D0, 0x10280, 0x102A0, 0x102E0, 0x10300, 0x10330, 0x10350, 0x10380, 0x103A0, 0x10400, 0x10450, 0x10480, 0x104B0, 0x10500, 0x10530, 0x10570, 0x10600, 0x10780, 0x10800, 0x10840, 0x10860, 0x10880, 0x108E0, 0x10900, 0x10920, 0x10980, 0x109A0, 0x10A00, 0x10A60, 0x10A80, 0x10AC0, 0x10B00, 0x10B40, 0x10B60, 0x10B80, 0x10C00, 0x10C80, 0x10D00, 0x10E60, 0x10E80, 0x10EC0, 0x10F00, 0x10F30, 0x10F70, 0x10FB0, 0x10FE0, 0x11000, 0x11080, 0x110D0, 0x11100, 0x11150, 0x11180, 0x111E0, 0x11200, 0x11280, 0x112B0, 0x11300, 0x11400, 0x11480, 0x11580, 0x11600, 0x11660, 0x11680, 0x11700, 0x11800, 0x118A0, 0x11900, 0x119A0, 0x11A00, 0x11A50, 0x11AB0, 0x11AC0, 0x11B00, 0x11C00, 0x11C70, 0x11D00, 0x11D60, 0x11EE0, 0x11F00, 0x11FB0, 0x11FC0, 0x12000, 0x12400, 0x12480, 0x12F90, 0x13000, 0x13430, 0x14400, 0x16800, 0x16A40, 0x16A70, 0x16AD0, 0x16B00, 0x16E40, 0x16F00, 0x16FE0, 0x17000, 0x18800, 0x18B00, 0x18D00, 0x1AFF0, 0x1B000, 0x1B100, 0x1B130, 0x1B170, 0x1BC00, 0x1BCA0, 0x1CF00, 0x1D000, 0x1D100, 0x1D200, 0x1D2C0, 0x1D2E0, 0x1D300, 0x1D360, 0x1D400, 0x1D800, 0x1DF00, 0x1E000, 0x1E030, 0x1E100, 0x1E290, 0x1E2C0, 0x1E4D0, 0x1E7E0, 0x1E800, 0x1E900, 0x1EC70, 0x1ED00, 0x1EE00, 0x1F000, 0x1F030, 0x1F0A0, 0x1F100, 0x1F200, 0x1F300, 0x1F600, 0x1F650, 0x1F680, 0x1F700, 0x1F780, 0x1F800, 0x1F900, 0x1FA00, 0x1FA70, 0x1FB00, 0x20000, 0x2A700, 0x2B740, 0x2B820, 0x2CEB0, 0x2F800, 0x30000, 0x31350, 0xE0000, 0xE0100, 0xF0000, 0x100000]; + // Find block code belongs in. + const findBlock = (code) => { + let s = 0; + let e = scriptblocks.length; + while (s < e - 1) { + let i = Math.floor((s + e) / 2); + if (code < scriptblocks[i]) { + e = i; + } + else { + s = i; + } + } + return s; + }; + // formatText adds s to element e, in a way that makes switching unicode scripts + // clear, with alternating DOM TextNode and span elements with a "switchscript" + // class. Useful for highlighting look alikes, e.g. a (ascii 0x61) and а (cyrillic + // 0x430). + // + // This is only called one string at a time, so the UI can still display strings + // without highlighting switching scripts, by calling formatText on the parts. + const formatText = (e, s) => { + // Handle some common cases quickly. + if (!s) { + return; + } + let ascii = true; + for (const c of s) { + const cp = c.codePointAt(0); // For typescript, to check for undefined. + if (cp !== undefined && cp >= 0x0080) { + ascii = false; + break; + } + } + if (ascii) { + e.appendChild(document.createTextNode(s)); + return; + } + // todo: handle grapheme clusters? wait for Intl.Segmenter? + let n = 0; // Number of text/span parts added. + let str = ''; // Collected so far. + let block = -1; // Previous block/script. + let mod = 1; + const put = (nextblock) => { + if (n === 0 && nextblock === 0) { + // Start was non-ascii, second block is ascii, we'll start marked as switched. + mod = 0; + } + if (n % 2 === mod) { + const x = document.createElement('span'); + x.classList.add('scriptswitch'); + x.appendChild(document.createTextNode(str)); + e.appendChild(x); + } + else { + e.appendChild(document.createTextNode(str)); + } + n++; + str = ''; + }; + for (const c of s) { + // Basic whitespace does not switch blocks. Will probably need to extend with more + // punctuation in the future. Possibly for digits too. But perhaps not in all + // scripts. + if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { + str += c; + continue; + } + const code = c.codePointAt(0); + if (block < 0 || !(code >= scriptblocks[block] && (code < scriptblocks[block + 1] || block === scriptblocks.length - 1))) { + const nextblock = code < 0x0080 ? 0 : findBlock(code); + if (block >= 0) { + put(nextblock); + } + block = nextblock; + } + str += c; + } + put(-1); + }; + const _domKids = (e, l) => { + l.forEach((c) => { + const xc = c; + if (typeof c === 'string') { + formatText(e, c); + } + else if (c instanceof String) { + // String is an escape-hatch for text that should not be formatted with + // unicode-block-change-highlighting, e.g. for textarea values. + e.appendChild(document.createTextNode('' + c)); + } + else if (c instanceof Element) { + e.appendChild(c); + } + else if (c instanceof Function) { + if (!c.name) { + throw new Error('function without name'); + } + e.addEventListener(c.name, c); + } + else if (Array.isArray(xc)) { + _domKids(e, c); + } + else if (xc._class) { + for (const s of xc._class) { + e.classList.toggle(s, true); + } + } + else if (xc._attrs) { + for (const k in xc._attrs) { + e.setAttribute(k, xc._attrs[k]); + } + } + else if (xc._styles) { + for (const k in xc._styles) { + const estyle = e.style; + estyle[k] = xc._styles[k]; + } + } + else if (xc._props) { + for (const k in xc._props) { + const eprops = e; + eprops[k] = xc._props[k]; + } + } + else if (xc.root) { + e.appendChild(xc.root); + } + else { + console.log('bad kid', c); + throw new Error('bad kid'); + } + }); + return e; + }; + const dom = { + _kids: function (e, ...kl) { + while (e.firstChild) { + e.removeChild(e.firstChild); + } + _domKids(e, kl); + }, + _attrs: (x) => { return { _attrs: x }; }, + _class: (...x) => { return { _class: x }; }, + // The createElement calls are spelled out so typescript can derive function + // signatures with a specific HTML*Element return type. + div: (...l) => _domKids(document.createElement('div'), l), + span: (...l) => _domKids(document.createElement('span'), l), + a: (...l) => _domKids(document.createElement('a'), l), + input: (...l) => _domKids(document.createElement('input'), l), + textarea: (...l) => _domKids(document.createElement('textarea'), l), + select: (...l) => _domKids(document.createElement('select'), l), + option: (...l) => _domKids(document.createElement('option'), l), + clickbutton: (...l) => _domKids(document.createElement('button'), [attr.type('button'), ...l]), + submitbutton: (...l) => _domKids(document.createElement('button'), [attr.type('submit'), ...l]), + form: (...l) => _domKids(document.createElement('form'), l), + fieldset: (...l) => _domKids(document.createElement('fieldset'), l), + table: (...l) => _domKids(document.createElement('table'), l), + thead: (...l) => _domKids(document.createElement('thead'), l), + tbody: (...l) => _domKids(document.createElement('tbody'), l), + tfoot: (...l) => _domKids(document.createElement('tfoot'), l), + tr: (...l) => _domKids(document.createElement('tr'), l), + td: (...l) => _domKids(document.createElement('td'), l), + th: (...l) => _domKids(document.createElement('th'), l), + datalist: (...l) => _domKids(document.createElement('datalist'), l), + h1: (...l) => _domKids(document.createElement('h1'), l), + h2: (...l) => _domKids(document.createElement('h2'), l), + h3: (...l) => _domKids(document.createElement('h3'), l), + br: (...l) => _domKids(document.createElement('br'), l), + hr: (...l) => _domKids(document.createElement('hr'), l), + pre: (...l) => _domKids(document.createElement('pre'), l), + label: (...l) => _domKids(document.createElement('label'), l), + ul: (...l) => _domKids(document.createElement('ul'), l), + li: (...l) => _domKids(document.createElement('li'), l), + iframe: (...l) => _domKids(document.createElement('iframe'), l), + b: (...l) => _domKids(document.createElement('b'), l), + img: (...l) => _domKids(document.createElement('img'), l), + style: (...l) => _domKids(document.createElement('style'), l), + search: (...l) => _domKids(document.createElement('search'), l), + p: (...l) => _domKids(document.createElement('p'), l), + }; + const _attr = (k, v) => { const o = {}; o[k] = v; return { _attrs: o }; }; + const attr = { + title: (s) => _attr('title', s), + value: (s) => _attr('value', s), + type: (s) => _attr('type', s), + tabindex: (s) => _attr('tabindex', s), + src: (s) => _attr('src', s), + placeholder: (s) => _attr('placeholder', s), + href: (s) => _attr('href', s), + checked: (s) => _attr('checked', s), + selected: (s) => _attr('selected', s), + id: (s) => _attr('id', s), + datalist: (s) => _attr('datalist', s), + rows: (s) => _attr('rows', s), + target: (s) => _attr('target', s), + rel: (s) => _attr('rel', s), + required: (s) => _attr('required', s), + multiple: (s) => _attr('multiple', s), + download: (s) => _attr('download', s), + disabled: (s) => _attr('disabled', s), + draggable: (s) => _attr('draggable', s), + rowspan: (s) => _attr('rowspan', s), + colspan: (s) => _attr('colspan', s), + for: (s) => _attr('for', s), + role: (s) => _attr('role', s), + arialabel: (s) => _attr('aria-label', s), + arialive: (s) => _attr('aria-live', s), + name: (s) => _attr('name', s), + min: (s) => _attr('min', s), + max: (s) => _attr('max', s), + }; + const style = (x) => { return { _styles: x }; }; + const prop = (x) => { return { _props: x }; }; + return [dom, style, attr, prop]; +})(); +// NOTE: GENERATED by github.com/mjl-/sherpats, DO NOT MODIFY +var api; +(function (api) { + // Policy as used in DMARC DNS record for "p=" or "sp=". + let DMARCPolicy; + (function (DMARCPolicy) { + DMARCPolicy["PolicyEmpty"] = ""; + DMARCPolicy["PolicyNone"] = "none"; + DMARCPolicy["PolicyQuarantine"] = "quarantine"; + DMARCPolicy["PolicyReject"] = "reject"; + })(DMARCPolicy = api.DMARCPolicy || (api.DMARCPolicy = {})); + // Align specifies the required alignment of a domain name. + let Align; + (function (Align) { + Align["AlignStrict"] = "s"; + Align["AlignRelaxed"] = "r"; + })(Align = api.Align || (api.Align = {})); + // Mode indicates how the policy should be interpreted. + let Mode; + (function (Mode) { + Mode["ModeEnforce"] = "enforce"; + Mode["ModeTesting"] = "testing"; + Mode["ModeNone"] = "none"; + })(Mode = api.Mode || (api.Mode = {})); + // PolicyType indicates the policy success/failure results are for. + let PolicyType; + (function (PolicyType) { + PolicyType["TLSA"] = "tlsa"; + PolicyType["STS"] = "sts"; + // Recipient domain did not have MTA-STS policy, or mail host (TSLA base domain) + // did not have DANE TLSA records. + PolicyType["NoPolicyFound"] = "no-policy-found"; + })(PolicyType = api.PolicyType || (api.PolicyType = {})); + // ResultType represents a TLS error. + let ResultType; + (function (ResultType) { + ResultType["ResultSTARTTLSNotSupported"] = "starttls-not-supported"; + ResultType["ResultCertificateHostMismatch"] = "certificate-host-mismatch"; + ResultType["ResultCertificateExpired"] = "certificate-expired"; + ResultType["ResultTLSAInvalid"] = "tlsa-invalid"; + ResultType["ResultDNSSECInvalid"] = "dnssec-invalid"; + ResultType["ResultDANERequired"] = "dane-required"; + ResultType["ResultCertificateNotTrusted"] = "certificate-not-trusted"; + ResultType["ResultSTSPolicyInvalid"] = "sts-policy-invalid"; + ResultType["ResultSTSWebPKIInvalid"] = "sts-webpki-invalid"; + ResultType["ResultValidationFailure"] = "validation-failure"; + ResultType["ResultSTSPolicyFetch"] = "sts-policy-fetch-error"; + })(ResultType = api.ResultType || (api.ResultType = {})); + // Alignment is the identifier alignment. + let Alignment; + (function (Alignment) { + Alignment["AlignmentRelaxed"] = "r"; + Alignment["AlignmentStrict"] = "s"; + })(Alignment = api.Alignment || (api.Alignment = {})); + // Disposition is the requested action for a DMARC fail as specified in the + // DMARC policy in DNS. + let Disposition; + (function (Disposition) { + Disposition["DispositionNone"] = "none"; + Disposition["DispositionQuarantine"] = "quarantine"; + Disposition["DispositionReject"] = "reject"; + })(Disposition = api.Disposition || (api.Disposition = {})); + // DMARCResult is the final validation and alignment verdict for SPF and DKIM. + let DMARCResult; + (function (DMARCResult) { + DMARCResult["DMARCPass"] = "pass"; + DMARCResult["DMARCFail"] = "fail"; + })(DMARCResult = api.DMARCResult || (api.DMARCResult = {})); + // PolicyOverride is a reason the requested DMARC policy from the DNS record + // was not applied. + let PolicyOverride; + (function (PolicyOverride) { + PolicyOverride["PolicyOverrideForwarded"] = "forwarded"; + PolicyOverride["PolicyOverrideSampledOut"] = "sampled_out"; + PolicyOverride["PolicyOverrideTrustedForwarder"] = "trusted_forwarder"; + PolicyOverride["PolicyOverrideMailingList"] = "mailing_list"; + PolicyOverride["PolicyOverrideLocalPolicy"] = "local_policy"; + PolicyOverride["PolicyOverrideOther"] = "other"; + })(PolicyOverride = api.PolicyOverride || (api.PolicyOverride = {})); + let DKIMResult; + (function (DKIMResult) { + DKIMResult["DKIMNone"] = "none"; + DKIMResult["DKIMPass"] = "pass"; + DKIMResult["DKIMFail"] = "fail"; + DKIMResult["DKIMPolicy"] = "policy"; + DKIMResult["DKIMNeutral"] = "neutral"; + DKIMResult["DKIMTemperror"] = "temperror"; + DKIMResult["DKIMPermerror"] = "permerror"; + })(DKIMResult = api.DKIMResult || (api.DKIMResult = {})); + let SPFDomainScope; + (function (SPFDomainScope) { + SPFDomainScope["SPFDomainScopeHelo"] = "helo"; + SPFDomainScope["SPFDomainScopeMailFrom"] = "mfrom"; + })(SPFDomainScope = api.SPFDomainScope || (api.SPFDomainScope = {})); + let SPFResult; + (function (SPFResult) { + SPFResult["SPFNone"] = "none"; + SPFResult["SPFNeutral"] = "neutral"; + SPFResult["SPFPass"] = "pass"; + SPFResult["SPFFail"] = "fail"; + SPFResult["SPFSoftfail"] = "softfail"; + SPFResult["SPFTemperror"] = "temperror"; + SPFResult["SPFPermerror"] = "permerror"; + })(SPFResult = api.SPFResult || (api.SPFResult = {})); + api.structTypes = { "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "DANECheckResult": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "Reverse": true, "Row": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true }; + api.stringsTypes = { "Align": true, "Alignment": true, "DKIMResult": true, "DMARCPolicy": true, "DMARCResult": true, "Disposition": true, "IP": true, "Localpart": true, "Mode": true, "PolicyOverride": true, "PolicyType": true, "RUA": true, "ResultType": true, "SPFDomainScope": true, "SPFResult": true }; + api.intsTypes = {}; + api.types = { + "CheckResult": { "Name": "CheckResult", "Docs": "", "Fields": [{ "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["DNSSECResult"] }, { "Name": "IPRev", "Docs": "", "Typewords": ["IPRevCheckResult"] }, { "Name": "MX", "Docs": "", "Typewords": ["MXCheckResult"] }, { "Name": "TLS", "Docs": "", "Typewords": ["TLSCheckResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["DANECheckResult"] }, { "Name": "SPF", "Docs": "", "Typewords": ["SPFCheckResult"] }, { "Name": "DKIM", "Docs": "", "Typewords": ["DKIMCheckResult"] }, { "Name": "DMARC", "Docs": "", "Typewords": ["DMARCCheckResult"] }, { "Name": "HostTLSRPT", "Docs": "", "Typewords": ["TLSRPTCheckResult"] }, { "Name": "DomainTLSRPT", "Docs": "", "Typewords": ["TLSRPTCheckResult"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["MTASTSCheckResult"] }, { "Name": "SRVConf", "Docs": "", "Typewords": ["SRVConfCheckResult"] }, { "Name": "Autoconf", "Docs": "", "Typewords": ["AutoconfCheckResult"] }, { "Name": "Autodiscover", "Docs": "", "Typewords": ["AutodiscoverCheckResult"] }] }, + "DNSSECResult": { "Name": "DNSSECResult", "Docs": "", "Fields": [{ "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "IPRevCheckResult": { "Name": "IPRevCheckResult", "Docs": "", "Fields": [{ "Name": "Hostname", "Docs": "", "Typewords": ["Domain"] }, { "Name": "IPNames", "Docs": "", "Typewords": ["{}", "[]", "string"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, + "MXCheckResult": { "Name": "MXCheckResult", "Docs": "", "Fields": [{ "Name": "Records", "Docs": "", "Typewords": ["[]", "MX"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "MX": { "Name": "MX", "Docs": "", "Fields": [{ "Name": "Host", "Docs": "", "Typewords": ["string"] }, { "Name": "Pref", "Docs": "", "Typewords": ["int32"] }, { "Name": "IPs", "Docs": "", "Typewords": ["[]", "string"] }] }, + "TLSCheckResult": { "Name": "TLSCheckResult", "Docs": "", "Fields": [{ "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "DANECheckResult": { "Name": "DANECheckResult", "Docs": "", "Fields": [{ "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "SPFCheckResult": { "Name": "SPFCheckResult", "Docs": "", "Fields": [{ "Name": "DomainTXT", "Docs": "", "Typewords": ["string"] }, { "Name": "DomainRecord", "Docs": "", "Typewords": ["nullable", "SPFRecord"] }, { "Name": "HostTXT", "Docs": "", "Typewords": ["string"] }, { "Name": "HostRecord", "Docs": "", "Typewords": ["nullable", "SPFRecord"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "SPFRecord": { "Name": "SPFRecord", "Docs": "", "Fields": [{ "Name": "Version", "Docs": "", "Typewords": ["string"] }, { "Name": "Directives", "Docs": "", "Typewords": ["[]", "Directive"] }, { "Name": "Redirect", "Docs": "", "Typewords": ["string"] }, { "Name": "Explanation", "Docs": "", "Typewords": ["string"] }, { "Name": "Other", "Docs": "", "Typewords": ["[]", "Modifier"] }] }, + "Directive": { "Name": "Directive", "Docs": "", "Fields": [{ "Name": "Qualifier", "Docs": "", "Typewords": ["string"] }, { "Name": "Mechanism", "Docs": "", "Typewords": ["string"] }, { "Name": "DomainSpec", "Docs": "", "Typewords": ["string"] }, { "Name": "IPstr", "Docs": "", "Typewords": ["string"] }, { "Name": "IP4CIDRLen", "Docs": "", "Typewords": ["nullable", "int32"] }, { "Name": "IP6CIDRLen", "Docs": "", "Typewords": ["nullable", "int32"] }] }, + "Modifier": { "Name": "Modifier", "Docs": "", "Fields": [{ "Name": "Key", "Docs": "", "Typewords": ["string"] }, { "Name": "Value", "Docs": "", "Typewords": ["string"] }] }, + "DKIMCheckResult": { "Name": "DKIMCheckResult", "Docs": "", "Fields": [{ "Name": "Records", "Docs": "", "Typewords": ["[]", "DKIMRecord"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "DKIMRecord": { "Name": "DKIMRecord", "Docs": "", "Fields": [{ "Name": "Selector", "Docs": "", "Typewords": ["string"] }, { "Name": "TXT", "Docs": "", "Typewords": ["string"] }, { "Name": "Record", "Docs": "", "Typewords": ["nullable", "Record"] }] }, + "Record": { "Name": "Record", "Docs": "", "Fields": [{ "Name": "Version", "Docs": "", "Typewords": ["string"] }, { "Name": "Hashes", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Key", "Docs": "", "Typewords": ["string"] }, { "Name": "Notes", "Docs": "", "Typewords": ["string"] }, { "Name": "Pubkey", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Services", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Flags", "Docs": "", "Typewords": ["[]", "string"] }] }, + "DMARCCheckResult": { "Name": "DMARCCheckResult", "Docs": "", "Fields": [{ "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "TXT", "Docs": "", "Typewords": ["string"] }, { "Name": "Record", "Docs": "", "Typewords": ["nullable", "DMARCRecord"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "DMARCRecord": { "Name": "DMARCRecord", "Docs": "", "Fields": [{ "Name": "Version", "Docs": "", "Typewords": ["string"] }, { "Name": "Policy", "Docs": "", "Typewords": ["DMARCPolicy"] }, { "Name": "SubdomainPolicy", "Docs": "", "Typewords": ["DMARCPolicy"] }, { "Name": "AggregateReportAddresses", "Docs": "", "Typewords": ["[]", "URI"] }, { "Name": "FailureReportAddresses", "Docs": "", "Typewords": ["[]", "URI"] }, { "Name": "ADKIM", "Docs": "", "Typewords": ["Align"] }, { "Name": "ASPF", "Docs": "", "Typewords": ["Align"] }, { "Name": "AggregateReportingInterval", "Docs": "", "Typewords": ["int32"] }, { "Name": "FailureReportingOptions", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "ReportingFormat", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Percentage", "Docs": "", "Typewords": ["int32"] }] }, + "URI": { "Name": "URI", "Docs": "", "Fields": [{ "Name": "Address", "Docs": "", "Typewords": ["string"] }, { "Name": "MaxSize", "Docs": "", "Typewords": ["uint64"] }, { "Name": "Unit", "Docs": "", "Typewords": ["string"] }] }, + "TLSRPTCheckResult": { "Name": "TLSRPTCheckResult", "Docs": "", "Fields": [{ "Name": "TXT", "Docs": "", "Typewords": ["string"] }, { "Name": "Record", "Docs": "", "Typewords": ["nullable", "TLSRPTRecord"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "TLSRPTRecord": { "Name": "TLSRPTRecord", "Docs": "", "Fields": [{ "Name": "Version", "Docs": "", "Typewords": ["string"] }, { "Name": "RUAs", "Docs": "", "Typewords": ["[]", "[]", "RUA"] }, { "Name": "Extensions", "Docs": "", "Typewords": ["[]", "Extension"] }] }, + "Extension": { "Name": "Extension", "Docs": "", "Fields": [{ "Name": "Key", "Docs": "", "Typewords": ["string"] }, { "Name": "Value", "Docs": "", "Typewords": ["string"] }] }, + "MTASTSCheckResult": { "Name": "MTASTSCheckResult", "Docs": "", "Fields": [{ "Name": "TXT", "Docs": "", "Typewords": ["string"] }, { "Name": "Record", "Docs": "", "Typewords": ["nullable", "MTASTSRecord"] }, { "Name": "PolicyText", "Docs": "", "Typewords": ["string"] }, { "Name": "Policy", "Docs": "", "Typewords": ["nullable", "Policy"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "MTASTSRecord": { "Name": "MTASTSRecord", "Docs": "", "Fields": [{ "Name": "Version", "Docs": "", "Typewords": ["string"] }, { "Name": "ID", "Docs": "", "Typewords": ["string"] }, { "Name": "Extensions", "Docs": "", "Typewords": ["[]", "Pair"] }] }, + "Pair": { "Name": "Pair", "Docs": "", "Fields": [{ "Name": "Key", "Docs": "", "Typewords": ["string"] }, { "Name": "Value", "Docs": "", "Typewords": ["string"] }] }, + "Policy": { "Name": "Policy", "Docs": "", "Fields": [{ "Name": "Version", "Docs": "", "Typewords": ["string"] }, { "Name": "Mode", "Docs": "", "Typewords": ["Mode"] }, { "Name": "MX", "Docs": "", "Typewords": ["[]", "STSMX"] }, { "Name": "MaxAgeSeconds", "Docs": "", "Typewords": ["int32"] }, { "Name": "Extensions", "Docs": "", "Typewords": ["[]", "Pair"] }] }, + "STSMX": { "Name": "STSMX", "Docs": "", "Fields": [{ "Name": "Wildcard", "Docs": "", "Typewords": ["bool"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, + "SRVConfCheckResult": { "Name": "SRVConfCheckResult", "Docs": "", "Fields": [{ "Name": "SRVs", "Docs": "", "Typewords": ["{}", "[]", "SRV"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "SRV": { "Name": "SRV", "Docs": "", "Fields": [{ "Name": "Target", "Docs": "", "Typewords": ["string"] }, { "Name": "Port", "Docs": "", "Typewords": ["uint16"] }, { "Name": "Priority", "Docs": "", "Typewords": ["uint16"] }, { "Name": "Weight", "Docs": "", "Typewords": ["uint16"] }] }, + "AutoconfCheckResult": { "Name": "AutoconfCheckResult", "Docs": "", "Fields": [{ "Name": "ClientSettingsDomainIPs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "IPs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "AutodiscoverCheckResult": { "Name": "AutodiscoverCheckResult", "Docs": "", "Fields": [{ "Name": "Records", "Docs": "", "Typewords": ["[]", "AutodiscoverSRV"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Warnings", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Instructions", "Docs": "", "Typewords": ["[]", "string"] }] }, + "AutodiscoverSRV": { "Name": "AutodiscoverSRV", "Docs": "", "Fields": [{ "Name": "Target", "Docs": "", "Typewords": ["string"] }, { "Name": "Port", "Docs": "", "Typewords": ["uint16"] }, { "Name": "Priority", "Docs": "", "Typewords": ["uint16"] }, { "Name": "Weight", "Docs": "", "Typewords": ["uint16"] }, { "Name": "IPs", "Docs": "", "Typewords": ["[]", "string"] }] }, + "PolicyRecord": { "Name": "PolicyRecord", "Docs": "", "Fields": [{ "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Inserted", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "ValidEnd", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "LastUpdate", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "LastUse", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Backoff", "Docs": "", "Typewords": ["bool"] }, { "Name": "RecordID", "Docs": "", "Typewords": ["string"] }, { "Name": "Version", "Docs": "", "Typewords": ["string"] }, { "Name": "Mode", "Docs": "", "Typewords": ["Mode"] }, { "Name": "MX", "Docs": "", "Typewords": ["[]", "STSMX"] }, { "Name": "MaxAgeSeconds", "Docs": "", "Typewords": ["int32"] }, { "Name": "Extensions", "Docs": "", "Typewords": ["[]", "Pair"] }, { "Name": "PolicyText", "Docs": "", "Typewords": ["string"] }] }, + "TLSReportRecord": { "Name": "TLSReportRecord", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "FromDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "MailFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "HostReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "Report", "Docs": "", "Typewords": ["Report"] }] }, + "Report": { "Name": "Report", "Docs": "", "Fields": [{ "Name": "OrganizationName", "Docs": "", "Typewords": ["string"] }, { "Name": "DateRange", "Docs": "", "Typewords": ["TLSRPTDateRange"] }, { "Name": "ContactInfo", "Docs": "", "Typewords": ["string"] }, { "Name": "ReportID", "Docs": "", "Typewords": ["string"] }, { "Name": "Policies", "Docs": "", "Typewords": ["[]", "Result"] }] }, + "TLSRPTDateRange": { "Name": "TLSRPTDateRange", "Docs": "", "Fields": [{ "Name": "Start", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "End", "Docs": "", "Typewords": ["timestamp"] }] }, + "Result": { "Name": "Result", "Docs": "", "Fields": [{ "Name": "Policy", "Docs": "", "Typewords": ["ResultPolicy"] }, { "Name": "Summary", "Docs": "", "Typewords": ["Summary"] }, { "Name": "FailureDetails", "Docs": "", "Typewords": ["[]", "FailureDetails"] }] }, + "ResultPolicy": { "Name": "ResultPolicy", "Docs": "", "Fields": [{ "Name": "Type", "Docs": "", "Typewords": ["PolicyType"] }, { "Name": "String", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "MXHost", "Docs": "", "Typewords": ["[]", "string"] }] }, + "Summary": { "Name": "Summary", "Docs": "", "Fields": [{ "Name": "TotalSuccessfulSessionCount", "Docs": "", "Typewords": ["int64"] }, { "Name": "TotalFailureSessionCount", "Docs": "", "Typewords": ["int64"] }] }, + "FailureDetails": { "Name": "FailureDetails", "Docs": "", "Fields": [{ "Name": "ResultType", "Docs": "", "Typewords": ["ResultType"] }, { "Name": "SendingMTAIP", "Docs": "", "Typewords": ["string"] }, { "Name": "ReceivingMXHostname", "Docs": "", "Typewords": ["string"] }, { "Name": "ReceivingMXHelo", "Docs": "", "Typewords": ["string"] }, { "Name": "ReceivingIP", "Docs": "", "Typewords": ["string"] }, { "Name": "FailedSessionCount", "Docs": "", "Typewords": ["int64"] }, { "Name": "AdditionalInformation", "Docs": "", "Typewords": ["string"] }, { "Name": "FailureReasonCode", "Docs": "", "Typewords": ["string"] }] }, + "TLSRPTSummary": { "Name": "TLSRPTSummary", "Docs": "", "Fields": [{ "Name": "PolicyDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Success", "Docs": "", "Typewords": ["int64"] }, { "Name": "Failure", "Docs": "", "Typewords": ["int64"] }, { "Name": "ResultTypeCounts", "Docs": "", "Typewords": ["{}", "int64"] }] }, + "DomainFeedback": { "Name": "DomainFeedback", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "FromDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "Version", "Docs": "", "Typewords": ["string"] }, { "Name": "ReportMetadata", "Docs": "", "Typewords": ["ReportMetadata"] }, { "Name": "PolicyPublished", "Docs": "", "Typewords": ["PolicyPublished"] }, { "Name": "Records", "Docs": "", "Typewords": ["[]", "ReportRecord"] }] }, + "ReportMetadata": { "Name": "ReportMetadata", "Docs": "", "Fields": [{ "Name": "OrgName", "Docs": "", "Typewords": ["string"] }, { "Name": "Email", "Docs": "", "Typewords": ["string"] }, { "Name": "ExtraContactInfo", "Docs": "", "Typewords": ["string"] }, { "Name": "ReportID", "Docs": "", "Typewords": ["string"] }, { "Name": "DateRange", "Docs": "", "Typewords": ["DateRange"] }, { "Name": "Errors", "Docs": "", "Typewords": ["[]", "string"] }] }, + "DateRange": { "Name": "DateRange", "Docs": "", "Fields": [{ "Name": "Begin", "Docs": "", "Typewords": ["int64"] }, { "Name": "End", "Docs": "", "Typewords": ["int64"] }] }, + "PolicyPublished": { "Name": "PolicyPublished", "Docs": "", "Fields": [{ "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "ADKIM", "Docs": "", "Typewords": ["Alignment"] }, { "Name": "ASPF", "Docs": "", "Typewords": ["Alignment"] }, { "Name": "Policy", "Docs": "", "Typewords": ["Disposition"] }, { "Name": "SubdomainPolicy", "Docs": "", "Typewords": ["Disposition"] }, { "Name": "Percentage", "Docs": "", "Typewords": ["int32"] }, { "Name": "ReportingOptions", "Docs": "", "Typewords": ["string"] }] }, + "ReportRecord": { "Name": "ReportRecord", "Docs": "", "Fields": [{ "Name": "Row", "Docs": "", "Typewords": ["Row"] }, { "Name": "Identifiers", "Docs": "", "Typewords": ["Identifiers"] }, { "Name": "AuthResults", "Docs": "", "Typewords": ["AuthResults"] }] }, + "Row": { "Name": "Row", "Docs": "", "Fields": [{ "Name": "SourceIP", "Docs": "", "Typewords": ["string"] }, { "Name": "Count", "Docs": "", "Typewords": ["int32"] }, { "Name": "PolicyEvaluated", "Docs": "", "Typewords": ["PolicyEvaluated"] }] }, + "PolicyEvaluated": { "Name": "PolicyEvaluated", "Docs": "", "Fields": [{ "Name": "Disposition", "Docs": "", "Typewords": ["Disposition"] }, { "Name": "DKIM", "Docs": "", "Typewords": ["DMARCResult"] }, { "Name": "SPF", "Docs": "", "Typewords": ["DMARCResult"] }, { "Name": "Reasons", "Docs": "", "Typewords": ["[]", "PolicyOverrideReason"] }] }, + "PolicyOverrideReason": { "Name": "PolicyOverrideReason", "Docs": "", "Fields": [{ "Name": "Type", "Docs": "", "Typewords": ["PolicyOverride"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }] }, + "Identifiers": { "Name": "Identifiers", "Docs": "", "Fields": [{ "Name": "EnvelopeTo", "Docs": "", "Typewords": ["string"] }, { "Name": "EnvelopeFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "HeaderFrom", "Docs": "", "Typewords": ["string"] }] }, + "AuthResults": { "Name": "AuthResults", "Docs": "", "Fields": [{ "Name": "DKIM", "Docs": "", "Typewords": ["[]", "DKIMAuthResult"] }, { "Name": "SPF", "Docs": "", "Typewords": ["[]", "SPFAuthResult"] }] }, + "DKIMAuthResult": { "Name": "DKIMAuthResult", "Docs": "", "Fields": [{ "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Selector", "Docs": "", "Typewords": ["string"] }, { "Name": "Result", "Docs": "", "Typewords": ["DKIMResult"] }, { "Name": "HumanResult", "Docs": "", "Typewords": ["string"] }] }, + "SPFAuthResult": { "Name": "SPFAuthResult", "Docs": "", "Fields": [{ "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Scope", "Docs": "", "Typewords": ["SPFDomainScope"] }, { "Name": "Result", "Docs": "", "Typewords": ["SPFResult"] }] }, + "DMARCSummary": { "Name": "DMARCSummary", "Docs": "", "Fields": [{ "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Total", "Docs": "", "Typewords": ["int32"] }, { "Name": "DispositionNone", "Docs": "", "Typewords": ["int32"] }, { "Name": "DispositionQuarantine", "Docs": "", "Typewords": ["int32"] }, { "Name": "DispositionReject", "Docs": "", "Typewords": ["int32"] }, { "Name": "DKIMFail", "Docs": "", "Typewords": ["int32"] }, { "Name": "SPFFail", "Docs": "", "Typewords": ["int32"] }, { "Name": "PolicyOverrides", "Docs": "", "Typewords": ["{}", "int32"] }] }, + "Reverse": { "Name": "Reverse", "Docs": "", "Fields": [{ "Name": "Hostnames", "Docs": "", "Typewords": ["[]", "string"] }] }, + "ClientConfigs": { "Name": "ClientConfigs", "Docs": "", "Fields": [{ "Name": "Entries", "Docs": "", "Typewords": ["[]", "ClientConfigsEntry"] }] }, + "ClientConfigsEntry": { "Name": "ClientConfigsEntry", "Docs": "", "Fields": [{ "Name": "Protocol", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Port", "Docs": "", "Typewords": ["int32"] }, { "Name": "Listener", "Docs": "", "Typewords": ["string"] }, { "Name": "Note", "Docs": "", "Typewords": ["string"] }] }, + "Msg": { "Name": "Msg", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Queued", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "SenderAccount", "Docs": "", "Typewords": ["string"] }, { "Name": "SenderLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "SenderDomain", "Docs": "", "Typewords": ["IPDomain"] }, { "Name": "RecipientLocalpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "RecipientDomain", "Docs": "", "Typewords": ["IPDomain"] }, { "Name": "RecipientDomainStr", "Docs": "", "Typewords": ["string"] }, { "Name": "Attempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxAttempts", "Docs": "", "Typewords": ["int32"] }, { "Name": "DialedIPs", "Docs": "", "Typewords": ["{}", "[]", "IP"] }, { "Name": "NextAttempt", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "LastAttempt", "Docs": "", "Typewords": ["nullable", "timestamp"] }, { "Name": "LastError", "Docs": "", "Typewords": ["string"] }, { "Name": "Has8bit", "Docs": "", "Typewords": ["bool"] }, { "Name": "SMTPUTF8", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsDMARCReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "IsTLSReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }, { "Name": "MessageID", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgPrefix", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "DSNUTF8", "Docs": "", "Typewords": ["nullable", "string"] }, { "Name": "Transport", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }] }, + "IPDomain": { "Name": "IPDomain", "Docs": "", "Fields": [{ "Name": "IP", "Docs": "", "Typewords": ["IP"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, + "WebserverConfig": { "Name": "WebserverConfig", "Docs": "", "Fields": [{ "Name": "WebDNSDomainRedirects", "Docs": "", "Typewords": ["[]", "[]", "Domain"] }, { "Name": "WebDomainRedirects", "Docs": "", "Typewords": ["[]", "[]", "string"] }, { "Name": "WebHandlers", "Docs": "", "Typewords": ["[]", "WebHandler"] }] }, + "WebHandler": { "Name": "WebHandler", "Docs": "", "Fields": [{ "Name": "LogName", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "PathRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "DontRedirectPlainHTTP", "Docs": "", "Typewords": ["bool"] }, { "Name": "Compress", "Docs": "", "Typewords": ["bool"] }, { "Name": "WebStatic", "Docs": "", "Typewords": ["nullable", "WebStatic"] }, { "Name": "WebRedirect", "Docs": "", "Typewords": ["nullable", "WebRedirect"] }, { "Name": "WebForward", "Docs": "", "Typewords": ["nullable", "WebForward"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, + "WebStatic": { "Name": "WebStatic", "Docs": "", "Fields": [{ "Name": "StripPrefix", "Docs": "", "Typewords": ["string"] }, { "Name": "Root", "Docs": "", "Typewords": ["string"] }, { "Name": "ListFiles", "Docs": "", "Typewords": ["bool"] }, { "Name": "ContinueNotFound", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseHeaders", "Docs": "", "Typewords": ["{}", "string"] }] }, + "WebRedirect": { "Name": "WebRedirect", "Docs": "", "Fields": [{ "Name": "BaseURL", "Docs": "", "Typewords": ["string"] }, { "Name": "OrigPathRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "ReplacePath", "Docs": "", "Typewords": ["string"] }, { "Name": "StatusCode", "Docs": "", "Typewords": ["int32"] }] }, + "WebForward": { "Name": "WebForward", "Docs": "", "Fields": [{ "Name": "StripPath", "Docs": "", "Typewords": ["bool"] }, { "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "ResponseHeaders", "Docs": "", "Typewords": ["{}", "string"] }] }, + "Transport": { "Name": "Transport", "Docs": "", "Fields": [{ "Name": "Submissions", "Docs": "", "Typewords": ["nullable", "TransportSMTP"] }, { "Name": "Submission", "Docs": "", "Typewords": ["nullable", "TransportSMTP"] }, { "Name": "SMTP", "Docs": "", "Typewords": ["nullable", "TransportSMTP"] }, { "Name": "Socks", "Docs": "", "Typewords": ["nullable", "TransportSocks"] }] }, + "TransportSMTP": { "Name": "TransportSMTP", "Docs": "", "Fields": [{ "Name": "Host", "Docs": "", "Typewords": ["string"] }, { "Name": "Port", "Docs": "", "Typewords": ["int32"] }, { "Name": "STARTTLSInsecureSkipVerify", "Docs": "", "Typewords": ["bool"] }, { "Name": "NoSTARTTLS", "Docs": "", "Typewords": ["bool"] }, { "Name": "Auth", "Docs": "", "Typewords": ["nullable", "SMTPAuth"] }] }, + "SMTPAuth": { "Name": "SMTPAuth", "Docs": "", "Fields": [{ "Name": "Username", "Docs": "", "Typewords": ["string"] }, { "Name": "Password", "Docs": "", "Typewords": ["string"] }, { "Name": "Mechanisms", "Docs": "", "Typewords": ["[]", "string"] }] }, + "TransportSocks": { "Name": "TransportSocks", "Docs": "", "Fields": [{ "Name": "Address", "Docs": "", "Typewords": ["string"] }, { "Name": "RemoteIPs", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "RemoteHostname", "Docs": "", "Typewords": ["string"] }] }, + "EvaluationStat": { "Name": "EvaluationStat", "Docs": "", "Fields": [{ "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Dispositions", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Count", "Docs": "", "Typewords": ["int32"] }, { "Name": "SendReport", "Docs": "", "Typewords": ["bool"] }] }, + "Evaluation": { "Name": "Evaluation", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "PolicyDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "Evaluated", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Optional", "Docs": "", "Typewords": ["bool"] }, { "Name": "IntervalHours", "Docs": "", "Typewords": ["int32"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "PolicyPublished", "Docs": "", "Typewords": ["PolicyPublished"] }, { "Name": "SourceIP", "Docs": "", "Typewords": ["string"] }, { "Name": "Disposition", "Docs": "", "Typewords": ["Disposition"] }, { "Name": "AlignedDKIMPass", "Docs": "", "Typewords": ["bool"] }, { "Name": "AlignedSPFPass", "Docs": "", "Typewords": ["bool"] }, { "Name": "OverrideReasons", "Docs": "", "Typewords": ["[]", "PolicyOverrideReason"] }, { "Name": "EnvelopeTo", "Docs": "", "Typewords": ["string"] }, { "Name": "EnvelopeFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "HeaderFrom", "Docs": "", "Typewords": ["string"] }, { "Name": "DKIMResults", "Docs": "", "Typewords": ["[]", "DKIMAuthResult"] }, { "Name": "SPFResults", "Docs": "", "Typewords": ["[]", "SPFAuthResult"] }] }, + "SuppressAddress": { "Name": "SuppressAddress", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Inserted", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "ReportingAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "Until", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }] }, + "TLSResult": { "Name": "TLSResult", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "PolicyDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "DayUTC", "Docs": "", "Typewords": ["string"] }, { "Name": "RecipientDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Updated", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "IsHost", "Docs": "", "Typewords": ["bool"] }, { "Name": "SendReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "SentToRecipientDomain", "Docs": "", "Typewords": ["bool"] }, { "Name": "RecipientDomainReportingAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "SentToPolicyDomain", "Docs": "", "Typewords": ["bool"] }, { "Name": "Results", "Docs": "", "Typewords": ["[]", "Result"] }] }, + "TLSRPTSuppressAddress": { "Name": "TLSRPTSuppressAddress", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Inserted", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "ReportingAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "Until", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }] }, + "DMARCPolicy": { "Name": "DMARCPolicy", "Docs": "", "Values": [{ "Name": "PolicyEmpty", "Value": "", "Docs": "" }, { "Name": "PolicyNone", "Value": "none", "Docs": "" }, { "Name": "PolicyQuarantine", "Value": "quarantine", "Docs": "" }, { "Name": "PolicyReject", "Value": "reject", "Docs": "" }] }, + "Align": { "Name": "Align", "Docs": "", "Values": [{ "Name": "AlignStrict", "Value": "s", "Docs": "" }, { "Name": "AlignRelaxed", "Value": "r", "Docs": "" }] }, + "RUA": { "Name": "RUA", "Docs": "", "Values": null }, + "Mode": { "Name": "Mode", "Docs": "", "Values": [{ "Name": "ModeEnforce", "Value": "enforce", "Docs": "" }, { "Name": "ModeTesting", "Value": "testing", "Docs": "" }, { "Name": "ModeNone", "Value": "none", "Docs": "" }] }, + "PolicyType": { "Name": "PolicyType", "Docs": "", "Values": [{ "Name": "TLSA", "Value": "tlsa", "Docs": "" }, { "Name": "STS", "Value": "sts", "Docs": "" }, { "Name": "NoPolicyFound", "Value": "no-policy-found", "Docs": "" }] }, + "ResultType": { "Name": "ResultType", "Docs": "", "Values": [{ "Name": "ResultSTARTTLSNotSupported", "Value": "starttls-not-supported", "Docs": "" }, { "Name": "ResultCertificateHostMismatch", "Value": "certificate-host-mismatch", "Docs": "" }, { "Name": "ResultCertificateExpired", "Value": "certificate-expired", "Docs": "" }, { "Name": "ResultTLSAInvalid", "Value": "tlsa-invalid", "Docs": "" }, { "Name": "ResultDNSSECInvalid", "Value": "dnssec-invalid", "Docs": "" }, { "Name": "ResultDANERequired", "Value": "dane-required", "Docs": "" }, { "Name": "ResultCertificateNotTrusted", "Value": "certificate-not-trusted", "Docs": "" }, { "Name": "ResultSTSPolicyInvalid", "Value": "sts-policy-invalid", "Docs": "" }, { "Name": "ResultSTSWebPKIInvalid", "Value": "sts-webpki-invalid", "Docs": "" }, { "Name": "ResultValidationFailure", "Value": "validation-failure", "Docs": "" }, { "Name": "ResultSTSPolicyFetch", "Value": "sts-policy-fetch-error", "Docs": "" }] }, + "Alignment": { "Name": "Alignment", "Docs": "", "Values": [{ "Name": "AlignmentRelaxed", "Value": "r", "Docs": "" }, { "Name": "AlignmentStrict", "Value": "s", "Docs": "" }] }, + "Disposition": { "Name": "Disposition", "Docs": "", "Values": [{ "Name": "DispositionNone", "Value": "none", "Docs": "" }, { "Name": "DispositionQuarantine", "Value": "quarantine", "Docs": "" }, { "Name": "DispositionReject", "Value": "reject", "Docs": "" }] }, + "DMARCResult": { "Name": "DMARCResult", "Docs": "", "Values": [{ "Name": "DMARCPass", "Value": "pass", "Docs": "" }, { "Name": "DMARCFail", "Value": "fail", "Docs": "" }] }, + "PolicyOverride": { "Name": "PolicyOverride", "Docs": "", "Values": [{ "Name": "PolicyOverrideForwarded", "Value": "forwarded", "Docs": "" }, { "Name": "PolicyOverrideSampledOut", "Value": "sampled_out", "Docs": "" }, { "Name": "PolicyOverrideTrustedForwarder", "Value": "trusted_forwarder", "Docs": "" }, { "Name": "PolicyOverrideMailingList", "Value": "mailing_list", "Docs": "" }, { "Name": "PolicyOverrideLocalPolicy", "Value": "local_policy", "Docs": "" }, { "Name": "PolicyOverrideOther", "Value": "other", "Docs": "" }] }, + "DKIMResult": { "Name": "DKIMResult", "Docs": "", "Values": [{ "Name": "DKIMNone", "Value": "none", "Docs": "" }, { "Name": "DKIMPass", "Value": "pass", "Docs": "" }, { "Name": "DKIMFail", "Value": "fail", "Docs": "" }, { "Name": "DKIMPolicy", "Value": "policy", "Docs": "" }, { "Name": "DKIMNeutral", "Value": "neutral", "Docs": "" }, { "Name": "DKIMTemperror", "Value": "temperror", "Docs": "" }, { "Name": "DKIMPermerror", "Value": "permerror", "Docs": "" }] }, + "SPFDomainScope": { "Name": "SPFDomainScope", "Docs": "", "Values": [{ "Name": "SPFDomainScopeHelo", "Value": "helo", "Docs": "" }, { "Name": "SPFDomainScopeMailFrom", "Value": "mfrom", "Docs": "" }] }, + "SPFResult": { "Name": "SPFResult", "Docs": "", "Values": [{ "Name": "SPFNone", "Value": "none", "Docs": "" }, { "Name": "SPFNeutral", "Value": "neutral", "Docs": "" }, { "Name": "SPFPass", "Value": "pass", "Docs": "" }, { "Name": "SPFFail", "Value": "fail", "Docs": "" }, { "Name": "SPFSoftfail", "Value": "softfail", "Docs": "" }, { "Name": "SPFTemperror", "Value": "temperror", "Docs": "" }, { "Name": "SPFPermerror", "Value": "permerror", "Docs": "" }] }, + "Localpart": { "Name": "Localpart", "Docs": "", "Values": null }, + "IP": { "Name": "IP", "Docs": "", "Values": [] }, + }; + api.parser = { + CheckResult: (v) => api.parse("CheckResult", v), + DNSSECResult: (v) => api.parse("DNSSECResult", v), + IPRevCheckResult: (v) => api.parse("IPRevCheckResult", v), + Domain: (v) => api.parse("Domain", v), + MXCheckResult: (v) => api.parse("MXCheckResult", v), + MX: (v) => api.parse("MX", v), + TLSCheckResult: (v) => api.parse("TLSCheckResult", v), + DANECheckResult: (v) => api.parse("DANECheckResult", v), + SPFCheckResult: (v) => api.parse("SPFCheckResult", v), + SPFRecord: (v) => api.parse("SPFRecord", v), + Directive: (v) => api.parse("Directive", v), + Modifier: (v) => api.parse("Modifier", v), + DKIMCheckResult: (v) => api.parse("DKIMCheckResult", v), + DKIMRecord: (v) => api.parse("DKIMRecord", v), + Record: (v) => api.parse("Record", v), + DMARCCheckResult: (v) => api.parse("DMARCCheckResult", v), + DMARCRecord: (v) => api.parse("DMARCRecord", v), + URI: (v) => api.parse("URI", v), + TLSRPTCheckResult: (v) => api.parse("TLSRPTCheckResult", v), + TLSRPTRecord: (v) => api.parse("TLSRPTRecord", v), + Extension: (v) => api.parse("Extension", v), + MTASTSCheckResult: (v) => api.parse("MTASTSCheckResult", v), + MTASTSRecord: (v) => api.parse("MTASTSRecord", v), + Pair: (v) => api.parse("Pair", v), + Policy: (v) => api.parse("Policy", v), + STSMX: (v) => api.parse("STSMX", v), + SRVConfCheckResult: (v) => api.parse("SRVConfCheckResult", v), + SRV: (v) => api.parse("SRV", v), + AutoconfCheckResult: (v) => api.parse("AutoconfCheckResult", v), + AutodiscoverCheckResult: (v) => api.parse("AutodiscoverCheckResult", v), + AutodiscoverSRV: (v) => api.parse("AutodiscoverSRV", v), + PolicyRecord: (v) => api.parse("PolicyRecord", v), + TLSReportRecord: (v) => api.parse("TLSReportRecord", v), + Report: (v) => api.parse("Report", v), + TLSRPTDateRange: (v) => api.parse("TLSRPTDateRange", v), + Result: (v) => api.parse("Result", v), + ResultPolicy: (v) => api.parse("ResultPolicy", v), + Summary: (v) => api.parse("Summary", v), + FailureDetails: (v) => api.parse("FailureDetails", v), + TLSRPTSummary: (v) => api.parse("TLSRPTSummary", v), + DomainFeedback: (v) => api.parse("DomainFeedback", v), + ReportMetadata: (v) => api.parse("ReportMetadata", v), + DateRange: (v) => api.parse("DateRange", v), + PolicyPublished: (v) => api.parse("PolicyPublished", v), + ReportRecord: (v) => api.parse("ReportRecord", v), + Row: (v) => api.parse("Row", v), + PolicyEvaluated: (v) => api.parse("PolicyEvaluated", v), + PolicyOverrideReason: (v) => api.parse("PolicyOverrideReason", v), + Identifiers: (v) => api.parse("Identifiers", v), + AuthResults: (v) => api.parse("AuthResults", v), + DKIMAuthResult: (v) => api.parse("DKIMAuthResult", v), + SPFAuthResult: (v) => api.parse("SPFAuthResult", v), + DMARCSummary: (v) => api.parse("DMARCSummary", v), + Reverse: (v) => api.parse("Reverse", v), + ClientConfigs: (v) => api.parse("ClientConfigs", v), + ClientConfigsEntry: (v) => api.parse("ClientConfigsEntry", v), + Msg: (v) => api.parse("Msg", v), + IPDomain: (v) => api.parse("IPDomain", v), + WebserverConfig: (v) => api.parse("WebserverConfig", v), + WebHandler: (v) => api.parse("WebHandler", v), + WebStatic: (v) => api.parse("WebStatic", v), + WebRedirect: (v) => api.parse("WebRedirect", v), + WebForward: (v) => api.parse("WebForward", v), + Transport: (v) => api.parse("Transport", v), + TransportSMTP: (v) => api.parse("TransportSMTP", v), + SMTPAuth: (v) => api.parse("SMTPAuth", v), + TransportSocks: (v) => api.parse("TransportSocks", v), + EvaluationStat: (v) => api.parse("EvaluationStat", v), + Evaluation: (v) => api.parse("Evaluation", v), + SuppressAddress: (v) => api.parse("SuppressAddress", v), + TLSResult: (v) => api.parse("TLSResult", v), + TLSRPTSuppressAddress: (v) => api.parse("TLSRPTSuppressAddress", v), + DMARCPolicy: (v) => api.parse("DMARCPolicy", v), + Align: (v) => api.parse("Align", v), + RUA: (v) => api.parse("RUA", v), + Mode: (v) => api.parse("Mode", v), + PolicyType: (v) => api.parse("PolicyType", v), + ResultType: (v) => api.parse("ResultType", v), + Alignment: (v) => api.parse("Alignment", v), + Disposition: (v) => api.parse("Disposition", v), + DMARCResult: (v) => api.parse("DMARCResult", v), + PolicyOverride: (v) => api.parse("PolicyOverride", v), + DKIMResult: (v) => api.parse("DKIMResult", v), + SPFDomainScope: (v) => api.parse("SPFDomainScope", v), + SPFResult: (v) => api.parse("SPFResult", v), + Localpart: (v) => api.parse("Localpart", v), + IP: (v) => api.parse("IP", v), + }; + // Admin exports web API functions for the admin web interface. All its methods are + // exported under api/. Function calls require valid HTTP Authentication + // credentials of a user. + let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true }; + class Client { + baseURL; + options; + constructor(baseURL = api.defaultBaseURL, options) { + this.baseURL = baseURL; + this.options = options; + if (!options) { + this.options = defaultOptions; + } + } + withOptions(options) { + return new Client(this.baseURL, { ...this.options, ...options }); + } + // CheckDomain checks the configuration for the domain, such as MX, SMTP STARTTLS, + // SPF, DKIM, DMARC, TLSRPT, MTASTS, autoconfig, autodiscover. + async CheckDomain(domainName) { + const fn = "CheckDomain"; + const paramTypes = [["string"]]; + const returnTypes = [["CheckResult"]]; + const params = [domainName]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // Domains returns all configured domain names, in UTF-8 for IDNA domains. + async Domains() { + const fn = "Domains"; + const paramTypes = []; + const returnTypes = [["[]", "Domain"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // Domain returns the dns domain for a (potentially unicode as IDNA) domain name. + async Domain(domain) { + const fn = "Domain"; + const paramTypes = [["string"]]; + const returnTypes = [["Domain"]]; + const params = [domain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // ParseDomain parses a domain, possibly an IDNA domain. + async ParseDomain(domain) { + const fn = "ParseDomain"; + const paramTypes = [["string"]]; + const returnTypes = [["Domain"]]; + const params = [domain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DomainLocalparts returns the encoded localparts and accounts configured in domain. + async DomainLocalparts(domain) { + const fn = "DomainLocalparts"; + const paramTypes = [["string"]]; + const returnTypes = [["{}", "string"]]; + const params = [domain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // Accounts returns the names of all configured accounts. + async Accounts() { + const fn = "Accounts"; + const paramTypes = []; + const returnTypes = [["[]", "string"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // Account returns the parsed configuration of an account. + async Account(account) { + const fn = "Account"; + const paramTypes = [["string"]]; + const returnTypes = [["{}", "any"]]; + const params = [account]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // ConfigFiles returns the paths and contents of the static and dynamic configuration files. + async ConfigFiles() { + const fn = "ConfigFiles"; + const paramTypes = []; + const returnTypes = [["string"], ["string"], ["string"], ["string"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // MTASTSPolicies returns all mtasts policies from the cache. + async MTASTSPolicies() { + const fn = "MTASTSPolicies"; + const paramTypes = []; + const returnTypes = [["[]", "PolicyRecord"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // TLSReports returns TLS reports overlapping with period start/end, for the given + // policy domain (or all domains if empty). The reports are sorted first by period + // end (most recent first), then by policy domain. + async TLSReports(start, end, policyDomain) { + const fn = "TLSReports"; + const paramTypes = [["timestamp"], ["timestamp"], ["string"]]; + const returnTypes = [["[]", "TLSReportRecord"]]; + const params = [start, end, policyDomain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // TLSReportID returns a single TLS report. + async TLSReportID(domain, reportID) { + const fn = "TLSReportID"; + const paramTypes = [["string"], ["int64"]]; + const returnTypes = [["TLSReportRecord"]]; + const params = [domain, reportID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // TLSRPTSummaries returns a summary of received TLS reports overlapping with + // period start/end for one or all domains (when domain is empty). + // The returned summaries are ordered by domain name. + async TLSRPTSummaries(start, end, policyDomain) { + const fn = "TLSRPTSummaries"; + const paramTypes = [["timestamp"], ["timestamp"], ["string"]]; + const returnTypes = [["[]", "TLSRPTSummary"]]; + const params = [start, end, policyDomain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DMARCReports returns DMARC reports overlapping with period start/end, for the + // given domain (or all domains if empty). The reports are sorted first by period + // end (most recent first), then by domain. + async DMARCReports(start, end, domain) { + const fn = "DMARCReports"; + const paramTypes = [["timestamp"], ["timestamp"], ["string"]]; + const returnTypes = [["[]", "DomainFeedback"]]; + const params = [start, end, domain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DMARCReportID returns a single DMARC report. + async DMARCReportID(domain, reportID) { + const fn = "DMARCReportID"; + const paramTypes = [["string"], ["int64"]]; + const returnTypes = [["DomainFeedback"]]; + const params = [domain, reportID]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DMARCSummaries returns a summary of received DMARC reports overlapping with + // period start/end for one or all domains (when domain is empty). + // The returned summaries are ordered by domain name. + async DMARCSummaries(start, end, domain) { + const fn = "DMARCSummaries"; + const paramTypes = [["timestamp"], ["timestamp"], ["string"]]; + const returnTypes = [["[]", "DMARCSummary"]]; + const params = [start, end, domain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // LookupIP does a reverse lookup of ip. + async LookupIP(ip) { + const fn = "LookupIP"; + const paramTypes = [["string"]]; + const returnTypes = [["Reverse"]]; + const params = [ip]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DNSBLStatus returns the IPs from which outgoing connections may be made and + // their current status in DNSBLs that are configured. The IPs are typically the + // configured listen IPs, or otherwise IPs on the machines network interfaces, with + // internal/private IPs removed. + // + // The returned value maps IPs to per DNSBL statuses, where "pass" means not listed and + // anything else is an error string, e.g. "fail: ..." or "temperror: ...". + async DNSBLStatus() { + const fn = "DNSBLStatus"; + const paramTypes = []; + const returnTypes = [["{}", "{}", "string"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DomainRecords returns lines describing DNS records that should exist for the + // configured domain. + async DomainRecords(domain) { + const fn = "DomainRecords"; + const paramTypes = [["string"]]; + const returnTypes = [["[]", "string"]]; + const params = [domain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DomainAdd adds a new domain and reloads the configuration. + async DomainAdd(domain, accountName, localpart) { + const fn = "DomainAdd"; + const paramTypes = [["string"], ["string"], ["string"]]; + const returnTypes = []; + const params = [domain, accountName, localpart]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DomainRemove removes an existing domain and reloads the configuration. + async DomainRemove(domain) { + const fn = "DomainRemove"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [domain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // AccountAdd adds existing a new account, with an initial email address, and + // reloads the configuration. + async AccountAdd(accountName, address) { + const fn = "AccountAdd"; + const paramTypes = [["string"], ["string"]]; + const returnTypes = []; + const params = [accountName, address]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // AccountRemove removes an existing account and reloads the configuration. + async AccountRemove(accountName) { + const fn = "AccountRemove"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [accountName]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // AddressAdd adds a new address to the account, which must already exist. + async AddressAdd(address, accountName) { + const fn = "AddressAdd"; + const paramTypes = [["string"], ["string"]]; + const returnTypes = []; + const params = [address, accountName]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // AddressRemove removes an existing address. + async AddressRemove(address) { + const fn = "AddressRemove"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [address]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // SetPassword saves a new password for an account, invalidating the previous password. + // Sessions are not interrupted, and will keep working. New login attempts must use the new password. + // Password must be at least 8 characters. + async SetPassword(accountName, password) { + const fn = "SetPassword"; + const paramTypes = [["string"], ["string"]]; + const returnTypes = []; + const params = [accountName, password]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // SetAccountLimits set new limits on outgoing messages for an account. + async SetAccountLimits(accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize) { + const fn = "SetAccountLimits"; + const paramTypes = [["string"], ["int32"], ["int32"], ["int64"]]; + const returnTypes = []; + const params = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // ClientConfigsDomain returns configurations for email clients, IMAP and + // Submission (SMTP) for the domain. + async ClientConfigsDomain(domain) { + const fn = "ClientConfigsDomain"; + const paramTypes = [["string"]]; + const returnTypes = [["ClientConfigs"]]; + const params = [domain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // QueueList returns the messages currently in the outgoing queue. + async QueueList() { + const fn = "QueueList"; + const paramTypes = []; + const returnTypes = [["[]", "Msg"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // QueueSize returns the number of messages currently in the outgoing queue. + async QueueSize() { + const fn = "QueueSize"; + const paramTypes = []; + const returnTypes = [["int32"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // QueueKick initiates delivery of a message from the queue and sets the transport + // to use for delivery. + async QueueKick(id, transport) { + const fn = "QueueKick"; + const paramTypes = [["int64"], ["string"]]; + const returnTypes = []; + const params = [id, transport]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // QueueDrop removes a message from the queue. + async QueueDrop(id) { + const fn = "QueueDrop"; + const paramTypes = [["int64"]]; + const returnTypes = []; + const params = [id]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // QueueSaveRequireTLS updates the requiretls field for a message in the queue, + // to be used for the next delivery. + async QueueSaveRequireTLS(id, requireTLS) { + const fn = "QueueSaveRequireTLS"; + const paramTypes = [["int64"], ["nullable", "bool"]]; + const returnTypes = []; + const params = [id, requireTLS]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // LogLevels returns the current log levels. + async LogLevels() { + const fn = "LogLevels"; + const paramTypes = []; + const returnTypes = [["{}", "string"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // LogLevelSet sets a log level for a package. + async LogLevelSet(pkg, levelStr) { + const fn = "LogLevelSet"; + const paramTypes = [["string"], ["string"]]; + const returnTypes = []; + const params = [pkg, levelStr]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // LogLevelRemove removes a log level for a package, which cannot be the empty string. + async LogLevelRemove(pkg) { + const fn = "LogLevelRemove"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [pkg]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // CheckUpdatesEnabled returns whether checking for updates is enabled. + async CheckUpdatesEnabled() { + const fn = "CheckUpdatesEnabled"; + const paramTypes = []; + const returnTypes = [["bool"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // WebserverConfig returns the current webserver config + async WebserverConfig() { + const fn = "WebserverConfig"; + const paramTypes = []; + const returnTypes = [["WebserverConfig"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // WebserverConfigSave saves a new webserver config. If oldConf is not equal to + // the current config, an error is returned. + async WebserverConfigSave(oldConf, newConf) { + const fn = "WebserverConfigSave"; + const paramTypes = [["WebserverConfig"], ["WebserverConfig"]]; + const returnTypes = [["WebserverConfig"]]; + const params = [oldConf, newConf]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // Transports returns the configured transports, for sending email. + async Transports() { + const fn = "Transports"; + const paramTypes = []; + const returnTypes = [["{}", "Transport"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DMARCEvaluationStats returns a map of all domains with evaluations to a count of + // the evaluations and whether those evaluations will cause a report to be sent. + async DMARCEvaluationStats() { + const fn = "DMARCEvaluationStats"; + const paramTypes = []; + const returnTypes = [["{}", "EvaluationStat"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DMARCEvaluationsDomain returns all evaluations for aggregate reports for the + // domain, sorted from oldest to most recent. + async DMARCEvaluationsDomain(domain) { + const fn = "DMARCEvaluationsDomain"; + const paramTypes = [["string"]]; + const returnTypes = [["Domain"], ["[]", "Evaluation"]]; + const params = [domain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DMARCRemoveEvaluations removes evaluations for a domain. + async DMARCRemoveEvaluations(domain) { + const fn = "DMARCRemoveEvaluations"; + const paramTypes = [["string"]]; + const returnTypes = []; + const params = [domain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DMARCSuppressAdd adds a reporting address to the suppress list. Outgoing + // reports will be suppressed for a period. + async DMARCSuppressAdd(reportingAddress, until, comment) { + const fn = "DMARCSuppressAdd"; + const paramTypes = [["string"], ["timestamp"], ["string"]]; + const returnTypes = []; + const params = [reportingAddress, until, comment]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DMARCSuppressList returns all reporting addresses on the suppress list. + async DMARCSuppressList() { + const fn = "DMARCSuppressList"; + const paramTypes = []; + const returnTypes = [["[]", "SuppressAddress"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DMARCSuppressRemove removes a reporting address record from the suppress list. + async DMARCSuppressRemove(id) { + const fn = "DMARCSuppressRemove"; + const paramTypes = [["int64"]]; + const returnTypes = []; + const params = [id]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // DMARCSuppressExtend updates the until field of a suppressed reporting address record. + async DMARCSuppressExtend(id, until) { + const fn = "DMARCSuppressExtend"; + const paramTypes = [["int64"], ["timestamp"]]; + const returnTypes = []; + const params = [id, until]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // TLSRPTResults returns all TLSRPT results in the database. + async TLSRPTResults() { + const fn = "TLSRPTResults"; + const paramTypes = []; + const returnTypes = [["[]", "TLSResult"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // TLSRPTResultsPolicyDomain returns the TLS results for a domain. + async TLSRPTResultsDomain(isRcptDom, policyDomain) { + const fn = "TLSRPTResultsDomain"; + const paramTypes = [["bool"], ["string"]]; + const returnTypes = [["Domain"], ["[]", "TLSResult"]]; + const params = [isRcptDom, policyDomain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // LookupTLSRPTRecord looks up a TLSRPT record and returns the parsed form, original txt + // form from DNS, and error with the TLSRPT record as a string. + async LookupTLSRPTRecord(domain) { + const fn = "LookupTLSRPTRecord"; + const paramTypes = [["string"]]; + const returnTypes = [["nullable", "TLSRPTRecord"], ["string"], ["string"]]; + const params = [domain]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // TLSRPTRemoveResults removes the TLS results for a domain for the given day. If + // day is empty, all results are removed. + async TLSRPTRemoveResults(isRcptDom, domain, day) { + const fn = "TLSRPTRemoveResults"; + const paramTypes = [["bool"], ["string"], ["string"]]; + const returnTypes = []; + const params = [isRcptDom, domain, day]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // TLSRPTSuppressAdd adds a reporting address to the suppress list. Outgoing + // reports will be suppressed for a period. + async TLSRPTSuppressAdd(reportingAddress, until, comment) { + const fn = "TLSRPTSuppressAdd"; + const paramTypes = [["string"], ["timestamp"], ["string"]]; + const returnTypes = []; + const params = [reportingAddress, until, comment]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // TLSRPTSuppressList returns all reporting addresses on the suppress list. + async TLSRPTSuppressList() { + const fn = "TLSRPTSuppressList"; + const paramTypes = []; + const returnTypes = [["[]", "TLSRPTSuppressAddress"]]; + const params = []; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // TLSRPTSuppressRemove removes a reporting address record from the suppress list. + async TLSRPTSuppressRemove(id) { + const fn = "TLSRPTSuppressRemove"; + const paramTypes = [["int64"]]; + const returnTypes = []; + const params = [id]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + // TLSRPTSuppressExtend updates the until field of a suppressed reporting address record. + async TLSRPTSuppressExtend(id, until) { + const fn = "TLSRPTSuppressExtend"; + const paramTypes = [["int64"], ["timestamp"]]; + const returnTypes = []; + const params = [id, until]; + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params); + } + } + api.Client = Client; + api.defaultBaseURL = (function () { + let p = location.pathname; + if (p && p[p.length - 1] !== '/') { + let l = location.pathname.split('/'); + l = l.slice(0, l.length - 1); + p = '/' + l.join('/') + '/'; + } + return location.protocol + '//' + location.host + p + 'api/'; + })(); + // NOTE: code below is shared between github.com/mjl-/sherpaweb and github.com/mjl-/sherpats. + // KEEP IN SYNC. + api.supportedSherpaVersion = 1; + // verifyArg typechecks "v" against "typewords", returning a new (possibly modified) value for JSON-encoding. + // toJS indicate if the data is coming into JS. If so, timestamps are turned into JS Dates. Otherwise, JS Dates are turned into strings. + // allowUnknownKeys configures whether unknown keys in structs are allowed. + // types are the named types of the API. + api.verifyArg = (path, v, typewords, toJS, allowUnknownKeys, types, opts) => { + return new verifier(types, toJS, allowUnknownKeys, opts).verify(path, v, typewords); + }; + api.parse = (name, v) => api.verifyArg(name, v, [name], true, false, api.types, defaultOptions); + class verifier { + types; + toJS; + allowUnknownKeys; + opts; + constructor(types, toJS, allowUnknownKeys, opts) { + this.types = types; + this.toJS = toJS; + this.allowUnknownKeys = allowUnknownKeys; + this.opts = opts; + } + verify(path, v, typewords) { + typewords = typewords.slice(0); + const ww = typewords.shift(); + const error = (msg) => { + if (path != '') { + msg = path + ': ' + msg; + } + throw new Error(msg); + }; + if (typeof ww !== 'string') { + error('bad typewords'); + return; // should not be necessary, typescript doesn't see error always throws an exception? + } + const w = ww; + const ensure = (ok, expect) => { + if (!ok) { + error('got ' + JSON.stringify(v) + ', expected ' + expect); + } + return v; + }; + switch (w) { + case 'nullable': + if (v === null || v === undefined && this.opts.nullableOptional) { + return v; + } + return this.verify(path, v, typewords); + case '[]': + if (v === null && this.opts.slicesNullable || v === undefined && this.opts.slicesNullable && this.opts.nullableOptional) { + return v; + } + ensure(Array.isArray(v), "array"); + return v.map((e, i) => this.verify(path + '[' + i + ']', e, typewords)); + case '{}': + if (v === null && this.opts.mapsNullable || v === undefined && this.opts.mapsNullable && this.opts.nullableOptional) { + return v; + } + ensure(v !== null || typeof v === 'object', "object"); + const r = {}; + for (const k in v) { + r[k] = this.verify(path + '.' + k, v[k], typewords); + } + return r; + } + ensure(typewords.length == 0, "empty typewords"); + const t = typeof v; + switch (w) { + case 'any': + return v; + case 'bool': + ensure(t === 'boolean', 'bool'); + return v; + case 'int8': + case 'uint8': + case 'int16': + case 'uint16': + case 'int32': + case 'uint32': + case 'int64': + case 'uint64': + ensure(t === 'number' && Number.isInteger(v), 'integer'); + return v; + case 'float32': + case 'float64': + ensure(t === 'number', 'float'); + return v; + case 'int64s': + case 'uint64s': + ensure(t === 'number' && Number.isInteger(v) || t === 'string', 'integer fitting in float without precision loss, or string'); + return '' + v; + case 'string': + ensure(t === 'string', 'string'); + return v; + case 'timestamp': + if (this.toJS) { + ensure(t === 'string', 'string, with timestamp'); + const d = new Date(v); + if (d instanceof Date && !isNaN(d.getTime())) { + return d; + } + error('invalid date ' + v); + } + else { + ensure(t === 'object' && v !== null, 'non-null object'); + ensure(v.__proto__ === Date.prototype, 'Date'); + return v.toISOString(); + } + } + // We're left with named types. + const nt = this.types[w]; + if (!nt) { + error('unknown type ' + w); + } + if (v === null) { + error('bad value ' + v + ' for named type ' + w); + } + if (api.structTypes[nt.Name]) { + const t = nt; + if (typeof v !== 'object') { + error('bad value ' + v + ' for struct ' + w); + } + const r = {}; + for (const f of t.Fields) { + r[f.Name] = this.verify(path + '.' + f.Name, v[f.Name], f.Typewords); + } + // If going to JSON also verify no unknown fields are present. + if (!this.allowUnknownKeys) { + const known = {}; + for (const f of t.Fields) { + known[f.Name] = true; + } + Object.keys(v).forEach((k) => { + if (!known[k]) { + error('unknown key ' + k + ' for struct ' + w); + } + }); + } + return r; + } + else if (api.stringsTypes[nt.Name]) { + const t = nt; + if (typeof v !== 'string') { + error('mistyped value ' + v + ' for named strings ' + t.Name); + } + if (!t.Values || t.Values.length === 0) { + return v; + } + for (const sv of t.Values) { + if (sv.Value === v) { + return v; + } + } + error('unknkown value ' + v + ' for named strings ' + t.Name); + } + else if (api.intsTypes[nt.Name]) { + const t = nt; + if (typeof v !== 'number' || !Number.isInteger(v)) { + error('mistyped value ' + v + ' for named ints ' + t.Name); + } + if (!t.Values || t.Values.length === 0) { + return v; + } + for (const sv of t.Values) { + if (sv.Value === v) { + return v; + } + } + error('unknkown value ' + v + ' for named ints ' + t.Name); + } + else { + throw new Error('unexpected named type ' + nt); + } + } + } + const _sherpaCall = async (baseURL, options, paramTypes, returnTypes, name, params) => { + if (!options.skipParamCheck) { + if (params.length !== paramTypes.length) { + return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length }); + } + params = params.map((v, index) => api.verifyArg('params[' + index + ']', v, paramTypes[index], false, false, api.types, options)); + } + const simulate = async (json) => { + const config = JSON.parse(json || 'null') || {}; + const waitMinMsec = config.waitMinMsec || 0; + const waitMaxMsec = config.waitMaxMsec || 0; + const wait = Math.random() * (waitMaxMsec - waitMinMsec); + const failRate = config.failRate || 0; + return new Promise((resolve, reject) => { + if (options.aborter) { + options.aborter.abort = () => { + reject({ message: 'call to ' + name + ' aborted by user', code: 'sherpa:aborted' }); + reject = resolve = () => { }; + }; + } + setTimeout(() => { + const r = Math.random(); + if (r < failRate) { + reject({ message: 'injected failure on ' + name, code: 'server:injected' }); + } + else { + resolve(); + } + reject = resolve = () => { }; + }, waitMinMsec + wait); + }); + }; + // Only simulate when there is a debug string. Otherwise it would always interfere + // with setting options.aborter. + let json = ''; + try { + json = window.localStorage.getItem('sherpats-debug') || ''; + } + catch (err) { } + if (json) { + await simulate(json); + } + // Immediately create promise, so options.aborter is changed before returning. + const promise = new Promise((resolve, reject) => { + let resolve1 = (v) => { + resolve(v); + resolve1 = () => { }; + reject1 = () => { }; + }; + let reject1 = (v) => { + reject(v); + resolve1 = () => { }; + reject1 = () => { }; + }; + const url = baseURL + name; + const req = new window.XMLHttpRequest(); + if (options.aborter) { + options.aborter.abort = () => { + req.abort(); + reject1({ code: 'sherpa:aborted', message: 'request aborted' }); + }; + } + req.open('POST', url, true); + if (options.timeoutMsec) { + req.timeout = options.timeoutMsec; + } + req.onload = () => { + if (req.status !== 200) { + if (req.status === 404) { + reject1({ code: 'sherpa:badFunction', message: 'function does not exist' }); + } + else { + reject1({ code: 'sherpa:http', message: 'error calling function, HTTP status: ' + req.status }); + } + return; + } + let resp; + try { + resp = JSON.parse(req.responseText); + } + catch (err) { + reject1({ code: 'sherpa:badResponse', message: 'bad JSON from server' }); + return; + } + if (resp && resp.error) { + const err = resp.error; + reject1({ code: err.code, message: err.message }); + return; + } + else if (!resp || !resp.hasOwnProperty('result')) { + reject1({ code: 'sherpa:badResponse', message: "invalid sherpa response object, missing 'result'" }); + return; + } + if (options.skipReturnCheck) { + resolve1(resp.result); + return; + } + let result = resp.result; + try { + if (returnTypes.length === 0) { + if (result) { + throw new Error('function ' + name + ' returned a value while prototype says it returns "void"'); + } + } + else if (returnTypes.length === 1) { + result = api.verifyArg('result', result, returnTypes[0], true, true, api.types, options); + } + else { + if (result.length != returnTypes.length) { + throw new Error('wrong number of values returned by ' + name + ', saw ' + result.length + ' != expected ' + returnTypes.length); + } + result = result.map((v, index) => api.verifyArg('result[' + index + ']', v, returnTypes[index], true, true, api.types, options)); + } + } + catch (err) { + let errmsg = 'bad types'; + if (err instanceof Error) { + errmsg = err.message; + } + reject1({ code: 'sherpa:badTypes', message: errmsg }); + } + resolve1(result); + }; + req.onerror = () => { + reject1({ code: 'sherpa:connection', message: 'connection failed' }); + }; + req.ontimeout = () => { + reject1({ code: 'sherpa:timeout', message: 'request timeout' }); + }; + req.setRequestHeader('Content-Type', 'application/json'); + try { + req.send(JSON.stringify({ params: params })); + } + catch (err) { + reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' }); + } + }); + return await promise; + }; +})(api || (api = {})); +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. +const client = new api.Client(); +const green = '#1dea20'; +const yellow = '#ffe400'; +const red = '#ff7443'; +const blue = '#8bc8ff'; +const link = (href, anchorOpt) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), anchorOpt || href); +const crumblink = (text, link) => dom.a(text, attr.href(link)); +const crumbs = (...l) => [dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])), dom.br()]; +const errmsg = (err) => '' + (err.message || '(no error message)'); +const footer = dom.div(style({ marginTop: '6ex', opacity: 0.75 }), link('https://github.com/mjl-/mox', 'mox'), ' ', moxversion); +const age = (date, future, nowSecs) => { + if (!nowSecs) { + nowSecs = new Date().getTime() / 1000; + } + let t = nowSecs - date.getTime() / 1000; + let negative = false; + if (t < 0) { + negative = true; + t = -t; + } + const minute = 60; + const hour = 60 * minute; + const day = 24 * hour; + const month = 30 * day; + const year = 365 * day; + const periods = [year, month, day, hour, minute, 1]; + const suffix = ['y', 'm', 'd', 'h', 'mins', 's']; + let l = []; + for (let i = 0; i < periods.length; i++) { + const p = periods[i]; + if (t >= 2 * p || i == periods.length - 1) { + const n = Math.floor(t / p); + l.push('' + n + suffix[i]); + t -= n * p; + if (l.length >= 2) { + break; + } + } + } + let s = l.join(' '); + if (!future || !negative) { + s += ' ago'; + } + return dom.span(attr.title(date.toString()), s); +}; +const domainName = (d) => { + return d.Unicode || d.ASCII; +}; +const domainString = (d) => { + if (d.Unicode) { + return d.Unicode + " (" + d.ASCII + ")"; + } + return d.ASCII; +}; +// IP is encoded as base64 bytes, either 4 or 16. +// It's a bit silly to encode this in JS, but it's convenient to simply pass on the +// net.IP bytes from the backend. +const formatIP = (s) => { + const buf = window.atob(s); + const bytes = Uint8Array.from(buf, (m) => m.codePointAt(0) || 0); + if (bytes.length === 4 || isIPv4MappedIPv6(bytes)) { + // Format last 4 bytes as IPv4 address.. + return [bytes.at(-4), bytes.at(-3), bytes.at(-2), bytes.at(-1)].join('.'); + } + return formatIPv6(bytes); +}; +// See if b is of the form ::ffff:x.x.x.x +const v4v6Prefix = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff]); +const isIPv4MappedIPv6 = (b) => { + if (b.length !== 16) { + return false; + } + for (let i = 0; i < v4v6Prefix.length; i++) { + if (b[i] !== v4v6Prefix[i]) { + return false; + } + } + return true; +}; +const formatIPv6 = (b) => { + const hexchars = "0123456789abcdef"; + const hex = (v, skipzero) => (skipzero && ((v >> 4) & 0xf) === 0 ? '' : hexchars[(v >> 4) & 0xf]) + hexchars[v & 0xf]; + let zeroStart = 0, zeroEnd = 0; + // Find largest run of zeroes. + let i = 0; + while (i < 16) { + let j = i; + while (j < 16 && b[j] === 0) { + j++; + } + if (j - i > 2 && j - i > zeroEnd - zeroStart) { + zeroStart = i; + zeroEnd = j; + i = j; + } + else if (j > i) { + i = j; + } + else { + i++; + } + } + let s = ''; + for (let i = 0; i < 16; i++) { + if (i === zeroStart) { + s += '::'; + } + else if (i < zeroStart || i >= zeroEnd) { + if (i > 0 && i % 2 === 0 && !s.endsWith(':')) { + s += ':'; + } + if (i % 2 === 1 || b[i] !== 0) { + s += hex(b[i], s === '' || s.endsWith(':')); + } + } + } + return s; +}; +const ipdomainString = (ipd) => { + if (ipd.IP !== '') { + return formatIP(ipd.IP); + } + return domainString(ipd.Domain); +}; +const formatSize = (n) => { + if (n > 10 * 1024 * 1024) { + return Math.round(n / (1024 * 1024)) + ' mb'; + } + else if (n > 500) { + return Math.round(n / 1024) + ' kb'; + } + return n + ' bytes'; +}; +const index = async () => { + const [domains, queueSize, checkUpdatesEnabled] = await Promise.all([ + client.Domains(), + client.QueueSize(), + client.CheckUpdatesEnabled(), + ]); + let fieldset; + let domain; + let account; + let localpart; + 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.h2('Domains'), (domains || []).length === 0 ? box(red, 'No domains') : + dom.ul((domains || []).map(d => dom.li(dom.a(attr.href('#domains/' + domainName(d)), domainString(d))))), dom.br(), dom.h2('Add domain'), dom.form(async function submit(e) { + e.preventDefault(); + e.stopPropagation(); + fieldset.disabled = true; + try { + await client.DomainAdd(domain.value, account.value, localpart.value); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + fieldset.disabled = false; + } + window.location.hash = '#domains/' + domain.value; + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Domain', dom.br(), domain = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Postmaster/reporting account', dom.br(), account = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Localpart (optional)', attr.title('Must be set if and only if account does not yet exist. The localpart for the user of this domain. E.g. postmaster.')), dom.br(), localpart = dom.input()), ' ', dom.submitbutton('Add domain', attr.title('Domain will be added and the config reloaded. You should add the required DNS records after adding the domain.')))), dom.br(), dom.h2('Reports'), dom.div(dom.a('DMARC', attr.href('#dmarc/reports'))), dom.div(dom.a('TLS', attr.href('#tlsrpt/reports'))), dom.br(), dom.h2('Operations'), dom.div(dom.a('MTA-STS policies', attr.href('#mtasts'))), dom.div(dom.a('DMARC evaluations', attr.href('#dmarc/evaluations'))), dom.div(dom.a('TLS connection results', attr.href('#tlsrpt/results'))), + // todo: routing, globally, per domain and per account + dom.br(), dom.h2('DNS blocklist status'), dom.div(dom.a('DNSBL status', attr.href('#dnsbl'))), dom.br(), dom.h2('Configuration'), dom.div(dom.a('Webserver', attr.href('#webserver'))), dom.div(dom.a('Files', attr.href('#config'))), dom.div(dom.a('Log levels', attr.href('#loglevels'))), footer); +}; +const config = async () => { + const [staticPath, dynamicPath, staticText, dynamicText] = await client.ConfigFiles(); + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Config'), dom.h2(staticPath), dom.pre(dom._class('literal'), staticText), dom.h2(dynamicPath), dom.pre(dom._class('literal'), dynamicText)); +}; +const loglevels = async () => { + const loglevels = await client.LogLevels(); + const levels = ['error', 'info', 'warn', 'debug', 'trace', 'traceauth', 'tracedata']; + let form; + let fieldset; + let pkg; + let level; + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Log levels'), dom.p('Note: changing a log level here only changes it for the current process. When mox restarts, it sets the log levels from the configuration file. Change mox.conf to keep the changes.'), dom.table(dom.thead(dom.tr(dom.th('Package', attr.title('Log levels can be configured per package. E.g. smtpserver, imapserver, dkim, dmarc, tlsrpt, etc.')), dom.th('Level', attr.title('If you set the log level to "trace", imap and smtp protocol transcripts will be logged. Sensitive authentication is replaced with "***" unless the level is >= "traceauth". Data is masked with "..." unless the level is "tracedata".')), dom.th('Action'))), dom.tbody(Object.entries(loglevels).map(t => { + let lvl; + return dom.tr(dom.td(t[0] || '(default)'), dom.td(lvl = dom.select(levels.map(l => dom.option(l, t[1] === l ? attr.selected('') : [])))), dom.td(dom.clickbutton('Save', attr.title('Set new log level for package.'), async function click(e) { + e.preventDefault(); + const target = e.target; + try { + target.disabled = true; + await client.LogLevelSet(t[0], lvl.value); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + err); + return; + } + finally { + target.disabled = false; + } + window.location.reload(); // todo: reload just the current loglevels + }), ' ', dom.clickbutton('Remove', attr.title('Remove this log level, the default log level will apply.'), t[0] === '' ? attr.disabled('') : [], async function click(e) { + e.preventDefault(); + const target = e.target; + try { + target.disabled = true; + await client.LogLevelRemove(t[0]); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + err); + return; + } + finally { + target.disabled = false; + } + window.location.reload(); // todo: reload just the current loglevels + }))); + }))), dom.br(), dom.h2('Add log level setting'), form = dom.form(async function submit(e) { + e.preventDefault(); + e.stopPropagation(); + fieldset.disabled = true; + try { + await client.LogLevelSet(pkg.value, level.value); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + fieldset.disabled = false; + } + form.reset(); + window.location.reload(); // todo: reload just the current loglevels + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Package', dom.br(), pkg = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Level', dom.br(), level = dom.select(attr.required(''), levels.map(l => dom.option(l, l === 'debug' ? attr.selected('') : [])))), ' ', dom.submitbutton('Add')), dom.br(), dom.p('Suggestions for packages: autotls dkim dmarc dmarcdb dns dnsbl dsn http imapserver iprev junk message metrics mox moxio mtasts mtastsdb publicsuffix queue sendmail serve smtpserver spf store subjectpass tlsrpt tlsrptdb updates'))); +}; +const box = (color, ...l) => [ + dom.div(style({ + display: 'inline-block', + padding: '.25em .5em', + backgroundColor: color, + borderRadius: '3px', + margin: '.5ex 0', + }), l), + dom.br(), +]; +const inlineBox = (color, ...l) => dom.span(style({ + display: 'inline-block', + padding: color ? '0.05em 0.2em' : '', + backgroundColor: color, + borderRadius: '3px', +}), l); +const accounts = async () => { + const accounts = await client.Accounts(); + let fieldset; + let account; + let email; + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Accounts'), dom.h2('Accounts'), (accounts || []).length === 0 ? dom.p('No accounts') : + dom.ul((accounts || []).map(s => dom.li(dom.a(s, attr.href('#accounts/' + s))))), dom.br(), dom.h2('Add account'), dom.form(async function submit(e) { + e.preventDefault(); + e.stopPropagation(); + fieldset.disabled = true; + try { + await client.AccountAdd(account.value, email.value); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + fieldset.disabled = false; + } + window.location.hash = '#accounts/' + account.value; + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Account name', dom.br(), account = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Email address', dom.br(), email = dom.input(attr.type('email'), attr.required(''))), ' ', dom.submitbutton('Add account', attr.title('The account will be added and the config reloaded.'))))); +}; +const account = async (name) => { + const config = await client.Account(name); + let form; + let fieldset; + let email; + let fieldsetLimits; + let maxOutgoingMessagesPerDay; + let maxFirstTimeRecipientsPerDay; + let quotaMessageSize; + let formPassword; + let fieldsetPassword; + let password; + let passwordHint; + const xparseSize = (s) => { + const origs = s; + s = s.toLowerCase(); + let mult = 1; + if (s.endsWith('k')) { + mult = 1024; + } + else if (s.endsWith('m')) { + mult = 1024 * 1024; + } + else if (s.endsWith('g')) { + mult = 1024 * 1024 * 1024; + } + else if (s.endsWith('t')) { + mult = 1024 * 1024 * 1024 * 1024; + } + if (mult !== 1) { + s = s.substring(0, s.length - 1); + } + let v = parseInt(s); + console.log('x', s, v, mult, formatQuotaSize(v * mult)); + if (isNaN(v) || origs !== formatQuotaSize(v * mult)) { + throw new Error('invalid number'); + } + return v * mult; + }; + const formatQuotaSize = (v) => { + if (v === 0) { + return '0'; + } + const m = 1024 * 1024; + const g = m * 1024; + const t = g * 1024; + if (Math.floor(v / t) * t === v) { + return '' + (v / t) + 't'; + } + else if (Math.floor(v / g) * g === v) { + return '' + (v / g) + 'g'; + } + else if (Math.floor(v / m) * m === v) { + return '' + (v / m) + 'm'; + } + return '' + v; + }; + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Accounts', '#accounts'), name), dom.div('Default domain: ', config.Domain ? dom.a(config.Domain, attr.href('#domains/' + config.Domain)) : '(none)'), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Action'))), dom.tbody(Object.keys(config.Destinations).map(k => { + let v = k; + const t = k.split('@'); + if (t.length > 1) { + const d = t[t.length - 1]; + const lp = t.slice(0, t.length - 1).join('@'); + v = [ + lp, '@', + dom.a(d, attr.href('#domains/' + d)), + ]; + if (lp === '') { + v.unshift('(catchall) '); + } + } + return dom.tr(dom.td(v), dom.td(dom.clickbutton('Remove', async function click(e) { + e.preventDefault(); + if (!window.confirm('Are you sure you want to remove this address?')) { + return; + } + const target = e.target; + target.disabled = true; + try { + let addr = k; + if (!addr.includes('@')) { + addr += '@' + config.Domain; + } + await client.AddressRemove(addr); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + target.disabled = false; + } + window.location.reload(); // todo: reload just the list + }))); + }))), dom.br(), dom.h2('Add address'), form = dom.form(async function submit(e) { + e.preventDefault(); + e.stopPropagation(); + fieldset.disabled = true; + try { + let addr = email.value; + if (!addr.includes('@')) { + if (!config.Domain) { + throw new Error('no default domain configured for account'); + } + addr += '@' + config.Domain; + } + await client.AddressAdd(addr, name); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + fieldset.disabled = false; + } + form.reset(); + window.location.reload(); // todo: only reload the destinations + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Email address or localpart', attr.title('If empty, or localpart is empty, a catchall address is configured for the domain.')), dom.br(), email = dom.input()), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Limits'), dom.form(fieldsetLimits = dom.fieldset(dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum outgoing messages per day', attr.title('Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000. MaxOutgoingMessagesPerDay in configuration file.')), dom.br(), maxOutgoingMessagesPerDay = dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxOutgoingMessagesPerDay || 1000))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum first-time recipients per day', attr.title('Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200. MaxFirstTimeRecipientsPerDay in configuration file.')), dom.br(), maxFirstTimeRecipientsPerDay = dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxFirstTimeRecipientsPerDay || 200))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size for the account, overriding any globally configured maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages beyond the maximum size will result in an error. Useful to prevent a single account from filling storage.')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize)))), dom.submitbutton('Save')), async function submit(e) { + e.stopPropagation(); + e.preventDefault(); + fieldsetLimits.disabled = true; + try { + await client.SetAccountLimits(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value)); + window.alert('Limits saved.'); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + fieldsetLimits.disabled = false; + } + }), dom.br(), dom.h2('Set new password'), formPassword = dom.form(fieldsetPassword = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'New password', dom.br(), password = dom.input(attr.type('password'), attr.required(''), function focus() { + passwordHint.style.display = ''; + })), ' ', dom.submitbutton('Change password')), passwordHint = dom.div(style({ display: 'none', marginTop: '.5ex' }), dom.clickbutton('Generate random password', function click(e) { + e.preventDefault(); + let b = new Uint8Array(1); + let s = ''; + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_;:,<.>/'; + while (s.length < 12) { + self.crypto.getRandomValues(b); + if (Math.ceil(b[0] / chars.length) * chars.length > 255) { + continue; // Prevent bias. + } + s += chars[b[0] % chars.length]; + } + password.type = 'text'; + password.value = s; + }), dom.div(dom._class('text'), box(yellow, 'Important: Bots will try to bruteforce your password. Connections with failed authentication attempts will be rate limited but attackers WILL find weak passwords. If your account is compromised, spammers are likely to abuse your system, spamming your address and the wider internet in your name. So please pick a random, unguessable password, preferrably at least 12 characters.'))), async function submit(e) { + e.stopPropagation(); + e.preventDefault(); + fieldsetPassword.disabled = true; + try { + await client.SetPassword(name, password.value); + window.alert('Password has been changed.'); + formPassword.reset(); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + fieldsetPassword.disabled = false; + } + }), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove account', async function click(e) { + e.preventDefault(); + if (!window.confirm('Are you sure you want to remove this account?')) { + return; + } + const target = e.target; + target.disabled = true; + try { + await client.AccountRemove(name); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + target.disabled = false; + } + window.location.hash = '#accounts'; + })); +}; +const domain = async (d) => { + const end = new Date(); + const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); + const [dmarcSummaries, tlsrptSummaries, localpartAccounts, dnsdomain, clientConfigs] = await Promise.all([ + client.DMARCSummaries(start, end, d), + client.TLSRPTSummaries(start, end, d), + client.DomainLocalparts(d), + client.Domain(d), + client.ClientConfigsDomain(d), + ]); + let form; + let fieldset; + let localpart; + let account; + 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.div('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password.'), dom.table(dom.thead(dom.tr(dom.th('Protocol'), dom.th('Host'), dom.th('Port'), dom.th('Listener'), dom.th('Note'))), dom.tbody((clientConfigs.Entries || []).map(e => dom.tr(dom.td(e.Protocol), dom.td(domainString(e.Host)), dom.td('' + e.Port), dom.td('' + e.Listener), dom.td('' + e.Note))))), dom.br(), dom.h2('DMARC aggregate reports summary'), renderDMARCSummaries(dmarcSummaries || []), dom.br(), dom.h2('TLS reports summary'), renderTLSRPTSummaries(tlsrptSummaries || []), dom.br(), dom.h2('Addresses'), dom.table(dom.thead(dom.tr(dom.th('Address'), dom.th('Account'), dom.th('Action'))), dom.tbody(Object.entries(localpartAccounts).map(t => dom.tr(dom.td(t[0] || '(catchall)'), dom.td(dom.a(t[1], attr.href('#accounts/' + t[1]))), dom.td(dom.clickbutton('Remove', async function click(e) { + e.preventDefault(); + if (!window.confirm('Are you sure you want to remove this address?')) { + return; + } + const target = e.target; + target.disabled = true; + try { + await client.AddressRemove(t[0] + '@' + d); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + target.disabled = false; + } + window.location.reload(); // todo: only reload the localparts + })))))), dom.br(), dom.h2('Add address'), form = dom.form(async function submit(e) { + e.preventDefault(); + e.stopPropagation(); + fieldset.disabled = true; + try { + await client.AddressAdd(localpart.value + '@' + d, account.value); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + fieldset.disabled = false; + } + form.reset(); + window.location.reload(); // todo: only reload the addresses + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('An empty localpart is the catchall destination/address for the domain.')), dom.br(), localpart = dom.input()), ' ', dom.label(style({ display: 'inline-block' }), 'Account', dom.br(), account = dom.input(attr.required(''))), ' ', dom.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')))), dom.br(), dom.h2('External checks'), dom.ul(dom.li(link('https://internet.nl/mail/' + dnsdomain.ASCII + '/', 'Check configuration at internet.nl'))), dom.br(), dom.h2('Danger'), dom.clickbutton('Remove domain', async function click(e) { + e.preventDefault(); + if (!window.confirm('Are you sure you want to remove this domain?')) { + return; + } + const target = e.target; + target.disabled = true; + try { + await client.DomainRemove(d); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + target.disabled = false; + } + window.location.hash = '#'; + })); +}; +const domainDNSRecords = async (d) => { + const [records, dnsdomain] = await Promise.all([ + client.DomainRecords(d), + client.Domain(d), + ]); + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'DNS Records'), dom.h1('Required DNS records'), dom.pre('pre', dom._class('literal'), (records || []).join('\n')), dom.br()); +}; +const domainDNSCheck = async (d) => { + const [checks, dnsdomain] = await Promise.all([ + client.CheckDomain(d), + client.Domain(d), + ]); + const resultSection = (title, r, details) => { + let success = []; + if ((r.Errors || []).length === 0 && (r.Warnings || []).length === 0) { + success = box(green, 'OK'); + } + const errors = (r.Errors || []).length === 0 ? [] : box(red, dom.ul(style({ marginLeft: '1em' }), (r.Errors || []).map(s => dom.li(s)))); + const warnings = (r.Warnings || []).length === 0 ? [] : box(yellow, dom.ul(style({ marginLeft: '1em' }), (r.Warnings || []).map(s => dom.li(s)))); + let instructions = null; + if (r.Instructions && r.Instructions.length > 0) { + instructions = dom.div(style({ margin: '.5ex 0' })); + const instrs = [ + (r.Instructions || []).map(s => [ + dom.pre(dom._class('literal'), style({ display: 'inline-block', maxWidth: '60em' }), s), + dom.br(), + ]), + ]; + if ((r.Errors || []).length === 0) { + dom._kids(instructions, dom.div(dom.a('Show instructions', attr.href('#'), function click(e) { + e.preventDefault(); + dom._kids(instructions, instrs); + }), dom.br())); + } + else { + dom._kids(instructions, instrs); + } + } + return [ + dom.h2(title), + success, + errors, + warnings, + details, + dom.br(), + instructions ? instructions : [], + dom.br(), + ]; + }; + const detailsDNSSEC = []; + const detailsIPRev = !checks.IPRev.IPNames || !Object.entries(checks.IPRev.IPNames).length ? [] : [ + dom.div('Hostname: ' + domainString(checks.IPRev.Hostname)), + dom.table(dom.tr(dom.th('IP'), dom.th('Addresses')), Object.entries(checks.IPRev.IPNames).sort().map(t => dom.tr(dom.td(t[0]), dom.td((t[1] || []).join(', '))))), + ]; + const detailsMX = (checks.MX.Records || []).length === 0 ? [] : [ + dom.table(dom.tr(dom.th('Preference'), dom.th('Host'), dom.th('IPs')), (checks.MX.Records || []).map(mx => dom.tr(dom.td('' + mx.Pref), dom.td(mx.Host), dom.td((mx.IPs || []).join(', '))))), + ]; + const detailsTLS = []; + const detailsDANE = []; + const detailsSPF = [ + checks.SPF.DomainTXT ? [dom.div('Domain TXT record: ' + checks.SPF.DomainTXT)] : [], + checks.SPF.HostTXT ? [dom.div('Host TXT record: ' + checks.SPF.HostTXT)] : [], + ]; + const detailsDKIM = (checks.DKIM.Records || []).length === 0 ? [] : [ + dom.table(dom.tr(dom.th('Selector'), dom.th('TXT record')), (checks.DKIM.Records || []).map(rec => dom.tr(dom.td(rec.Selector), dom.td(rec.TXT)))) + ]; + const detailsDMARC = !checks.DMARC.Domain ? [] : [ + dom.div('Domain: ' + checks.DMARC.Domain), + !checks.DMARC.TXT ? [] : dom.div('TXT record: ' + checks.DMARC.TXT), + ]; + const detailsTLSRPT = (checksTLSRPT) => !checksTLSRPT.TXT ? [] : [ + dom.div('TXT record: ' + checksTLSRPT.TXT), + ]; + const detailsMTASTS = !checks.MTASTS.TXT && !checks.MTASTS.PolicyText ? [] : [ + !checks.MTASTS.TXT ? [] : dom.div('MTA-STS record: ' + checks.MTASTS.TXT), + !checks.MTASTS.PolicyText ? [] : dom.div('MTA-STS policy: ', dom.pre(dom._class('literal'), style({ maxWidth: '60em' }), checks.MTASTS.PolicyText)), + ]; + const detailsSRVConf = !checks.SRVConf.SRVs || Object.keys(checks.SRVConf.SRVs).length === 0 ? [] : [ + dom.table(dom.tr(dom.th('Service'), dom.th('Priority'), dom.th('Weight'), dom.th('Port'), dom.th('Host')), Object.entries(checks.SRVConf.SRVs || []).map(t => { + const l = t[1]; + if (!l || !l.length) { + return dom.tr(dom.td(t[0]), dom.td(attr.colspan('4'), '(none)')); + } + return l.map(r => dom.tr([t[0], r.Priority, r.Weight, r.Port, r.Target].map(s => dom.td('' + s)))); + })), + ]; + const detailsAutoconf = [ + ...(!checks.Autoconf.ClientSettingsDomainIPs ? [] : [dom.div('Client settings domain IPs: ' + checks.Autoconf.ClientSettingsDomainIPs.join(', '))]), + ...(!checks.Autoconf.IPs ? [] : [dom.div('IPs: ' + checks.Autoconf.IPs.join(', '))]), + ]; + const detailsAutodiscover = !checks.Autodiscover.Records ? [] : [ + dom.table(dom.tr(dom.th('Host'), dom.th('Port'), dom.th('Priority'), dom.th('Weight'), dom.th('IPs')), (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()); +}; +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.'))); +}; +const dmarcReports = async () => { + const end = new Date(); + const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); + const summaries = await client.DMARCSummaries(start, end, ""); + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), 'Aggregate reporting summary'), dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), renderDMARCSummaries(summaries || [])); +}; +const renderDMARCSummaries = (summaries) => { + return [ + dom.p('Below a summary of DMARC aggregate reporting results for the past 30 days.'), + summaries.length === 0 ? dom.div(box(yellow, 'No domains with reports.')) : + dom.table(dom.thead(dom.tr(dom.th('Domain', attr.title('Domain to which the DMARC policy applied. If example.com has a DMARC policy, and email is sent with a From-header with subdomain.example.com, and there is no DMARC record for that subdomain, but there is one for example.com, then the DMARC policy of example.com applies and reports are sent for that that domain.')), dom.th('Messages', attr.title('Total number of messages that had the DMARC policy applied and reported. Actual messages sent is likely higher because not all email servers send DMARC aggregate reports, or perform DMARC checks at all.')), dom.th('DMARC "quarantine"/"reject"', attr.title('Messages for which policy was to mark them as spam (quarantine) or reject them during SMTP delivery.')), dom.th('DKIM "fail"', attr.title('Messages with a failing DKIM check. This can happen when sending through a mailing list where that list keeps your address in the message From-header but also strips DKIM-Signature headers in the message. DMARC evaluation passes if either DKIM passes or SPF passes.')), dom.th('SPF "fail"', attr.title('Message with a failing SPF check. This can happen with email forwarding and with mailing list. Other mail servers have sent email with this domain in the message From-header. DMARC evaluation passes if at least SPF or DKIM passes.')), dom.th('Policy overrides', attr.title('Mail servers can override the DMARC policy. E.g. a mail server may be able to detect emails coming from mailing lists that do not pass DMARC and would have to be rejected, but for which an override has been configured.')))), dom.tbody(summaries.map(r => dom.tr(dom.td(dom.a(attr.href('#domains/' + r.Domain + '/dmarc'), attr.title('See report details.'), r.Domain)), dom.td(style({ textAlign: 'right' }), '' + r.Total), dom.td(style({ textAlign: 'right' }), r.DispositionQuarantine === 0 && r.DispositionReject === 0 ? '0/0' : box(red, '' + r.DispositionQuarantine + '/' + r.DispositionReject)), dom.td(style({ textAlign: 'right' }), box(r.DKIMFail === 0 ? green : red, '' + r.DKIMFail)), dom.td(style({ textAlign: 'right' }), box(r.SPFFail === 0 ? green : red, '' + r.SPFFail)), dom.td(!r.PolicyOverrides ? [] : Object.entries(r.PolicyOverrides).map(kv => kv[0] + ': ' + kv[1]).join('; ')))))) + ]; +}; +const dmarcEvaluations = async () => { + const [evalStats, suppressAddresses] = await Promise.all([ + client.DMARCEvaluationStats(), + client.DMARCSuppressList(), + ]); + const isEmpty = (o) => { + for (const _ in o) { + return false; + } + return true; + }; + let fieldset; + let reportingAddress; + let until; + let comment; + const nextmonth = new Date(new Date().getTime() + 31 * 24 * 3600 * 1000); + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('DMARC', '#dmarc'), 'Evaluations'), dom.p('Incoming messages are checked against the DMARC policy of the domain in the message From header. If the policy requests reporting on the resulting evaluations, they are stored in the database. Each interval of 1 to 24 hours, the evaluations may be sent to a reporting address specified in the domain\'s DMARC policy. Not all evaluations are a reason to send a report, but if a report is sent all evaluations are included.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Domain', attr.title('Domain in the message From header. Keep in mind these can be forged, so this does not necessarily mean someone from this domain authentically tried delivering email.')), dom.th('Dispositions', attr.title('Unique dispositions occurring in report.')), dom.th('Evaluations', attr.title('Total number of message delivery attempts, including retries.')), dom.th('Send report', attr.title('Whether the current evaluations will cause a report to be sent.')))), dom.tbody(Object.entries(evalStats).sort((a, b) => a[0] < b[0] ? -1 : 1).map(t => dom.tr(dom.td(dom.a(attr.href('#dmarc/evaluations/' + domainName(t[1].Domain)), domainString(t[1].Domain))), dom.td((t[1].Dispositions || []).join(' ')), dom.td(style({ textAlign: 'right' }), '' + t[1].Count), dom.td(style({ textAlign: 'right' }), t[1].SendReport ? '✓' : ''))), isEmpty(evalStats) ? dom.tr(dom.td(attr.colspan('3'), 'No evaluations.')) : [])), dom.br(), dom.br(), dom.h2('Suppressed reporting addresses'), dom.p('In practice, sending a DMARC report to a reporting address can cause DSN to be sent back. Such addresses can be added to a supression list for a period, to reduce noise in the postmaster mailbox.'), dom.form(async function submit(e) { + e.stopPropagation(); + e.preventDefault(); + try { + fieldset.disabled = true; + await client.DMARCSuppressAdd(reportingAddress.value, new Date(until.value), comment.value); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + fieldset.disabled = false; + } + window.location.reload(); // todo: add the address to the list, or only reload the list + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Reporting address', dom.br(), reportingAddress = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Until', dom.br(), until = dom.input(attr.type('date'), attr.required(''), attr.value(nextmonth.getFullYear() + '-' + (1 + nextmonth.getMonth()) + '-' + nextmonth.getDate()))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Comment (optional)'), dom.br(), comment = dom.input()), ' ', dom.submitbutton('Add', attr.title('Outgoing reports to this reporting address will be suppressed until the end time.')))), dom.br(), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Reporting address'), dom.th('Until'), dom.th('Comment'), dom.th('Action'))), dom.tbody((suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No suppressed reporting addresses.')) : [], (suppressAddresses || []).map(ba => dom.tr(dom.td(ba.ReportingAddress), dom.td(ba.Until.toISOString()), dom.td(ba.Comment), dom.td(dom.clickbutton('Remove', async function click(e) { + const target = e.target; + try { + target.disabled = true; + await client.DMARCSuppressRemove(ba.ID); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + target.disabled = false; + } + window.location.reload(); // todo: only reload the list + }), ' ', dom.clickbutton('Extend for 1 month', async function click(e) { + const target = e.target; + try { + target.disabled = true; + await client.DMARCSuppressExtend(ba.ID, new Date(new Date().getTime() + 31 * 24 * 3600 * 1000)); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + target.disabled = false; + } + window.location.reload(); // todo: only reload the list + }))))))); +}; +const dmarcEvaluationsDomain = async (domain) => { + const [d, evaluations] = await client.DMARCEvaluationsDomain(domain); + let lastInterval = ''; + let lastAddresses = ''; + const formatPolicy = (e) => { + const p = e.PolicyPublished; + let s = ''; + const add = (k, v) => { + if (v) { + s += k + '=' + v + '; '; + } + }; + add('p', p.Policy); + add('sp', p.SubdomainPolicy); + add('adkim', p.ADKIM); + add('aspf', p.ASPF); + add('pct', '' + p.Percentage); + add('fo', '' + p.ReportingOptions); + return s; + }; + let lastPolicy = ''; + const authStatus = (v) => inlineBox(v ? '' : yellow, v ? 'pass' : 'fail'); + const formatDKIMResults = (results) => results.map(r => dom.div('selector ' + r.Selector + (r.Domain !== domain ? ', domain ' + r.Domain : '') + ': ', inlineBox(r.Result === "pass" ? '' : yellow, r.Result))); + const formatSPFResults = (alignedpass, results) => results.map(r => dom.div('' + r.Scope + (r.Domain !== domain ? ', domain ' + r.Domain : '') + ': ', inlineBox(r.Result === "pass" && alignedpass ? '' : yellow, r.Result))); + const sourceIP = (ip) => { + const r = dom.span(ip, attr.title('Click to do a reverse lookup of the IP.'), style({ cursor: 'pointer' }), async function click(e) { + e.preventDefault(); + try { + const rev = await client.LookupIP(ip); + r.innerText = ip + '\n' + (rev.Hostnames || []).join('\n'); + } + catch (err) { + r.innerText = ip + '\nerror: ' + errmsg(err); + } + }); + 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) { + const target = e.target; + target.disabled = true; + try { + await client.DMARCRemoveEvaluations(domain); + window.location.reload(); // todo: only clear the table? + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + } + finally { + target.disabled = false; + } + })), 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 => { + const ival = e.IntervalHours + 'h'; + const interval = ival === lastInterval ? '' : ival; + lastInterval = ival; + const a = (e.Addresses || []).join('\n'); + const addresses = a === lastAddresses ? '' : a; + lastAddresses = a; + const p = formatPolicy(e); + const policy = p === lastPolicy ? '' : p; + lastPolicy = p; + return dom.tr(dom.td('' + e.ID), dom.td(new Date(e.Evaluated).toUTCString()), dom.td(e.Optional ? 'Yes' : ''), dom.td(interval), dom.td(addresses), dom.td(policy), dom.td(sourceIP(e.SourceIP)), dom.td(inlineBox(e.Disposition === 'none' ? '' : red, e.Disposition), (e.OverrideReasons || []).length > 0 ? ' (' + (e.OverrideReasons || []).map(r => r.Type).join(', ') + ')' : ''), dom.td(authStatus(e.AlignedDKIMPass), '/', authStatus(e.AlignedSPFPass)), dom.td(e.EnvelopeTo), dom.td(e.EnvelopeFrom), dom.td(e.HeaderFrom), dom.td(formatDKIMResults(e.DKIMResults || [])), dom.td(formatSPFResults(e.AlignedSPFPass, e.SPFResults || []))); + }), (evaluations || []).length === 0 ? dom.tr(dom.td(attr.colspan('14'), 'No evaluations.')) : []))); +}; +const utcDate = (dt) => new Date(Date.UTC(dt.getUTCFullYear(), dt.getUTCMonth(), dt.getUTCDate(), dt.getUTCHours(), dt.getUTCMinutes(), dt.getUTCSeconds())); +const utcDateStr = (dt) => [dt.getUTCFullYear(), 1 + dt.getUTCMonth(), dt.getUTCDate()].join('-'); +const isDayChange = (dt) => utcDateStr(new Date(dt.getTime() - 2 * 60 * 1000)) !== utcDateStr(new Date(dt.getTime() + 2 * 60 * 1000)); +const period = (start, end) => { + const beginUTC = utcDate(start); + const endUTC = utcDate(end); + const beginDayChange = isDayChange(beginUTC); + const endDayChange = isDayChange(endUTC); + let beginstr = utcDateStr(beginUTC); + let endstr = utcDateStr(endUTC); + const title = attr.title('' + beginUTC.toISOString() + ' - ' + endUTC.toISOString()); + if (beginDayChange && endDayChange && Math.abs(beginUTC.getTime() - endUTC.getTime()) < 24 * (2 * 60 + 3600) * 1000) { + return dom.span(beginstr, title); + } + const pad = (v) => v < 10 ? '0' + v : '' + v; + if (!beginDayChange) { + beginstr += ' ' + pad(beginUTC.getUTCHours()) + ':' + pad(beginUTC.getUTCMinutes()); + } + if (!endDayChange) { + endstr += ' ' + pad(endUTC.getUTCHours()) + ':' + pad(endUTC.getUTCMinutes()); + } + return dom.span(beginstr + ' - ' + endstr, title); +}; +const domainDMARC = async (d) => { + const end = new Date(); + const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); + const [reports, dnsdomain] = await Promise.all([ + client.DMARCReports(start, end, d), + client.Domain(d), + ]); + // todo future: table sorting? period selection (last day, 7 days, 1 month, 1 year, custom period)? collapse rows for a report? show totals per report? a simple bar graph to visualize messages and dmarc/dkim/spf fails? similar for TLSRPT. + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), 'DMARC aggregate reports'), dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), dom.p('Below the DMARC aggregate reports for the past 30 days.'), (reports || []).length === 0 ? dom.div('No DMARC reports for domain.') : + dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('ID'), dom.th('Organisation', attr.title('Organization that sent the DMARC report.')), dom.th('Period (UTC)', attr.title('Period this reporting period is about. Mail servers are recommended to stick to whole UTC days.')), dom.th('Policy', attr.title('The DMARC policy that the remote mail server had fetched and applied to the message. A policy that changed during the reporting period may result in unexpected policy evaluations.')), dom.th('Source IP', attr.title('Remote IP address of session at remote mail server.')), dom.th('Messages', attr.title('Total messages that the results apply to.')), dom.th('Result', attr.title('DMARC evaluation result.')), dom.th('ADKIM', attr.title('DKIM alignment. For a pass, one of the DKIM signatures that pass must be strict/relaxed-aligned with the domain, as specified by the policy.')), dom.th('ASPF', attr.title('SPF alignment. For a pass, the SPF policy must pass and be strict/relaxed-aligned with the domain, as specified by the policy.')), dom.th('SMTP to', attr.title('Domain of destination address, as specified during the SMTP session.')), dom.th('SMTP from', attr.title('Domain of originating address, as specified during the SMTP session.')), dom.th('Header from', attr.title('Domain of address in From-header of message.')), dom.th('Auth Results', attr.title('Details of DKIM and/or SPF authentication results. DMARC requires at least one aligned DKIM or SPF pass.')))), dom.tbody((reports || []).map(r => { + const m = r.ReportMetadata; + let policy = []; + if (r.PolicyPublished.Domain !== d) { + policy.push(r.PolicyPublished.Domain); + } + const alignments = { 'r': 'relaxed', 's': 'strict' }; + if (r.PolicyPublished.ADKIM !== '') { + policy.push('dkim ' + (alignments[r.PolicyPublished.ADKIM] || r.PolicyPublished.ADKIM)); + } + if (r.PolicyPublished.ASPF !== '') { + policy.push('spf ' + (alignments[r.PolicyPublished.ASPF] || r.PolicyPublished.ASPF)); + } + if (r.PolicyPublished.Policy !== '') { + policy.push('policy ' + r.PolicyPublished.Policy); + } + if (r.PolicyPublished.SubdomainPolicy !== '' && r.PolicyPublished.SubdomainPolicy !== r.PolicyPublished.Policy) { + policy.push('subdomain ' + r.PolicyPublished.SubdomainPolicy); + } + if (r.PolicyPublished.Percentage !== 100) { + policy.push('' + r.PolicyPublished.Percentage + '%'); + } + const sourceIP = (ip) => { + const r = dom.span(ip, attr.title('Click to do a reverse lookup of the IP.'), style({ cursor: 'pointer' }), async function click(e) { + e.preventDefault(); + try { + const rev = await client.LookupIP(ip); + r.innerText = ip + '\n' + (rev.Hostnames || []).join('\n'); + } + catch (err) { + r.innerText = ip + '\nerror: ' + errmsg(err); + } + }); + return r; + }; + let authResults = 0; + for (const record of (r.Records || [])) { + authResults += (record.AuthResults.DKIM || []).length; + authResults += (record.AuthResults.SPF || []).length; + } + const reportRowspan = attr.rowspan('' + authResults); + return (r.Records || []).map((record, recordIndex) => { + const row = record.Row; + const pol = row.PolicyEvaluated; + const ids = record.Identifiers; + const dkims = record.AuthResults.DKIM || []; + const spfs = record.AuthResults.SPF || []; + const recordRowspan = attr.rowspan('' + (dkims.length + spfs.length)); + const valignTop = style({ verticalAlign: 'top' }); + const dmarcStatuses = { + none: 'DMARC checks or were not applied. This does not mean these messages are definitely not spam though, and they may have been rejected based on other checks, such as reputation or content-based filters.', + quarantine: 'DMARC policy is to mark message as spam.', + reject: 'DMARC policy is to reject the message during SMTP delivery.', + }; + const rows = []; + const addRow = (...last) => { + const tr = dom.tr(recordIndex > 0 || rows.length > 0 ? [] : [ + dom.td(reportRowspan, valignTop, dom.a('' + r.ID, attr.href('#domains/' + d + '/dmarc/' + r.ID), attr.title('View raw report.'))), + dom.td(reportRowspan, valignTop, m.OrgName, attr.title('Email: ' + m.Email + ', ReportID: ' + m.ReportID)), + dom.td(reportRowspan, valignTop, period(new Date(m.DateRange.Begin * 1000), new Date(m.DateRange.End * 1000)), m.Errors && m.Errors.length ? dom.span('errors', attr.title(m.Errors.join('; '))) : []), + dom.td(reportRowspan, valignTop, policy.join(', ')), + ], rows.length > 0 ? [] : [ + dom.td(recordRowspan, valignTop, sourceIP(row.SourceIP)), + dom.td(recordRowspan, valignTop, '' + row.Count), + dom.td(recordRowspan, valignTop, dom.span(pol.Disposition === 'none' ? 'none' : box(red, pol.Disposition), attr.title(pol.Disposition + ': ' + dmarcStatuses[pol.Disposition])), (pol.Reasons || []).map(reason => [dom.br(), dom.span(reason.Type + (reason.Comment ? ' (' + reason.Comment + ')' : ''), attr.title('Policy was overridden by remote mail server for this reasons.'))])), + dom.td(recordRowspan, valignTop, pol.DKIM === 'pass' ? 'pass' : box(yellow, dom.span(pol.DKIM, attr.title('No or no valid DKIM-signature is present that is "aligned" with the domain name.')))), + dom.td(recordRowspan, valignTop, pol.SPF === 'pass' ? 'pass' : box(yellow, dom.span(pol.SPF, attr.title('No SPF policy was found, or IP is not allowed by policy, or domain name is not "aligned" with the domain name.')))), + dom.td(recordRowspan, valignTop, ids.EnvelopeTo), + dom.td(recordRowspan, valignTop, ids.EnvelopeFrom), + dom.td(recordRowspan, valignTop, ids.HeaderFrom), + ], dom.td(last)); + rows.push(tr); + }; + for (const dkim of dkims) { + const statuses = { + none: 'Message was not signed', + pass: 'Message was signed and signature was verified.', + fail: 'Message was signed, but signature was invalid.', + policy: 'Message was signed, but signature is not accepted by policy.', + neutral: 'Message was signed, but the signature contains an error or could not be processed. This status is also used for errors not covered by other statuses.', + temperror: 'Message could not be verified. E.g. because of DNS resolve error. A later attempt may succeed. A missing DNS record is treated as temporary error, a new key may not have propagated through DNS shortly after it was taken into use.', + permerror: 'Message cannot be verified. E.g. when a required header field is absent or for invalid (combination of) parameters. We typically set this if a DNS record does not allow the signature, e.g. due to algorithm mismatch or expiry.', + }; + addRow('dkim: ', dom.span((dkim.Result === 'none' || dkim.Result === 'pass') ? dkim.Result : box(yellow, dkim.Result), attr.title((dkim.HumanResult ? 'additional information: ' + dkim.HumanResult + ';\n' : '') + dkim.Result + ': ' + (statuses[dkim.Result] || 'invalid status'))), !dkim.Selector ? [] : [ + ', ', + dom.span(dkim.Selector, attr.title('Selector, the DKIM record is at "._domainkey.".' + (dkim.Domain === d ? '' : ';\ndomain: ' + dkim.Domain))), + ]); + } + for (const spf of spfs) { + const statuses = { + none: 'No SPF policy found.', + neutral: 'Policy states nothing about IP, typically due to "?" qualifier in SPF record.', + pass: 'IP is authorized.', + fail: 'IP is explicitly not authorized, due to "-" qualifier in SPF record.', + softfail: 'Weak statement that IP is probably not authorized, "~" qualifier in SPF record.', + temperror: 'Trying again later may succeed, e.g. for temporary DNS lookup error.', + permerror: 'Error requiring some intervention to correct. E.g. invalid DNS record.', + }; + addRow('spf: ', dom.span((spf.Result === 'none' || spf.Result === 'neutral' || spf.Result === 'pass') ? spf.Result : box(yellow, spf.Result), attr.title(spf.Result + ': ' + (statuses[spf.Result] || 'invalid status'))), ', ', dom.span(spf.Scope, attr.title('scopes:\nhelo: "SMTP HELO"\nmfrom: SMTP "MAIL FROM"')), ' ', dom.span(spf.Domain)); + } + return rows; + }); + })))); +}; +const domainDMARCReport = async (d, reportID) => { + const [report, dnsdomain] = await Promise.all([ + client.DMARCReportID(d, reportID), + client.Domain(d), + ]); + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('Domain ' + domainString(dnsdomain), '#domains/' + d), crumblink('DMARC aggregate reports', '#domains/' + d + '/dmarc'), 'Report ' + reportID), dom.p('Below is the raw report as received from the remote mail server.'), dom.div(dom._class('literal'), JSON.stringify(report, null, '\t'))); +}; +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.'))); +}; +const tlsrptResults = async () => { + const [results, suppressAddresses] = await Promise.all([ + client.TLSRPTResults(), + client.TLSRPTSuppressList(), + ]); + // todo: add a view where results are grouped by policy domain+dayutc. now each recipient domain gets a row. + let fieldset; + let reportingAddress; + let until; + let comment; + const nextmonth = new Date(new Date().getTime() + 31 * 24 * 3600 * 1000); + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), 'Results'), dom.p('Messages are delivered with SMTP with TLS using STARTTLS if supported and/or required by the recipient domain\'s mail server. TLS connections may fail for various reasons, such as mismatching certificate host name, expired certificates or TLS protocol version/cipher suite incompatibilities. Statistics about successful connections and failed connections are tracked. Results can be tracked for recipient domains (for MTA-STS policies), and per MX host (for DANE). A domain/host can publish a TLSRPT DNS record with addresses that should receive TLS reports. Reports are sent every 24 hours. Not all results are enough reason to send a report, but if a report is sent all results are included. By default, reports are only sent if a report contains a connection failure. Sending reports about all-successful connections can be configured. Reports sent to recipient domains include the results for its MX hosts, and reports for an MX host reference the recipient domains.'), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Day (UTC)', attr.title('Day covering these results, a whole day from 00:00 UTC to 24:00 UTC.')), dom.th('Recipient domain', attr.title('Domain of addressee. For delivery to a recipient, the recipient and policy domains will match for reporting on MTA-STS policies, but can also result in reports for hosts from the MX record of the recipient to report on DANE policies.')), dom.th('Policy domain', attr.title('Domain for TLSRPT policy, specifying URIs to which reports should be sent.')), dom.th('Host', attr.title('Whether policy domain is an (MX) host (for DANE), or a recipient domain (for MTA-STS).')), dom.th('Policies', attr.title('Policies found.')), dom.th('Success', attr.title('Total number of successful connections.')), dom.th('Failure', attr.title('Total number of failed connection attempts.')), dom.th('Failure details', attr.title('Total number of details about failures.')), dom.th('Send report', attr.title('Whether the current results may cause a report to be sent. To prevent report loops, reports are not sent for TLS connections used to deliver TLS or DMARC reports. Whether a report is eventually sent depends on more factors, such as whether the policy domain has a TLSRPT policy with reporting addresses, and whether TLS connection failures were registered (depending on configuration).')))), dom.tbody((results || []).sort((a, b) => { + if (a.DayUTC !== b.DayUTC) { + return a.DayUTC < b.DayUTC ? -1 : 1; + } + if (a.RecipientDomain !== b.RecipientDomain) { + return a.RecipientDomain < b.RecipientDomain ? -1 : 1; + } + return a.PolicyDomain < b.PolicyDomain ? -1 : 1; + }).map(r => { + let success = 0; + let failed = 0; + let failureDetails = 0; + (r.Results || []).forEach(result => { + success += result.Summary.TotalSuccessfulSessionCount; + failed += result.Summary.TotalFailureSessionCount; + failureDetails += (result.FailureDetails || []).length; + }); + const policyTypes = []; + for (const result of (r.Results || [])) { + const pt = result.Policy.Type; + if (!policyTypes.includes(pt)) { + policyTypes.push(pt); + } + } + return dom.tr(dom.td(r.DayUTC), dom.td(r.RecipientDomain), dom.td(dom.a(attr.href('#tlsrpt/results/' + (r.RecipientDomain === r.PolicyDomain ? 'rcptdom/' : 'host/') + r.PolicyDomain), r.PolicyDomain)), dom.td(r.IsHost ? '✓' : ''), dom.td(policyTypes.join(', ')), dom.td(style({ textAlign: 'right' }), '' + success), dom.td(style({ textAlign: 'right' }), '' + failed), dom.td(style({ textAlign: 'right' }), '' + failureDetails), dom.td(style({ textAlign: 'right' }), r.SendReport ? '✓' : '')); + }), (results || []).length === 0 ? dom.tr(dom.td(attr.colspan('9'), 'No results.')) : [])), dom.br(), dom.br(), dom.h2('Suppressed reporting addresses'), dom.p('In practice, sending a TLS report to a reporting address can cause DSN to be sent back. Such addresses can be added to a suppress list for a period, to reduce noise in the postmaster mailbox.'), dom.form(async function submit(e) { + e.stopPropagation(); + e.preventDefault(); + try { + fieldset.disabled = true; + await client.TLSRPTSuppressAdd(reportingAddress.value, new Date(until.value), comment.value); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + fieldset.disabled = false; + } + window.location.reload(); // todo: add the address to the list, or only reload the list + }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Reporting address', dom.br(), reportingAddress = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Until', dom.br(), until = dom.input(attr.type('date'), attr.required(''), attr.value(nextmonth.getFullYear() + '-' + (1 + nextmonth.getMonth()) + '-' + nextmonth.getDate()))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Comment (optional)'), dom.br(), comment = dom.input()), ' ', dom.submitbutton('Add', attr.title('Outgoing reports to this reporting address will be suppressed until the end time.')))), dom.br(), dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Reporting address'), dom.th('Until'), dom.th('Comment'), dom.th('Action'))), dom.tbody((suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No suppressed reporting addresses.')) : [], (suppressAddresses || []).map(ba => dom.tr(dom.td(ba.ReportingAddress), dom.td(ba.Until.toISOString()), dom.td(ba.Comment), dom.td(dom.clickbutton('Remove', async function click(e) { + const target = e.target; + try { + target.disabled = true; + await client.TLSRPTSuppressRemove(ba.ID); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + target.disabled = false; + } + window.location.reload(); // todo: only reload the list + }), ' ', dom.clickbutton('Extend for 1 month', async function click(e) { + const target = e.target; + try { + target.disabled = true; + await client.TLSRPTSuppressExtend(ba.ID, new Date(new Date().getTime() + 31 * 24 * 3600 * 1000)); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + target.disabled = false; + } + window.location.reload(); // todo: only reload the list + }))))))); +}; +const tlsrptResultsPolicyDomain = async (isrcptdom, domain) => { + const [d, tlsresults] = await client.TLSRPTResultsDomain(isrcptdom, domain); + const recordPromise = client.LookupTLSRPTRecord(domain); + let recordBox; + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Results', '#tlsrpt/results'), (isrcptdom ? 'Recipient domain ' : 'Host ') + domainString(d)), dom.div(dom.clickbutton('Remove results', async function click(e) { + e.preventDefault(); + const target = e.target; + target.disabled = true; + try { + await client.TLSRPTRemoveResults(isrcptdom, domain, ''); + window.location.reload(); // todo: only clear the table? + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + } + finally { + target.disabled = false; + } + })), dom.br(), dom.div('Fetching TLSRPT DNS record...'), recordBox = dom.div(), dom.br(), dom.p('Below are the results per day and ' + (isrcptdom ? 'policy' : 'recipient') + ' domain that may be sent in a report.'), (tlsresults || []).map(tlsresult => [ + dom.h2(tlsresult.DayUTC, ' - ', dom.span(attr.title('Recipient domain, as used in SMTP MAIL TO, usually based on message To/Cc/Bcc.'), isrcptdom ? tlsresult.PolicyDomain : tlsresult.RecipientDomain)), + dom.p('Send report (if TLSRPT policy exists and has address): ' + (tlsresult.SendReport ? 'Yes' : 'No'), dom.br(), 'Report about (MX) host (instead of recipient domain): ' + (tlsresult.IsHost ? 'Yes' : 'No')), + dom.div(dom._class('literal'), JSON.stringify(tlsresult.Results, null, '\t')), + ])); + (async () => { + let txt = ''; + let error; + try { + let [_, xtxt, xerror] = await recordPromise; + txt = xtxt; + error = xerror; + } + catch (err) { + error = 'error: ' + errmsg(err); + } + const l = []; + if (txt) { + l.push(dom.div(dom._class('literal'), txt)); + } + if (error) { + l.push(box(red, error)); + } + dom._kids(recordBox, l); + })(); +}; +const tlsrptReports = async () => { + const end = new Date(); + const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); + const summaries = await client.TLSRPTSummaries(start, end, ''); + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), 'Reports'), dom.p('TLSRPT (TLS reporting) is a mechanism to request feedback from other mail servers about TLS connections to your mail server. If is typically used along with MTA-STS and/or DANE to enforce that SMTP connections are protected with TLS. Mail servers implementing TLSRPT will typically send a daily report with both successful and failed connection counts, including details about failures.'), renderTLSRPTSummaries(summaries || [])); +}; +const renderTLSRPTSummaries = (summaries) => { + return [ + dom.p('Below a summary of TLS reports for the past 30 days.'), + summaries.length === 0 ? dom.div(box(yellow, 'No domains with TLS reports.')) : + dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Policy domain', attr.title('Policy domain the report is about. The recipient domain for MTA-STS, the TLSA base domain for DANE.')), dom.th('Successes', attr.title('Number of successful SMTP STARTTLS sessions.')), dom.th('Failures', attr.title('Number of failed SMTP STARTTLS sessions.')), dom.th('Failure details', attr.title('Details about connection failures.')))), dom.tbody(summaries.map(r => dom.tr(dom.td(dom.a(attr.href('#tlsrpt/reports/' + domainName(r.PolicyDomain)), attr.title('See report details.'), domainName(r.PolicyDomain))), dom.td(style({ textAlign: 'right' }), '' + r.Success), dom.td(style({ textAlign: 'right' }), '' + r.Failure), dom.td(!r.ResultTypeCounts ? [] : Object.entries(r.ResultTypeCounts).map(kv => kv[0] + ': ' + kv[1]).join('; ')))))) + ]; +}; +const domainTLSRPT = async (d) => { + const end = new Date(); + const start = new Date(new Date().getTime() - 30 * 24 * 3600 * 1000); + const [records, dnsdomain] = await Promise.all([ + client.TLSReports(start, end, d), + client.ParseDomain(d), + ]); + const policyType = (policy) => { + let s = policy.Type; + if (s === 'sts') { + const mode = (policy.String || []).find(s => s.startsWith('mode:')); + if (mode) { + s += ': ' + mode.replace('mode:', '').trim(); + } + } + 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.') : + dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('Report', attr.colspan('3')), dom.th('Policy', attr.colspan('3')), dom.th('Failure Details', attr.colspan('8'))), dom.tr(dom.th('ID'), dom.th('From', attr.title('SMTP mail from from which we received the report.')), dom.th('Period (UTC)', attr.title('Period this reporting period is about. Mail servers are recommended to stick to whole UTC days.')), dom.th('Policy', attr.title('The policy applied, typically STSv1.')), dom.th('Successes', attr.title('Total number of successful TLS connections for policy.')), dom.th('Failures', attr.title('Total number of failed TLS connections for policy.')), dom.th('Result Type', attr.title('Type of failure.')), dom.th('Sending MTA', attr.title('IP of sending MTA.')), dom.th('Receiving MX Host'), dom.th('Receiving MX HELO'), dom.th('Receiving IP'), dom.th('Count', attr.title('Number of TLS connections that failed with these details.')), dom.th('More', attr.title('Optional additional information about the failure.')), dom.th('Code', attr.title('Optional API error code relating to the failure.')))), dom.tbody((records || []).map(record => { + const r = record.Report; + let nrows = 0; + (r.Policies || []).forEach(pr => nrows += (pr.FailureDetails || []).length || 1); + const reportRowSpan = attr.rowspan('' + nrows); + const valignTop = style({ verticalAlign: 'top' }); + const alignRight = style({ textAlign: 'right' }); + return (r.Policies || []).map((result, index) => { + const rows = []; + const details = result.FailureDetails || []; + const resultRowSpan = attr.rowspan('' + (details.length || 1)); + const addRow = (d, di) => { + const row = dom.tr(index > 0 || rows.length > 0 ? [] : [ + dom.td(reportRowSpan, valignTop, dom.a('' + record.ID, attr.href('#tlsrpt/reports/' + record.Domain + '/' + record.ID))), + dom.td(reportRowSpan, valignTop, r.OrganizationName || r.ContactInfo || record.MailFrom || '', attr.title('Organization: ' + r.OrganizationName + '; \nContact info: ' + r.ContactInfo + '; \nReport ID: ' + r.ReportID + '; \nMail from: ' + record.MailFrom)), + dom.td(reportRowSpan, valignTop, period(r.DateRange.Start, r.DateRange.End)), + ], di > 0 ? [] : [ + dom.td(resultRowSpan, valignTop, policyType(result.Policy), attr.title((result.Policy.String || []).join('\n'))), + dom.td(resultRowSpan, valignTop, alignRight, '' + result.Summary.TotalSuccessfulSessionCount), + dom.td(resultRowSpan, valignTop, alignRight, '' + result.Summary.TotalFailureSessionCount), + ], !d ? dom.td(attr.colspan('8')) : [ + dom.td(d.ResultType), + dom.td(d.SendingMTAIP), + dom.td(d.ReceivingMXHostname), + dom.td(d.ReceivingMXHelo), + dom.td(d.ReceivingIP), + dom.td(alignRight, '' + d.FailedSessionCount), + dom.td(d.AdditionalInformation), + dom.td(d.FailureReasonCode), + ]); + rows.push(row); + }; + let di = 0; + for (const d of details) { + addRow(d, di); + di++; + } + if (details.length === 0) { + addRow(undefined, 0); + } + return rows; + }); + })))); +}; +const domainTLSRPTID = async (d, reportID) => { + const [report, dnsdomain] = await Promise.all([ + client.TLSReportID(d, reportID), + client.ParseDomain(d), + ]); + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), crumblink('TLSRPT', '#tlsrpt'), crumblink('Reports', '#tlsrpt/reports'), crumblink('Domain ' + domainString(dnsdomain), '#tlsrpt/reports/' + d + ''), 'Report ' + reportID), dom.p('Below is the raw report as received from the remote mail server.'), dom.div(dom._class('literal'), JSON.stringify(report, null, '\t'))); +}; +const mtasts = async () => { + const policies = await client.MTASTSPolicies(); + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'MTA-STS policies'), dom.p("MTA-STS is a mechanism allowing email domains to publish a policy for using SMTP STARTTLS and TLS verification. See ", link('https://www.rfc-editor.org/rfc/rfc8461.html', 'RFC 8461'), '.'), dom.p("The SMTP protocol is unencrypted by default, though the SMTP STARTTLS command is typically used to enable TLS on a connection. However, MTA's using STARTTLS typically do not validate the TLS certificate. An MTA-STS policy can specify that validation of host name, non-expiration and webpki trust is required."), makeMTASTSTable(policies || [])); +}; +const formatMTASTSMX = (mx) => { + return mx.map(e => { + return (e.Wildcard ? '*.' : '') + e.Domain.ASCII; + }).join(', '); +}; +const makeMTASTSTable = (items) => { + if (items.length === 0) { + return dom.div('No data'); + } + // Elements: Field name in JSON, column name override, title for column name. + const keys = [ + ["LastUse", "", "Last time this policy was used."], + ["Domain", "Domain", "Domain this policy was retrieved from and this policy applies to."], + ["Backoff", "", "If true, a DNS record for MTA-STS exists, but a policy could not be fetched. This indicates a failure with MTA-STS."], + ["RecordID", "", "Unique ID for this policy. Each time a domain changes its policy, it must also change the record ID that is published in DNS to propagate the change."], + ["Version", "", "For valid MTA-STS policies, this must be 'STSv1'."], + ["Mode", "", "'enforce': TLS must be used and certificates must be validated; 'none': TLS and certificate validation is not required, typically only useful for removing once-used MTA-STS; 'testing': TLS should be used and certificated should be validated, but fallback to unverified TLS or plain text is allowed, but such cases must be reported"], + ["MX", "", "The MX hosts that are configured to do TLS. If TLS and validation is required, but an MX host is not on this list, delivery will not be attempted to that host."], + ["MaxAgeSeconds", "", "How long a policy can be cached and reused after it was fetched. Typically in the order of weeks."], + ["Extensions", "", "Free-form extensions in the MTA-STS policy."], + ["ValidEnd", "", "Until when this cached policy is valid, based on time the policy was fetched and the policy max age. Non-failure policies are automatically refreshed before they become invalid."], + ["LastUpdate", "", "Last time this policy was updated."], + ["Inserted", "", "Time when the policy was first inserted."], + ]; + const nowSecs = new Date().getTime() / 1000; + return dom.table(dom._class('hover'), dom.thead(dom.tr(keys.map(kt => dom.th(dom.span(attr.title(kt[2]), kt[1] || kt[0]))))), dom.tbody(items.map(e => dom.tr([ + age(e.LastUse, false, nowSecs), + e.Domain, + e.Backoff, + e.RecordID, + e.Version, + e.Mode, + formatMTASTSMX(e.MX || []), + e.MaxAgeSeconds, + e.Extensions, + age(e.ValidEnd, true, nowSecs), + age(e.LastUpdate, false, nowSecs), + age(e.Inserted, false, nowSecs), + ].map(v => dom.td(v === null ? [] : (v instanceof HTMLElement ? v : '' + v))))))); +}; +const dnsbl = async () => { + const ipZoneResults = await client.DNSBLStatus(); + const url = (ip) => { + return 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html'; + }; + 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 => { + const [ip, zoneResults] = ipZones; + return dom.li(link(url(ip), ip), !ipZones.length ? [] : dom.ul(Object.entries(zoneResults).sort().map(zoneResult => dom.li(zoneResult[0] + ': ', zoneResult[1] === 'pass' ? 'pass' : box(red, zoneResult[1]))))); + })), !Object.entries(ipZoneResults).length ? box(red, 'No IPs found.') : []); +}; +const queueList = async () => { + const [msgs, transports] = await Promise.all([ + client.QueueList(), + client.Transports(), + ]); + const nowSecs = new Date().getTime() / 1000; + dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'Queue'), (msgs || []).length === 0 ? 'Currently no messages in the queue.' : [ + dom.p('The messages below are currently in the queue.'), + // todo: sorting by address/timestamps/attempts. perhaps filtering. + dom.table(dom._class('hover'), dom.thead(dom.tr(dom.th('ID'), dom.th('Submitted'), dom.th('From'), dom.th('To'), dom.th('Size'), dom.th('Attempts'), dom.th('Next attempt'), dom.th('Last attempt'), dom.th('Last error'), dom.th('Require TLS'), dom.th('Transport/Retry'), dom.th('Remove'))), dom.tbody((msgs || []).map(m => { + let requiretlsFieldset; + let requiretls; + let transport; + return dom.tr(dom.td('' + m.ID), dom.td(age(new Date(m.Queued), false, nowSecs)), dom.td(m.SenderLocalpart + "@" + ipdomainString(m.SenderDomain)), // todo: escaping of localpart + dom.td(m.RecipientLocalpart + "@" + ipdomainString(m.RecipientDomain)), // todo: escaping of localpart + dom.td(formatSize(m.Size)), dom.td('' + m.Attempts), dom.td(age(new Date(m.NextAttempt), true, nowSecs)), dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'), dom.td(m.LastError || '-'), dom.td(dom.form(requiretlsFieldset = dom.fieldset(requiretls = dom.select(attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'), dom.option('Default', attr.value('')), dom.option('With RequireTLS', attr.value('yes'), m.RequireTLS === true ? attr.selected('') : []), dom.option('Fallback to insecure', attr.value('no'), m.RequireTLS === false ? attr.selected('') : [])), ' ', dom.submitbutton('Save')), async function submit(e) { + e.preventDefault(); + try { + requiretlsFieldset.disabled = true; + await client.QueueSaveRequireTLS(m.ID, requiretls.value === '' ? null : requiretls.value === 'yes'); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + requiretlsFieldset.disabled = false; + } + })), dom.td(dom.form(transport = dom.select(attr.title('Transport to use for delivery attempts. The default is direct delivery, connecting to the MX hosts of the domain.'), dom.option('(default)', attr.value('')), Object.keys(transports || []).sort().map(t => dom.option(t, m.Transport === t ? attr.checked('') : []))), ' ', dom.submitbutton('Retry now'), async function submit(e) { + e.preventDefault(); + const target = e.target; + try { + target.disabled = true; + await client.QueueKick(m.ID, transport.value); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + target.disabled = false; + } + window.location.reload(); // todo: only refresh the list + })), dom.td(dom.clickbutton('Remove', async function click(e) { + e.preventDefault(); + if (!window.confirm('Are you sure you want to remove this message? It will be removed completely.')) { + return; + } + const target = e.target; + try { + target.disabled = true; + await client.QueueDrop(m.ID); + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + return; + } + finally { + target.disabled = false; + } + window.location.reload(); // todo: only refresh the list + }))); + }))), + ]); +}; +const webserver = async () => { + let conf = await client.WebserverConfig(); + // We disable this while saving the form. + let fieldset; + // Keep track of redirects. Rows are objects that hold both the DOM and allows + // retrieving the visible (modified) data to construct a config for saving. + let redirectRows = []; + let redirectsTbody; + let noredirect; + // Make a new redirect rows, adding it to the list. The caller typically uses this + // while building the DOM, the element is added because this object has it as + // "root" field. + const redirectRow = (t) => { + let row; + let from; + let to; + const root = dom.tr(dom.td(from = dom.input(attr.required(''), attr.value(domainName(t[0])))), dom.td(to = dom.input(attr.required(''), attr.value(domainName(t[1])))), dom.td(dom.clickbutton('Remove', function click() { + redirectRows = redirectRows.filter(r => r !== row); + row.root.remove(); + noredirect.style.display = redirectRows.length ? 'none' : ''; + }))); + // "get" is the common function to retrieve the data from an object with a root field as DOM element. + const get = () => [from.value, to.value]; + row = { root: root, from: from, to: to, get: get }; + redirectRows.push(row); + return row; + }; + // Reusable component for managing headers. Just a table with a header key and + // value. We can remove existing rows, and add new rows, and edit existing. + const makeHeaders = (h) => { + let view; + let rows = []; + let tbody; + let norow; + const headerRow = (k, v) => { + let row; + let key; + let value; + const root = dom.tr(dom.td(key = dom.input(attr.required(''), attr.value(k))), dom.td(value = dom.input(attr.required(''), attr.value(v))), dom.td(dom.clickbutton('Remove', function click() { + rows = rows.filter(x => x !== row); + row.root.remove(); + norow.style.display = rows.length ? 'none' : ''; + }))); + const get = () => [row.key.value, row.value.value]; + row = { root: root, key: key, value: value, get: get }; + rows.push(row); + return row; + }; + const add = dom.clickbutton('Add', function click() { + const row = headerRow('', ''); + tbody.appendChild(row.root); + norow.style.display = rows.length ? 'none' : ''; + }); + const root = dom.table(tbody = dom.tbody(Object.entries(h).sort().map(t => headerRow(t[0], t[1])), norow = dom.tr(style({ display: rows.length ? 'none' : '' }), dom.td(attr.colspan('3'), 'None added.')))); + const get = () => Object.fromEntries(rows.map(row => row.get())); + view = { root: root, add: add, get: get }; + return view; + }; + // Similar to redirects, but for web handlers. + let handlerRows = []; + let handlersTbody; + let nohandler; + // Make a handler row. This is more complicated, since it can be one of the three + // types (static, redirect, forward), and can change between those types. + const handlerRow = (wh) => { + let row; // Shared between the handler types. + let handlerType; + let staticView = null; + let redirectView = null; + let forwardView = null; + let moveButtons; + const makeWebStatic = (ws) => { + let view; + let stripPrefix; + let rootPath; + let listFiles; + let continueNotFound; + let responseHeaders = makeHeaders(ws.ResponseHeaders || {}); + const get = () => { + return { + StripPrefix: stripPrefix.value, + Root: rootPath.value, + ListFiles: listFiles.checked, + ContinueNotFound: continueNotFound.checked, + ResponseHeaders: responseHeaders.get(), + }; + }; + const root = dom.table(dom.tr(dom.td('Type'), dom.td('StripPrefix', attr.title('Path to strip from the request URL before evaluating to a local path. If the requested URL path does not start with this prefix and ContinueNotFound it is considered non-matching and next WebHandlers are tried. If ContinueNotFound is not set, a file not found (404) is returned in that case.')), dom.td('Root', attr.title('Directory to serve files from for this handler. Keep in mind that relative paths are relative to the working directory of mox.')), dom.td('ListFiles', attr.title('If set, and a directory is requested, and no index.html is present that can be served, a file listing is returned. Results in 403 if ListFiles is not set. If a directory is requested and the URL does not end with a slash, the response is a redirect to the path with trailing slash.')), dom.td('ContinueNotFound', attr.title("If a requested URL does not exist, don't return a file not found (404) response, but consider this handler non-matching and continue attempts to serve with later WebHandlers, which may be a reverse proxy generating dynamic content, possibly even writing a static file for a next request to serve statically. If ContinueNotFound is set, HTTP requests other than GET and HEAD do not match. This mechanism can be used to implement the equivalent of 'try_files' in other webservers.")), dom.td(dom.span('Response headers', attr.title('Headers to add to the response. Useful for cache-control, content-type, etc. By default, Content-Type headers are automatically added for recognized file types, unless added explicitly through this setting. For directory listings, a content-type header is skipped.')), ' ', responseHeaders.add)), dom.tr(dom.td(dom.select(attr.required(''), dom.option('Static', attr.selected('')), dom.option('Redirect'), dom.option('Forward'), function change(e) { + makeType(e.target.value); + })), dom.td(stripPrefix = dom.input(attr.value(ws.StripPrefix || ''))), dom.td(rootPath = dom.input(attr.required(''), attr.placeholder('web/...'), attr.value(ws.Root || ''))), dom.td(listFiles = dom.input(attr.type('checkbox'), ws.ListFiles ? attr.checked('') : [])), dom.td(continueNotFound = dom.input(attr.type('checkbox'), ws.ContinueNotFound ? attr.checked('') : [])), dom.td(responseHeaders))); + view = { root: root, get: get }; + return view; + }; + const makeWebRedirect = (wr) => { + let view; + let baseURL; + let origPathRegexp; + let replacePath; + let statusCode; + const get = () => { + return { + BaseURL: baseURL.value, + OrigPathRegexp: origPathRegexp.value, + ReplacePath: replacePath.value, + StatusCode: statusCode.value ? parseInt(statusCode.value) : 0, + }; + }; + const root = dom.table(dom.tr(dom.td('Type'), dom.td('BaseURL', attr.title('Base URL to redirect to. The path must be empty and will be replaced, either by the request URL path, or by OrigPathRegexp/ReplacePath. Scheme, host, port and fragment stay intact, and query strings are combined. If empty, the response redirects to a different path through OrigPathRegexp and ReplacePath, which must then be set. Use a URL without scheme to redirect without changing the protocol, e.g. //newdomain/. If a redirect would send a request to a URL with the same scheme, host and path, the WebRedirect does not match so a next WebHandler can be tried. This can be used to redirect all plain http traffic to https.')), dom.td('OrigPathRegexp', attr.title('Regular expression for matching path. If set and path does not match, a 404 is returned. The HTTP path used for matching always starts with a slash.')), dom.td('ReplacePath', attr.title("Replacement path for destination URL based on OrigPathRegexp. Implemented with Go's Regexp.ReplaceAllString: $1 is replaced with the text of the first submatch, etc. If both OrigPathRegexp and ReplacePath are empty, BaseURL must be set and all paths are redirected unaltered.")), dom.td('StatusCode', attr.title('Status code to use in redirect, e.g. 307. By default, a permanent redirect (308) is returned.'))), dom.tr(dom.td(dom.select(attr.required(''), dom.option('Static'), dom.option('Redirect', attr.selected('')), dom.option('Forward'), function change(e) { + makeType(e.target.value); + })), dom.td(baseURL = dom.input(attr.placeholder('empty or https://target/path?q=1#frag or //target/...'), attr.value(wr.BaseURL || ''))), dom.td(origPathRegexp = dom.input(attr.placeholder('^/old/(.*)'), attr.value(wr.OrigPathRegexp || ''))), dom.td(replacePath = dom.input(attr.placeholder('/new/$1'), attr.value(wr.ReplacePath || ''))), dom.td(statusCode = dom.input(style({ width: '4em' }), attr.type('number'), attr.value(wr.StatusCode ? '' + wr.StatusCode : ''), attr.min('300'), attr.max('399'))))); + view = { root: root, get: get }; + return view; + }; + const makeWebForward = (wf) => { + let view; + let stripPath; + let url; + let responseHeaders = makeHeaders(wf.ResponseHeaders || {}); + const get = () => { + return { + StripPath: stripPath.checked, + URL: url.value, + ResponseHeaders: responseHeaders.get(), + }; + }; + const root = dom.table(dom.tr(dom.td('Type'), dom.td('StripPath', attr.title('Strip the matching WebHandler path from the WebHandler before forwarding the request.')), dom.td('URL', attr.title("URL to forward HTTP requests to, e.g. http://127.0.0.1:8123/base. If StripPath is false the full request path is added to the URL. Host headers are sent unmodified. New X-Forwarded-{For,Host,Proto} headers are set. Any query string in the URL is ignored. Requests are made using Go's net/http.DefaultTransport that takes environment variables HTTP_PROXY and HTTPS_PROXY into account. Websocket connections are forwarded and data is copied between client and backend without looking at the framing. The websocket 'version' and 'key'/'accept' headers are verified during the handshake, but other websocket headers, including 'origin', 'protocol' and 'extensions' headers, are not inspected and the backend is responsible for verifying/interpreting them.")), dom.td(dom.span('Response headers', attr.title('Headers to add to the response. Useful for adding security- and cache-related headers.')), ' ', responseHeaders.add)), dom.tr(dom.td(dom.select(attr.required(''), dom.option('Static'), dom.option('Redirect'), dom.option('Forward', attr.selected('')), function change(e) { + makeType(e.target.value); + })), dom.td(stripPath = dom.input(attr.type('checkbox'), wf.StripPath || wf.StripPath === undefined ? attr.checked('') : [])), dom.td(url = dom.input(attr.required(''), attr.placeholder('http://127.0.0.1:8888'), attr.value(wf.URL || ''))), dom.td(responseHeaders))); + view = { root: root, get: get }; + return view; + }; + let logName; + let domain; + let pathRegexp; + let toHTTPS; + let compress; + let details; + const detailsRoot = (root) => { + details.replaceWith(root); + details = root; + }; + // Transform the input fields to match the type of WebHandler. + const makeType = (s) => { + if (s === 'Static') { + staticView = makeWebStatic(wh.WebStatic || { + StripPrefix: '', + Root: '', + ListFiles: false, + ContinueNotFound: false, + ResponseHeaders: {}, + }); + detailsRoot(staticView.root); + } + else if (s === 'Redirect') { + redirectView = makeWebRedirect(wh.WebRedirect || { + BaseURL: '', + OrigPathRegexp: '', + ReplacePath: '', + StatusCode: 0, + }); + detailsRoot(redirectView.root); + } + else if (s === 'Forward') { + forwardView = makeWebForward(wh.WebForward || { + StripPath: false, + URL: '', + ResponseHeaders: {}, + }); + detailsRoot(forwardView.root); + } + else { + throw new Error('unknown handler type'); + } + handlerType = s; + }; + // Remove row from oindex, insert it in nindex. Both in handlerRows and in the DOM. + const moveHandler = (row, oindex, nindex) => { + row.root.remove(); + handlersTbody.insertBefore(row.root, handlersTbody.children[nindex]); + handlerRows.splice(oindex, 1); + handlerRows.splice(nindex, 0, row); + }; + // Row that starts starts with two tables: one for the fields all WebHandlers have + // (in common). And one for the details, i.e. WebStatic, WebRedirect, WebForward. + const root = dom.tr(dom.td(dom.table(dom.tr(dom.td('LogName', attr.title('Name used during logging for requests matching this handler. If empty, the index of the handler in the list is used.')), dom.td('Domain', attr.title('Request must be for this domain to match this handler.')), dom.td('Path Regexp', attr.title('Request must match this path regular expression to match this handler. Must start with with a ^.')), dom.td('To HTTPS', attr.title('Redirect plain HTTP (non-TLS) requests to HTTPS.')), dom.td('Compress', attr.title('Transparently compress responses (currently with gzip) if the client supports it, the status is 200 OK, no Content-Encoding is set on the response yet and the Content-Type of the response hints that the data is compressible (text/..., specific application/... and .../...+json and .../...+xml). For static files only, a cache with compressed files is kept.'))), dom.tr(dom.td(logName = dom.input(attr.value(wh.LogName || ''))), dom.td(domain = dom.input(attr.required(''), attr.placeholder('example.org'), attr.value(domainName(wh.DNSDomain)))), dom.td(pathRegexp = dom.input(attr.required(''), attr.placeholder('^/'), attr.value(wh.PathRegexp || ''))), dom.td(toHTTPS = dom.input(attr.type('checkbox'), attr.title('Redirect plain HTTP (non-TLS) requests to HTTPS'), !wh.DontRedirectPlainHTTP ? attr.checked('') : [])), dom.td(compress = dom.input(attr.type('checkbox'), attr.title('Transparently compress responses.'), wh.Compress ? attr.checked('') : [])))), + // Replaced with a call to makeType, below (and later when switching types). + details = dom.table()), dom.td(dom.td(dom.clickbutton('Remove', function click() { + handlerRows = handlerRows.filter(r => r !== row); + row.root.remove(); + nohandler.style.display = handlerRows.length ? 'none' : ''; + }), ' ', + // We show/hide the buttons to move when clicking the Move button. + moveButtons = dom.span(style({ display: 'none' }), dom.clickbutton('↑↑', attr.title('Move to top.'), function click() { + const index = handlerRows.findIndex(r => r === row); + if (index > 0) { + moveHandler(row, index, 0); + } + }), ' ', dom.clickbutton('↑', attr.title('Move one up.'), function click() { + const index = handlerRows.findIndex(r => r === row); + if (index > 0) { + moveHandler(row, index, index - 1); + } + }), ' ', dom.clickbutton('↓', attr.title('Move one down.'), function click() { + const index = handlerRows.findIndex(r => r === row); + if (index + 1 < handlerRows.length) { + moveHandler(row, index, index + 1); + } + }), ' ', dom.clickbutton('↓↓', attr.title('Move to bottom.'), function click() { + const index = handlerRows.findIndex(r => r === row); + if (index + 1 < handlerRows.length) { + moveHandler(row, index, handlerRows.length - 1); + } + }))))); + // Final "get" that returns a WebHandler that reflects the UI. + const get = () => { + const wh = { + LogName: logName.value, + Domain: domain.value, + PathRegexp: pathRegexp.value, + DontRedirectPlainHTTP: !toHTTPS.checked, + Compress: compress.checked, + Name: '', + DNSDomain: { ASCII: '', Unicode: '' }, + }; + if (handlerType === 'Static' && staticView != null) { + wh.WebStatic = staticView.get(); + } + else if (handlerType === 'Redirect' && redirectView !== null) { + wh.WebRedirect = redirectView.get(); + } + else if (handlerType === 'Forward' && forwardView !== null) { + wh.WebForward = forwardView.get(); + } + else { + throw new Error('unknown WebHandler type'); + } + return wh; + }; + // Initialize one of the Web* types. + if (wh.WebStatic) { + handlerType = 'Static'; + } + else if (wh.WebRedirect) { + handlerType = 'Redirect'; + } + else if (wh.WebForward) { + handlerType = 'Forward'; + } + else { + throw new Error('unknown WebHandler type'); + } + makeType(handlerType); + row = { root: root, moveButtons: moveButtons, get: get }; + handlerRows.push(row); + return row; + }; + // Return webserver config to store. + const gatherConf = () => { + return { + WebDomainRedirects: redirectRows.map(row => row.get()), + WebHandlers: handlerRows.map(row => row.get()), + }; + }; + // Add and move buttons, both above and below the table for quick access, hence a function. + const handlerActions = () => { + return [ + 'Action ', + dom.clickbutton('Add', function click() { + // New WebHandler added as WebForward. Good chance this is what the user wants. And + // it has the least fields. (; + const nwh = { + LogName: '', + Domain: '', + PathRegexp: '^/', + DontRedirectPlainHTTP: false, + Compress: false, + WebForward: { + StripPath: true, + URL: '', + }, + Name: '', + DNSDomain: { ASCII: '', Unicode: '' }, + }; + const row = handlerRow(nwh); + handlersTbody.appendChild(row.root); + nohandler.style.display = handlerRows.length ? 'none' : ''; + }), + ' ', + dom.clickbutton('Move', function click() { + for (const row of handlerRows) { + row.moveButtons.style.display = row.moveButtons.style.display === 'none' ? '' : 'none'; + } + }), + ]; + }; + 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() { + const row = redirectRow([{ ASCII: '', Unicode: '' }, { ASCII: '', Unicode: '' }]); + redirectsTbody.appendChild(row.root); + noredirect.style.display = redirectRows.length ? 'none' : ''; + })))), redirectsTbody = dom.tbody((conf.WebDNSDomainRedirects || []).sort().map(t => redirectRow([t[0], t[1]])), noredirect = dom.tr(style({ display: redirectRows.length ? 'none' : '' }), dom.td(attr.colspan('3'), 'No redirects.')))), dom.br(), dom.h2('Handlers', attr.title('Corresponds with WebHandlers in domains.conf')), dom.p('Each incoming request is check against these handlers, in order. The first matching handler serves the request. Don\'t forget to save after making a change.'), dom.table(dom._class('long'), dom.thead(dom.tr(dom.th(), dom.th(handlerActions()))), handlersTbody = dom.tbody((conf.WebHandlers || []).map(wh => handlerRow(wh)), nohandler = dom.tr(style({ display: handlerRows.length ? 'none' : '' }), dom.td(attr.colspan('2'), 'No handlers.'))), dom.tfoot(dom.tr(dom.th(), dom.th(handlerActions())))), dom.br(), dom.submitbutton('Save', attr.title('Save config. If the configuration has changed since this page was loaded, an error will be returned. After saving, the changes take effect immediately.'))), async function submit(e) { + e.preventDefault(); + e.stopPropagation(); + fieldset.disabled = true; + try { + const newConf = gatherConf(); + const savedConf = await client.WebserverConfigSave(conf, newConf); + conf = savedConf; + } + catch (err) { + console.log({ err }); + window.alert('Error: ' + errmsg(err)); + } + finally { + fieldset.disabled = false; + } + })); +}; +const init = async () => { + let curhash; + const hashChange = async () => { + if (curhash === window.location.hash) { + return; + } + let h = decodeURIComponent(window.location.hash); + if (h !== '' && h.substring(0, 1) == '#') { + h = h.substring(1); + } + const t = h.split('/'); + page.classList.add('loading'); + try { + if (h == '') { + await index(); + } + else if (h === 'config') { + await config(); + } + else if (h === 'loglevels') { + await loglevels(); + } + else if (h === 'accounts') { + await accounts(); + } + else if (t[0] === 'accounts' && t.length === 2) { + await account(t[1]); + } + else if (t[0] === 'domains' && t.length === 2) { + await domain(t[1]); + } + else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dmarc') { + await domainDMARC(t[1]); + } + else if (t[0] === 'domains' && t.length === 4 && t[2] === 'dmarc' && parseInt(t[3])) { + await domainDMARCReport(t[1], parseInt(t[3])); + } + else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnscheck') { + await domainDNSCheck(t[1]); + } + else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnsrecords') { + await domainDNSRecords(t[1]); + } + else if (h === 'queue') { + await queueList(); + } + else if (h === 'tlsrpt') { + await tlsrptIndex(); + } + else if (h === 'tlsrpt/reports') { + await tlsrptReports(); + } + else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 3) { + await domainTLSRPT(t[2]); + } + else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 4 && parseInt(t[3])) { + await domainTLSRPTID(t[2], parseInt(t[3])); + } + else if (h === 'tlsrpt/results') { + await tlsrptResults(); + } + else if (t[0] == 'tlsrpt' && t[1] == 'results' && (t[2] === 'rcptdom' || t[2] == 'host') && t.length === 4) { + await tlsrptResultsPolicyDomain(t[2] === 'rcptdom', t[3]); + } + else if (h === 'dmarc') { + await dmarcIndex(); + } + else if (h === 'dmarc/reports') { + await dmarcReports(); + } + else if (h === 'dmarc/evaluations') { + await dmarcEvaluations(); + } + else if (t[0] == 'dmarc' && t[1] == 'evaluations' && t.length === 3) { + await dmarcEvaluationsDomain(t[2]); + } + else if (h === 'mtasts') { + await mtasts(); + } + else if (h === 'dnsbl') { + await dnsbl(); + } + else if (h === 'webserver') { + await webserver(); + } + else { + dom._kids(page, 'page not found'); + } + } + catch (err) { + console.log('error', err); + window.alert('Error: ' + errmsg(err)); + curhash = window.location.hash; + return; + } + curhash = window.location.hash; + page.classList.remove('loading'); + }; + window.addEventListener('hashchange', hashChange); + hashChange(); +}; +window.addEventListener('load', init); diff --git a/webadmin/admin.ts b/webadmin/admin.ts new file mode 100644 index 0000000..b7f0773 --- /dev/null +++ b/webadmin/admin.ts @@ -0,0 +1,2982 @@ +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. + +// From HTML. +declare let page: HTMLElement +declare let moxversion: string + +const client = new api.Client() + +const green = '#1dea20' +const yellow = '#ffe400' +const red = '#ff7443' +const blue = '#8bc8ff' + +const link = (href: string, anchorOpt?: string) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), anchorOpt || href) + +const crumblink = (text: string, link: string) => dom.a(text, attr.href(link)) +const crumbs = (...l: ElemArg[]) => [dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])), dom.br()] + +const errmsg = (err: unknown) => ''+((err as any).message || '(no error message)') + +const footer = dom.div( + style({marginTop: '6ex', opacity: 0.75}), + link('https://github.com/mjl-/mox', 'mox'), + ' ', + moxversion, +) + +const age = (date: Date, future: boolean, nowSecs: number) => { + if (!nowSecs) { + nowSecs = new Date().getTime()/1000 + } + let t = nowSecs - date.getTime()/1000 + let negative = false + if (t < 0) { + negative = true + t = -t + } + const minute = 60 + const hour = 60*minute + const day = 24*hour + const month = 30*day + const year = 365*day + const periods = [year, month, day, hour, minute, 1] + const suffix = ['y', 'm', 'd', 'h', 'mins', 's'] + let l: string[] = [] + for (let i = 0; i < periods.length; i++) { + const p = periods[i] + if (t >= 2*p || i == periods.length-1) { + const n = Math.floor(t/p) + l.push('' + n + suffix[i]) + t -= n*p + if (l.length >= 2) { + break + } + } + } + let s = l.join(' ') + if (!future || !negative) { + s += ' ago' + } + return dom.span(attr.title(date.toString()), s) +} + +const domainName = (d: api.Domain) => { + return d.Unicode || d.ASCII +} + +const domainString = (d: api.Domain) => { + if (d.Unicode) { + return d.Unicode+" ("+d.ASCII+")" + } + return d.ASCII +} + +// IP is encoded as base64 bytes, either 4 or 16. +// It's a bit silly to encode this in JS, but it's convenient to simply pass on the +// net.IP bytes from the backend. +const formatIP = (s: string) => { + const buf = window.atob(s) + const bytes = Uint8Array.from(buf, (m) => m.codePointAt(0) || 0) + if (bytes.length === 4 || isIPv4MappedIPv6(bytes)) { + // Format last 4 bytes as IPv4 address.. + return [bytes.at(-4), bytes.at(-3), bytes.at(-2), bytes.at(-1)].join('.') + } + return formatIPv6(bytes) +} + +// See if b is of the form ::ffff:x.x.x.x +const v4v6Prefix = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff]) +const isIPv4MappedIPv6 = (b: Uint8Array) => { + if (b.length !== 16) { + return false + } + for (let i = 0; i < v4v6Prefix.length; i++) { + if (b[i] !== v4v6Prefix[i]) { + return false + } + } + return true +} + +const formatIPv6 = (b: Uint8Array) => { + const hexchars = "0123456789abcdef" + const hex = (v: number, skipzero: boolean) => (skipzero && ((v>>4) & 0xf) === 0 ? '' : hexchars[(v>>4) & 0xf]) + hexchars[v&0xf] + + let zeroStart = 0, zeroEnd = 0 + + // Find largest run of zeroes. + let i = 0 + while (i < 16) { + let j = i + while (j < 16 && b[j] === 0) { + j++ + } + if (j-i > 2 && j-i > zeroEnd-zeroStart) { + zeroStart = i + zeroEnd = j + i = j + } else if (j > i) { + i = j + } else { + i++ + } + } + + let s = '' + for (let i = 0; i < 16; i++) { + if (i === zeroStart) { + s += '::' + } else if (i < zeroStart || i >= zeroEnd) { + if (i > 0 && i % 2 === 0 && !s.endsWith(':')) { + s += ':' + } + if (i % 2 === 1 || b[i] !== 0) { + s += hex(b[i], s === '' || s.endsWith(':')) + } + } + } + return s +} + +const ipdomainString = (ipd: api.IPDomain) => { + if (ipd.IP !== '') { + return formatIP(ipd.IP) + } + return domainString(ipd.Domain) +} + +const formatSize = (n: number) => { + if (n > 10*1024*1024) { + return Math.round(n/(1024*1024)) + ' mb' + } else if (n > 500) { + return Math.round(n/1024) + ' kb' + } + return n + ' bytes' +} + +const index = async () => { + const [domains, queueSize, checkUpdatesEnabled] = await Promise.all([ + client.Domains(), + client.QueueSize(), + client.CheckUpdatesEnabled(), + ]) + + let fieldset: HTMLFieldSetElement + let domain: HTMLInputElement + let account: HTMLInputElement + let localpart: HTMLInputElement + + 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.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: SubmitEvent) { + e.preventDefault() + e.stopPropagation() + fieldset.disabled = true + try { + await client.DomainAdd(domain.value, account.value, localpart.value) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + fieldset.disabled = false + } + window.location.hash = '#domains/' + domain.value + }, + fieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + 'Domain', + dom.br(), + domain=dom.input(attr.required('')), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + 'Postmaster/reporting account', + dom.br(), + account=dom.input(attr.required('')), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + dom.span('Localpart (optional)', attr.title('Must be set if and only if account does not yet exist. The localpart for the user of this domain. E.g. postmaster.')), + dom.br(), + localpart=dom.input(), + ), + ' ', + dom.submitbutton('Add domain', attr.title('Domain will be added and the config reloaded. You should add the required DNS records after adding the domain.')), + ), + ), + dom.br(), + dom.h2('Reports'), + dom.div(dom.a('DMARC', attr.href('#dmarc/reports'))), + dom.div(dom.a('TLS', attr.href('#tlsrpt/reports'))), + dom.br(), + dom.h2('Operations'), + dom.div(dom.a('MTA-STS policies', attr.href('#mtasts'))), + dom.div(dom.a('DMARC evaluations', attr.href('#dmarc/evaluations'))), + dom.div(dom.a('TLS connection results', attr.href('#tlsrpt/results'))), + // todo: routing, globally, per domain and per account + dom.br(), + dom.h2('DNS blocklist status'), + dom.div(dom.a('DNSBL status', attr.href('#dnsbl'))), + dom.br(), + dom.h2('Configuration'), + dom.div(dom.a('Webserver', attr.href('#webserver'))), + dom.div(dom.a('Files', attr.href('#config'))), + dom.div(dom.a('Log levels', attr.href('#loglevels'))), + footer, + ) +} + +const config = async () => { + const [staticPath, dynamicPath, staticText, dynamicText] = await client.ConfigFiles() + + dom._kids(page, + crumbs( + crumblink('Mox Admin', '#'), + 'Config', + ), + dom.h2(staticPath), + dom.pre(dom._class('literal'), staticText), + dom.h2(dynamicPath), + dom.pre(dom._class('literal'), dynamicText), + ) +} + +const loglevels = async () => { + const loglevels = await client.LogLevels() + + const levels = ['error', 'info', 'warn', 'debug', 'trace', 'traceauth', 'tracedata'] + + let form: HTMLFormElement + let fieldset: HTMLFieldSetElement + let pkg: HTMLInputElement + let level: HTMLSelectElement + + 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 => { + let lvl: HTMLSelectElement + 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: MouseEvent) { + e.preventDefault() + const target = e.target! as HTMLButtonElement + try { + target.disabled = true + await client.LogLevelSet(t[0], lvl.value) + } catch (err) { + console.log({err}) + window.alert('Error: ' + err) + return + } finally { + target.disabled = false + } + window.location.reload() // todo: reload just the current loglevels + }), + ' ', + dom.clickbutton('Remove', attr.title('Remove this log level, the default log level will apply.'), t[0] === '' ? attr.disabled('') : [], async function click(e: MouseEvent) { + e.preventDefault() + const target = e.target! as HTMLButtonElement + try { + target.disabled = true + await client.LogLevelRemove(t[0]) + } catch (err) { + console.log({err}) + window.alert('Error: ' + err) + return + } finally { + target.disabled = false + } + window.location.reload() // todo: reload just the current loglevels + }), + ), + ) + }), + ), + ), + dom.br(), + dom.h2('Add log level setting'), + form=dom.form( + async function submit(e: SubmitEvent) { + e.preventDefault() + e.stopPropagation() + fieldset.disabled = true + try { + await client.LogLevelSet(pkg.value, level.value) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + fieldset.disabled = false + } + form.reset() + window.location.reload() // todo: reload just the current loglevels + }, + fieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + 'Package', + dom.br(), + pkg=dom.input(attr.required('')), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + 'Level', + dom.br(), + level=dom.select( + attr.required(''), + levels.map(l => dom.option(l, l === 'debug' ? attr.selected('') : [])), + ), + ), + ' ', + dom.submitbutton('Add'), + ), + dom.br(), + dom.p('Suggestions for packages: autotls dkim dmarc dmarcdb dns dnsbl dsn http imapserver iprev junk message metrics mox moxio mtasts mtastsdb publicsuffix queue sendmail serve smtpserver spf store subjectpass tlsrpt tlsrptdb updates'), + ), + ) +} + +const box = (color: string, ...l: ElemArg[]) => [ + dom.div( + style({ + display: 'inline-block', + padding: '.25em .5em', + backgroundColor: color, + borderRadius: '3px', + margin: '.5ex 0', + }), + l, + ), + dom.br(), +] +const inlineBox = (color: string, ...l: ElemArg[]) => + dom.span( + style({ + display: 'inline-block', + padding: color ? '0.05em 0.2em' : '', + backgroundColor: color, + borderRadius: '3px', + }), + l, + ) + +const accounts = async () => { + const accounts = await client.Accounts() + + let fieldset: HTMLFieldSetElement + let account: HTMLInputElement + let email: HTMLInputElement + + dom._kids(page, + crumbs( + crumblink('Mox Admin', '#'), + 'Accounts', + ), + dom.h2('Accounts'), + (accounts || []).length === 0 ? dom.p('No accounts') : + dom.ul( + (accounts || []).map(s => dom.li(dom.a(s, attr.href('#accounts/'+s)))), + ), + dom.br(), + dom.h2('Add account'), + dom.form( + async function submit(e: SubmitEvent) { + e.preventDefault() + e.stopPropagation() + fieldset.disabled = true + try { + await client.AccountAdd(account.value, email.value) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + fieldset.disabled = false + } + window.location.hash = '#accounts/'+account.value + }, + fieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + 'Account name', + dom.br(), + account=dom.input(attr.required('')), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + 'Email address', + dom.br(), + email=dom.input(attr.type('email'), attr.required('')), + ), + ' ', + dom.submitbutton('Add account', attr.title('The account will be added and the config reloaded.')), + ) + ) + ) +} + +const account = async (name: string) => { + const config = await client.Account(name) + + let form: HTMLFormElement + let fieldset: HTMLFieldSetElement + let email: HTMLInputElement + + let fieldsetLimits: HTMLFieldSetElement + let maxOutgoingMessagesPerDay: HTMLInputElement + let maxFirstTimeRecipientsPerDay: HTMLInputElement + let quotaMessageSize: HTMLInputElement + + let formPassword: HTMLFormElement + let fieldsetPassword: HTMLFieldSetElement + let password: HTMLInputElement + let passwordHint: HTMLElement + + const xparseSize = (s: string) => { + const origs = s + s = s.toLowerCase() + let mult = 1 + if (s.endsWith('k')) { + mult = 1024 + } else if (s.endsWith('m')) { + mult = 1024*1024 + } else if (s.endsWith('g')) { + mult = 1024*1024*1024 + } else if (s.endsWith('t')) { + mult = 1024*1024*1024*1024 + } + if (mult !== 1) { + s = s.substring(0, s.length-1) + } + let v = parseInt(s) + console.log('x', s, v, mult, formatQuotaSize(v*mult)) + if (isNaN(v) || origs !== formatQuotaSize(v*mult)) { + throw new Error('invalid number') + } + return v*mult + } + + const formatQuotaSize = (v: number) => { + if (v === 0) { + return '0' + } + const m = 1024*1024 + const g = m*1024 + const t = g*1024 + if (Math.floor(v/t)*t === v) { + return ''+(v/t)+'t' + } else if (Math.floor(v/g)*g === v) { + return ''+(v/g)+'g' + } else if (Math.floor(v/m)*m === v) { + return ''+(v/m)+'m' + } + return ''+v + } + + dom._kids(page, + crumbs( + crumblink('Mox Admin', '#'), + crumblink('Accounts', '#accounts'), + name, + ), + dom.div( + 'Default domain: ', + config.Domain ? dom.a(config.Domain, attr.href('#domains/'+config.Domain)) : '(none)', + ), + dom.br(), + dom.h2('Addresses'), + dom.table( + dom.thead( + dom.tr( + dom.th('Address'), dom.th('Action'), + ), + ), + dom.tbody( + Object.keys(config.Destinations).map(k => { + let v: ElemArg = k + const t = k.split('@') + if (t.length > 1) { + const d = t[t.length-1] + const lp = t.slice(0, t.length-1).join('@') + v = [ + lp, '@', + dom.a(d, attr.href('#domains/'+d)), + ] + if (lp === '') { + v.unshift('(catchall) ') + } + } + return dom.tr( + dom.td(v), + dom.td( + dom.clickbutton('Remove', async function click(e: MouseEvent) { + e.preventDefault() + if (!window.confirm('Are you sure you want to remove this address?')) { + return + } + const target = e.target! as HTMLButtonElement + target.disabled = true + try { + let addr = k + if (!addr.includes('@')) { + addr += '@' + config.Domain + } + await client.AddressRemove(addr) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + target.disabled = false + } + window.location.reload() // todo: reload just the list + }), + ), + ) + }) + ), + ), + dom.br(), + dom.h2('Add address'), + form=dom.form( + async function submit(e: SubmitEvent) { + e.preventDefault() + e.stopPropagation() + fieldset.disabled = true + try { + let addr = email.value + if (!addr.includes('@')) { + if (!config.Domain) { + throw new Error('no default domain configured for account') + } + addr += '@' + config.Domain + } + await client.AddressAdd(addr, name) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + fieldset.disabled = false + } + form.reset() + window.location.reload() // todo: only reload the destinations + }, + fieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + dom.span('Email address or localpart', attr.title('If empty, or localpart is empty, a catchall address is configured for the domain.')), + dom.br(), + email=dom.input(), + ), + ' ', + dom.submitbutton('Add address'), + ), + ), + dom.br(), + dom.h2('Limits'), + dom.form( + fieldsetLimits=dom.fieldset( + dom.label( + style({display: 'block', marginBottom: '.5ex'}), + dom.span('Maximum outgoing messages per day', attr.title('Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000. MaxOutgoingMessagesPerDay in configuration file.')), + dom.br(), + maxOutgoingMessagesPerDay=dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxOutgoingMessagesPerDay || 1000)), + ), + dom.label( + style({display: 'block', marginBottom: '.5ex'}), + dom.span('Maximum first-time recipients per day', attr.title('Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200. MaxFirstTimeRecipientsPerDay in configuration file.')), + dom.br(), + maxFirstTimeRecipientsPerDay=dom.input(attr.type('number'), attr.required(''), attr.value(config.MaxFirstTimeRecipientsPerDay || 200)), + ), + dom.label( + style({display: 'block', marginBottom: '.5ex'}), + dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size for the account, overriding any globally configured maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages beyond the maximum size will result in an error. Useful to prevent a single account from filling storage.')), + dom.br(), + quotaMessageSize=dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize))), + ), + dom.submitbutton('Save'), + ), + async function submit(e: SubmitEvent) { + e.stopPropagation() + e.preventDefault() + fieldsetLimits.disabled = true + try { + await client.SetAccountLimits(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value)) + window.alert('Limits saved.') + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + fieldsetLimits.disabled = false + } + }, + ), + dom.br(), + dom.h2('Set new password'), + formPassword=dom.form( + fieldsetPassword=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + 'New password', + dom.br(), + password=dom.input(attr.type('password'), attr.required(''), function focus() { + passwordHint.style.display = '' + }), + ), + ' ', + dom.submitbutton('Change password'), + ), + passwordHint=dom.div( + style({display: 'none', marginTop: '.5ex'}), + dom.clickbutton('Generate random password', function click(e: MouseEvent) { + e.preventDefault() + let b = new Uint8Array(1) + let s = '' + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_;:,<.>/' + while (s.length < 12) { + self.crypto.getRandomValues(b) + if (Math.ceil(b[0]/chars.length)*chars.length > 255) { + continue // Prevent bias. + } + s += chars[b[0]%chars.length] + } + password.type = 'text' + password.value = s + }), + dom.div(dom._class('text'), + box(yellow, 'Important: Bots will try to bruteforce your password. Connections with failed authentication attempts will be rate limited but attackers WILL find weak passwords. If your account is compromised, spammers are likely to abuse your system, spamming your address and the wider internet in your name. So please pick a random, unguessable password, preferrably at least 12 characters.'), + ), + ), + async function submit(e: SubmitEvent) { + e.stopPropagation() + e.preventDefault() + fieldsetPassword.disabled = true + try { + await client.SetPassword(name, password.value) + window.alert('Password has been changed.') + formPassword.reset() + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + fieldsetPassword.disabled = false + } + }, + ), + dom.br(), + dom.h2('Danger'), + dom.clickbutton('Remove account', async function click(e: MouseEvent) { + e.preventDefault() + if (!window.confirm('Are you sure you want to remove this account?')) { + return + } + const target = e.target! as HTMLButtonElement + target.disabled = true + try { + await client.AccountRemove(name) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + target.disabled = false + } + window.location.hash = '#accounts' + }), + ) +} + +const domain = async (d: string) => { + const end = new Date() + const start = new Date(new Date().getTime() - 30*24*3600*1000) + const [dmarcSummaries, tlsrptSummaries, localpartAccounts, dnsdomain, clientConfigs] = await Promise.all([ + client.DMARCSummaries(start, end, d), + client.TLSRPTSummaries(start, end, d), + client.DomainLocalparts(d), + client.Domain(d), + client.ClientConfigsDomain(d), + ]) + + let form: HTMLFormElement + let fieldset: HTMLFieldSetElement + let localpart: HTMLInputElement + let account: HTMLInputElement + + 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.div('If autoconfig/autodiscover does not work with an email client, use the settings below for this domain. Authenticate with email address and password.'), + dom.table( + dom.thead( + dom.tr( + dom.th('Protocol'), dom.th('Host'), dom.th('Port'), dom.th('Listener'), dom.th('Note'), + ), + ), + dom.tbody( + (clientConfigs.Entries || []).map(e => + dom.tr( + dom.td(e.Protocol), + dom.td(domainString(e.Host)), + dom.td(''+e.Port), + dom.td(''+e.Listener), + dom.td(''+e.Note), + ) + ), + ), + ), + dom.br(), + dom.h2('DMARC aggregate reports summary'), + renderDMARCSummaries(dmarcSummaries || []), + dom.br(), + dom.h2('TLS reports summary'), + renderTLSRPTSummaries(tlsrptSummaries || []), + dom.br(), + dom.h2('Addresses'), + dom.table( + dom.thead( + dom.tr( + dom.th('Address'), dom.th('Account'), dom.th('Action'), + ), + ), + dom.tbody( + Object.entries(localpartAccounts).map(t => + dom.tr( + dom.td(t[0] || '(catchall)'), + dom.td(dom.a(t[1], attr.href('#accounts/'+t[1]))), + dom.td( + dom.clickbutton('Remove', async function click(e: MouseEvent) { + e.preventDefault() + if (!window.confirm('Are you sure you want to remove this address?')) { + return + } + const target = e.target! as HTMLButtonElement + target.disabled = true + try { + await client.AddressRemove(t[0] + '@' + d) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + target.disabled = false + } + window.location.reload() // todo: only reload the localparts + }), + ), + ), + ), + ), + ), + dom.br(), + dom.h2('Add address'), + form=dom.form( + async function submit(e: SubmitEvent) { + e.preventDefault() + e.stopPropagation() + fieldset.disabled = true + try { + await client.AddressAdd(localpart.value+'@'+d, account.value) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + fieldset.disabled = false + } + form.reset() + window.location.reload() // todo: only reload the addresses + }, + fieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + dom.span('Localpart', attr.title('An empty localpart is the catchall destination/address for the domain.')), + dom.br(), + localpart=dom.input(), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + 'Account', + dom.br(), + account=dom.input(attr.required('')), + ), + ' ', + dom.submitbutton('Add address', attr.title('Address will be added and the config reloaded.')), + ), + ), + dom.br(), + dom.h2('External checks'), + dom.ul( + dom.li(link('https://internet.nl/mail/'+dnsdomain.ASCII+'/', 'Check configuration at internet.nl')), + ), + dom.br(), + dom.h2('Danger'), + dom.clickbutton('Remove domain', async function click(e: MouseEvent) { + e.preventDefault() + if (!window.confirm('Are you sure you want to remove this domain?')) { + return + } + const target = e.target! as HTMLButtonElement + target.disabled = true + try { + await client.DomainRemove(d) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + target.disabled = false + } + window.location.hash = '#' + }), + ) +} + +const domainDNSRecords = async (d: string) => { + const [records, dnsdomain] = await Promise.all([ + client.DomainRecords(d), + client.Domain(d), + ]) + + dom._kids(page, + crumbs( + crumblink('Mox Admin', '#'), + crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), + 'DNS Records', + ), + dom.h1('Required DNS records'), + dom.pre('pre', dom._class('literal'), (records || []).join('\n')), + dom.br(), + ) +} + +const domainDNSCheck = async (d: string) => { + const [checks, dnsdomain] = await Promise.all([ + client.CheckDomain(d), + client.Domain(d), + ]) + + interface Result { + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null + } + + const resultSection = (title: string, r: Result, details: ElemArg[]) => { + let success: ElemArg[] = [] + if ((r.Errors || []).length === 0 && (r.Warnings || []).length === 0) { + success = box(green, 'OK') + } + const errors = (r.Errors || []).length === 0 ? [] : box(red, dom.ul(style({marginLeft: '1em'}), (r.Errors || []).map(s => dom.li(s)))) + const warnings = (r.Warnings || []).length === 0 ? [] : box(yellow, dom.ul(style({marginLeft: '1em'}), (r.Warnings || []).map(s => dom.li(s)))) + + let instructions: HTMLElement | null = null + if (r.Instructions && r.Instructions.length > 0) { + instructions = dom.div(style({margin: '.5ex 0'})) + const instrs = [ + (r.Instructions || []).map(s => [ + dom.pre(dom._class('literal'), style({display: 'inline-block', maxWidth: '60em'}), s), + dom.br(), + ]), + ] + if ((r.Errors || []).length === 0) { + dom._kids(instructions, + dom.div( + dom.a('Show instructions', attr.href('#'), function click(e: MouseEvent) { + e.preventDefault() + dom._kids(instructions!, instrs) + }), + dom.br(), + ) + ) + } else { + dom._kids(instructions, instrs) + } + } + return [ + dom.h2(title), + success, + errors, + warnings, + details, + dom.br(), + instructions ? instructions : [], + dom.br(), + ] + } + + const detailsDNSSEC: ElemArg[] = [] + const detailsIPRev = !checks.IPRev.IPNames || !Object.entries(checks.IPRev.IPNames).length ? [] : [ + dom.div('Hostname: ' + domainString(checks.IPRev.Hostname)), + dom.table( + dom.tr(dom.th('IP'), dom.th('Addresses')), + Object.entries(checks.IPRev.IPNames).sort().map(t => + dom.tr(dom.td(t[0]), dom.td((t[1] || []).join(', '))), + ) + ), + ] + const detailsMX = (checks.MX.Records || []).length === 0 ? [] : [ + dom.table( + dom.tr(dom.th('Preference'), dom.th('Host'), dom.th('IPs')), + (checks.MX.Records || []).map(mx => + dom.tr(dom.td(''+mx.Pref), dom.td(mx.Host), dom.td((mx.IPs || []).join(', '))), + ) + ), + ] + const detailsTLS: ElemArg[] = [] + const detailsDANE: ElemArg[] = [] + const detailsSPF: ElemArg[] = [ + checks.SPF.DomainTXT ? [dom.div('Domain TXT record: ' + checks.SPF.DomainTXT)] : [], + checks.SPF.HostTXT ? [dom.div('Host TXT record: ' + checks.SPF.HostTXT)] : [], + ] + const detailsDKIM = (checks.DKIM.Records || []).length === 0 ? [] : [ + dom.table( + dom.tr(dom.th('Selector'), dom.th('TXT record')), + (checks.DKIM.Records || []).map(rec => + dom.tr(dom.td(rec.Selector), dom.td(rec.TXT)), + ), + ) + ] + const detailsDMARC = !checks.DMARC.Domain ? [] : [ + dom.div('Domain: ' + checks.DMARC.Domain), + !checks.DMARC.TXT ? [] : dom.div('TXT record: ' + checks.DMARC.TXT), + ] + const detailsTLSRPT = (checksTLSRPT: api.TLSRPTCheckResult) => !checksTLSRPT.TXT ? [] : [ + dom.div('TXT record: ' + checksTLSRPT.TXT), + ] + const detailsMTASTS = !checks.MTASTS.TXT && !checks.MTASTS.PolicyText ? [] : [ + !checks.MTASTS.TXT ? [] : dom.div('MTA-STS record: ' + checks.MTASTS.TXT), + !checks.MTASTS.PolicyText ? [] : dom.div('MTA-STS policy: ', dom.pre(dom._class('literal'), style({maxWidth: '60em'}), checks.MTASTS.PolicyText)), + ] + const detailsSRVConf = !checks.SRVConf.SRVs || Object.keys(checks.SRVConf.SRVs).length === 0 ? [] : [ + dom.table( + dom.tr(dom.th('Service'), dom.th('Priority'), dom.th('Weight'), dom.th('Port'), dom.th('Host')), + Object.entries(checks.SRVConf.SRVs || []).map(t => { + const l = t[1] + if (!l || !l.length) { + return dom.tr(dom.td(t[0]), dom.td(attr.colspan('4'), '(none)')) + } + return l.map(r => dom.tr([t[0], r.Priority, r.Weight, r.Port, r.Target].map(s => dom.td(''+s)))) + }), + ), + ] + const detailsAutoconf = [ + ...(!checks.Autoconf.ClientSettingsDomainIPs ? [] : [dom.div('Client settings domain IPs: ' + checks.Autoconf.ClientSettingsDomainIPs.join(', '))]), + ...(!checks.Autoconf.IPs ? [] : [dom.div('IPs: ' + checks.Autoconf.IPs.join(', '))]), + ] + const detailsAutodiscover = !checks.Autodiscover.Records ? [] : [ + dom.table( + dom.tr(dom.th('Host'), dom.th('Port'), dom.th('Priority'), dom.th('Weight'), dom.th('IPs')), + (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(), + ) +} + +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.', + ), + ), + ) +} + +const dmarcReports = async () => { + const end = new Date() + const start = new Date(new Date().getTime() - 30*24*3600*1000) + const summaries = await client.DMARCSummaries(start, end, "") + + dom._kids(page, + crumbs( + crumblink('Mox Admin', '#'), + crumblink('DMARC', '#dmarc'), + 'Aggregate reporting summary', + ), + dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), + renderDMARCSummaries(summaries || []), + ) +} + +const renderDMARCSummaries = (summaries: api.DMARCSummary[]) => { + return [ + dom.p('Below a summary of DMARC aggregate reporting results for the past 30 days.'), + summaries.length === 0 ? dom.div(box(yellow, 'No domains with reports.')) : + dom.table( + dom.thead( + dom.tr( + dom.th('Domain', attr.title('Domain to which the DMARC policy applied. If example.com has a DMARC policy, and email is sent with a From-header with subdomain.example.com, and there is no DMARC record for that subdomain, but there is one for example.com, then the DMARC policy of example.com applies and reports are sent for that that domain.')), + dom.th('Messages', attr.title('Total number of messages that had the DMARC policy applied and reported. Actual messages sent is likely higher because not all email servers send DMARC aggregate reports, or perform DMARC checks at all.')), + dom.th('DMARC "quarantine"/"reject"', attr.title('Messages for which policy was to mark them as spam (quarantine) or reject them during SMTP delivery.')), + dom.th('DKIM "fail"', attr.title('Messages with a failing DKIM check. This can happen when sending through a mailing list where that list keeps your address in the message From-header but also strips DKIM-Signature headers in the message. DMARC evaluation passes if either DKIM passes or SPF passes.')), + dom.th('SPF "fail"', attr.title('Message with a failing SPF check. This can happen with email forwarding and with mailing list. Other mail servers have sent email with this domain in the message From-header. DMARC evaluation passes if at least SPF or DKIM passes.')), + dom.th('Policy overrides', attr.title('Mail servers can override the DMARC policy. E.g. a mail server may be able to detect emails coming from mailing lists that do not pass DMARC and would have to be rejected, but for which an override has been configured.')), + ) + ), + dom.tbody( + summaries.map(r => + dom.tr( + dom.td(dom.a(attr.href('#domains/' + r.Domain + '/dmarc'), attr.title('See report details.'), r.Domain)), + dom.td(style({textAlign: 'right'}), '' + r.Total), + dom.td(style({textAlign: 'right'}), r.DispositionQuarantine === 0 && r.DispositionReject === 0 ? '0/0' : box(red, '' + r.DispositionQuarantine + '/' + r.DispositionReject)), + dom.td(style({textAlign: 'right'}), box(r.DKIMFail === 0 ? green : red, '' + r.DKIMFail)), + dom.td(style({textAlign: 'right'}), box(r.SPFFail === 0 ? green : red, '' + r.SPFFail)), + dom.td(!r.PolicyOverrides ? [] : Object.entries(r.PolicyOverrides).map(kv => kv[0] + ': ' + kv[1]).join('; ')), + ) + ), + ), + ) + ] +} + +const dmarcEvaluations = async () => { + const [evalStats, suppressAddresses] = await Promise.all([ + client.DMARCEvaluationStats(), + client.DMARCSuppressList(), + ]) + + const isEmpty = (o: { [key: string]: T }) => { + for (const _ in o) { + return false + } + return true + } + + let fieldset: HTMLFieldSetElement + let reportingAddress: HTMLInputElement + let until: HTMLInputElement + let comment: HTMLInputElement + + 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 supression list for a period, to reduce noise in the postmaster mailbox.'), + dom.form( + async function submit(e: SubmitEvent) { + e.stopPropagation() + e.preventDefault() + try { + fieldset.disabled = true + await client.DMARCSuppressAdd(reportingAddress.value, new Date(until.value), comment.value) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + fieldset.disabled = false + } + window.location.reload() // todo: add the address to the list, or only reload the list + }, + fieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + 'Reporting address', + dom.br(), + reportingAddress=dom.input(attr.required('')), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + 'Until', + dom.br(), + until=dom.input(attr.type('date'), attr.required(''), attr.value(nextmonth.getFullYear()+'-'+(1+nextmonth.getMonth())+'-'+nextmonth.getDate())), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + dom.span('Comment (optional)'), + dom.br(), + comment=dom.input(), + ), + ' ', + dom.submitbutton('Add', attr.title('Outgoing reports to this reporting address will be suppressed until the end time.')), + ), + ), + dom.br(), + dom.table(dom._class('hover'), + dom.thead( + dom.tr( + dom.th('Reporting address'), + dom.th('Until'), + dom.th('Comment'), + dom.th('Action'), + ), + ), + dom.tbody( + (suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No suppressed reporting addresses.')) : [], + (suppressAddresses || []).map(ba => + dom.tr( + dom.td(ba.ReportingAddress), + dom.td(ba.Until.toISOString()), + dom.td(ba.Comment), + dom.td( + dom.clickbutton('Remove', async function click(e: MouseEvent) { + const target = e.target! as HTMLButtonElement + try { + target.disabled = true + await client.DMARCSuppressRemove(ba.ID) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + target.disabled = false + } + window.location.reload() // todo: only reload the list + }), + ' ', + dom.clickbutton('Extend for 1 month', async function click(e: MouseEvent) { + const target = e.target! as HTMLButtonElement + try { + target.disabled = true + await client.DMARCSuppressExtend(ba.ID, new Date(new Date().getTime() + 31*24*3600*1000)) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + target.disabled = false + } + window.location.reload() // todo: only reload the list + }), + ), + ) + ), + ), + ), + ) +} + +const dmarcEvaluationsDomain = async (domain: string) => { + const [d, evaluations] = await client.DMARCEvaluationsDomain(domain) + + let lastInterval = '' + let lastAddresses = '' + + const formatPolicy = (e: api.Evaluation) => { + const p = e.PolicyPublished + let s = '' + const add = (k: string, v: string) => { + if (v) { + s += k+'='+v+'; ' + } + } + add('p', p.Policy) + add('sp', p.SubdomainPolicy) + add('adkim', p.ADKIM) + add('aspf', p.ASPF) + add('pct', ''+p.Percentage) + add('fo', ''+p.ReportingOptions) + return s + } + let lastPolicy = '' + + const authStatus = (v: boolean) => inlineBox(v ? '' : yellow, v ? 'pass' : 'fail') + const formatDKIMResults = (results: api.DKIMAuthResult[]) => results.map(r => dom.div('selector '+r.Selector+(r.Domain !== domain ? ', domain '+r.Domain : '') + ': ', inlineBox(r.Result === "pass" ? '' : yellow, r.Result))) + const formatSPFResults = (alignedpass: boolean, results: api.SPFAuthResult[]) => results.map(r => dom.div(''+r.Scope+(r.Domain !== domain ? ', domain '+r.Domain : '') + ': ', inlineBox(r.Result === "pass" && alignedpass ? '' : yellow, r.Result))) + + const sourceIP = (ip: string) => { + const r = dom.span(ip, attr.title('Click to do a reverse lookup of the IP.'), style({cursor: 'pointer'}), async function click(e: MouseEvent) { + e.preventDefault() + try { + const rev = await client.LookupIP(ip) + r.innerText = ip + '\n' + (rev.Hostnames || []).join('\n') + } catch (err) { + r.innerText = ip + '\nerror: ' +errmsg(err) + } + }) + 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: MouseEvent) { + const target = e.target! as HTMLButtonElement + target.disabled = true + try { + await client.DMARCRemoveEvaluations(domain) + window.location.reload() // todo: only clear the table? + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + } finally { + target.disabled = false + } + }), + ), + 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 => { + const ival = e.IntervalHours + 'h' + const interval = ival === lastInterval ? '' : ival + lastInterval = ival + + const a = (e.Addresses || []).join('\n') + const addresses = a === lastAddresses ? '' : a + lastAddresses = a + + const p = formatPolicy(e) + const policy = p === lastPolicy ? '' : p + lastPolicy = p + + return dom.tr( + dom.td(''+e.ID), + dom.td(new Date(e.Evaluated).toUTCString()), + dom.td(e.Optional ? 'Yes' : ''), + dom.td(interval), + dom.td(addresses), + dom.td(policy), + dom.td(sourceIP(e.SourceIP)), + dom.td(inlineBox(e.Disposition === 'none' ? '' : red, e.Disposition), (e.OverrideReasons || []).length > 0 ? ' ('+(e.OverrideReasons || []).map(r => r.Type).join(', ')+')' : ''), + dom.td(authStatus(e.AlignedDKIMPass), '/', authStatus(e.AlignedSPFPass)), + dom.td(e.EnvelopeTo), + dom.td(e.EnvelopeFrom), + dom.td(e.HeaderFrom), + dom.td(formatDKIMResults(e.DKIMResults || [])), + dom.td(formatSPFResults(e.AlignedSPFPass, e.SPFResults || [])), + ) + }), + (evaluations || []).length === 0 ? dom.tr(dom.td(attr.colspan('14'), 'No evaluations.')) : [], + ), + ), + ) +} + +const utcDate = (dt: Date) => new Date(Date.UTC(dt.getUTCFullYear(), dt.getUTCMonth(), dt.getUTCDate(), dt.getUTCHours(), dt.getUTCMinutes(), dt.getUTCSeconds())) +const utcDateStr = (dt: Date) => [dt.getUTCFullYear(), 1+dt.getUTCMonth(), dt.getUTCDate()].join('-') +const isDayChange = (dt: Date) => utcDateStr(new Date(dt.getTime() - 2*60*1000)) !== utcDateStr(new Date(dt.getTime() + 2*60*1000)) + +const period = (start: Date, end: Date) => { + const beginUTC = utcDate(start) + const endUTC = utcDate(end) + const beginDayChange = isDayChange(beginUTC) + const endDayChange = isDayChange(endUTC) + let beginstr = utcDateStr(beginUTC) + let endstr = utcDateStr(endUTC) + const title = attr.title('' + beginUTC.toISOString() + ' - ' + endUTC.toISOString()) + if (beginDayChange && endDayChange && Math.abs(beginUTC.getTime() - endUTC.getTime()) < 24*(2*60+3600)*1000) { + return dom.span(beginstr, title) + } + const pad = (v: number) => v < 10 ? '0'+v : ''+v + if (!beginDayChange) { + beginstr += ' '+pad(beginUTC.getUTCHours()) + ':' + pad(beginUTC.getUTCMinutes()) + } + if (!endDayChange) { + endstr += ' '+pad(endUTC.getUTCHours()) + ':' + pad(endUTC.getUTCMinutes()) + } + return dom.span(beginstr + ' - ' + endstr, title) +} + +const domainDMARC = async (d: string) => { + const end = new Date() + const start = new Date(new Date().getTime() - 30*24*3600*1000) + const [reports, dnsdomain] = await Promise.all([ + client.DMARCReports(start, end, d), + client.Domain(d), + ]) + + // todo future: table sorting? period selection (last day, 7 days, 1 month, 1 year, custom period)? collapse rows for a report? show totals per report? a simple bar graph to visualize messages and dmarc/dkim/spf fails? similar for TLSRPT. + + dom._kids(page, + crumbs( + crumblink('Mox Admin', '#'), + crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), + 'DMARC aggregate reports', + ), + dom.p('DMARC reports are periodically sent by other mail servers that received an email message with a "From" header with our domain. Domains can have a DMARC DNS record that asks other mail servers to send these aggregate reports for analysis.'), + dom.p('Below the DMARC aggregate reports for the past 30 days.'), + (reports || []).length === 0 ? dom.div('No DMARC reports for domain.') : + dom.table(dom._class('hover'), + dom.thead( + dom.tr( + dom.th('ID'), + dom.th('Organisation', attr.title('Organization that sent the DMARC report.')), + dom.th('Period (UTC)', attr.title('Period this reporting period is about. Mail servers are recommended to stick to whole UTC days.')), + dom.th('Policy', attr.title('The DMARC policy that the remote mail server had fetched and applied to the message. A policy that changed during the reporting period may result in unexpected policy evaluations.')), + dom.th('Source IP', attr.title('Remote IP address of session at remote mail server.')), + dom.th('Messages', attr.title('Total messages that the results apply to.')), + dom.th('Result', attr.title('DMARC evaluation result.')), + dom.th('ADKIM', attr.title('DKIM alignment. For a pass, one of the DKIM signatures that pass must be strict/relaxed-aligned with the domain, as specified by the policy.')), + dom.th('ASPF', attr.title('SPF alignment. For a pass, the SPF policy must pass and be strict/relaxed-aligned with the domain, as specified by the policy.')), + dom.th('SMTP to', attr.title('Domain of destination address, as specified during the SMTP session.')), + dom.th('SMTP from', attr.title('Domain of originating address, as specified during the SMTP session.')), + dom.th('Header from', attr.title('Domain of address in From-header of message.')), + dom.th('Auth Results', attr.title('Details of DKIM and/or SPF authentication results. DMARC requires at least one aligned DKIM or SPF pass.')), + ), + ), + dom.tbody( + (reports || []).map(r => { + const m = r.ReportMetadata + + let policy: string[] = [] + if (r.PolicyPublished.Domain !== d) { + policy.push(r.PolicyPublished.Domain) + } + const alignments = {'r': 'relaxed', 's': 'strict'} + if (r.PolicyPublished.ADKIM as string !== '') { + policy.push('dkim '+(alignments[r.PolicyPublished.ADKIM] || r.PolicyPublished.ADKIM)) + } + if (r.PolicyPublished.ASPF as string !== '') { + policy.push('spf '+(alignments[r.PolicyPublished.ASPF] || r.PolicyPublished.ASPF)) + } + if (r.PolicyPublished.Policy as string !== '') { + policy.push('policy '+r.PolicyPublished.Policy) + } + if (r.PolicyPublished.SubdomainPolicy as string !== '' && r.PolicyPublished.SubdomainPolicy !== r.PolicyPublished.Policy) { + policy.push('subdomain '+r.PolicyPublished.SubdomainPolicy) + } + if (r.PolicyPublished.Percentage !== 100) { + policy.push('' + r.PolicyPublished.Percentage + '%') + } + + const sourceIP = (ip: string) => { + const r = dom.span(ip, attr.title('Click to do a reverse lookup of the IP.'), style({cursor: 'pointer'}), async function click(e: MouseEvent) { + e.preventDefault() + try { + const rev = await client.LookupIP(ip) + r.innerText = ip + '\n' + (rev.Hostnames || []).join('\n') + } catch (err) { + r.innerText = ip + '\nerror: ' +errmsg(err) + } + }) + return r + } + + let authResults = 0 + for (const record of (r.Records || [])) { + authResults += (record.AuthResults.DKIM || []).length + authResults += (record.AuthResults.SPF || []).length + } + const reportRowspan = attr.rowspan('' + authResults) + return (r.Records || []).map((record, recordIndex) => { + const row = record.Row + const pol = row.PolicyEvaluated + const ids = record.Identifiers + const dkims = record.AuthResults.DKIM || [] + const spfs = record.AuthResults.SPF || [] + + const recordRowspan = attr.rowspan('' + (dkims.length+spfs.length)) + const valignTop = style({verticalAlign: 'top'}) + + const dmarcStatuses = { + none: 'DMARC checks or were not applied. This does not mean these messages are definitely not spam though, and they may have been rejected based on other checks, such as reputation or content-based filters.', + quarantine: 'DMARC policy is to mark message as spam.', + reject: 'DMARC policy is to reject the message during SMTP delivery.', + } + const rows: HTMLElement[] = [] + const addRow = (...last: ElemArg[]) => { + const tr = dom.tr( + recordIndex > 0 || rows.length > 0 ? [] : [ + dom.td(reportRowspan, valignTop, dom.a('' + r.ID, attr.href('#domains/' + d + '/dmarc/' + r.ID), attr.title('View raw report.'))), + dom.td(reportRowspan, valignTop, m.OrgName, attr.title('Email: ' + m.Email + ', ReportID: ' + m.ReportID)), + dom.td(reportRowspan, valignTop, period(new Date(m.DateRange.Begin*1000), new Date(m.DateRange.End*1000)), m.Errors && m.Errors.length ? dom.span('errors', attr.title(m.Errors.join('; '))) : []), + dom.td(reportRowspan, valignTop, policy.join(', ')), + ], + rows.length > 0 ? [] : [ + dom.td(recordRowspan, valignTop, sourceIP(row.SourceIP)), + dom.td(recordRowspan, valignTop, '' + row.Count), + dom.td(recordRowspan, valignTop, + dom.span(pol.Disposition === 'none' ? 'none' : box(red, pol.Disposition), attr.title(pol.Disposition + ': ' + dmarcStatuses[pol.Disposition])), + (pol.Reasons || []).map(reason => [dom.br(), dom.span(reason.Type + (reason.Comment ? ' (' + reason.Comment + ')' : ''), attr.title('Policy was overridden by remote mail server for this reasons.'))]), + ), + dom.td(recordRowspan, valignTop, pol.DKIM === 'pass' ? 'pass' : box(yellow, dom.span(pol.DKIM, attr.title('No or no valid DKIM-signature is present that is "aligned" with the domain name.')))), + dom.td(recordRowspan, valignTop, pol.SPF === 'pass' ? 'pass' : box(yellow, dom.span(pol.SPF, attr.title('No SPF policy was found, or IP is not allowed by policy, or domain name is not "aligned" with the domain name.')))), + dom.td(recordRowspan, valignTop, ids.EnvelopeTo), + dom.td(recordRowspan, valignTop, ids.EnvelopeFrom), + dom.td(recordRowspan, valignTop, ids.HeaderFrom), + ], + dom.td(last), + ) + rows.push(tr) + } + for (const dkim of dkims) { + const statuses = { + none: 'Message was not signed', + pass: 'Message was signed and signature was verified.', + fail: 'Message was signed, but signature was invalid.', + policy: 'Message was signed, but signature is not accepted by policy.', + neutral: 'Message was signed, but the signature contains an error or could not be processed. This status is also used for errors not covered by other statuses.', + temperror: 'Message could not be verified. E.g. because of DNS resolve error. A later attempt may succeed. A missing DNS record is treated as temporary error, a new key may not have propagated through DNS shortly after it was taken into use.', + permerror: 'Message cannot be verified. E.g. when a required header field is absent or for invalid (combination of) parameters. We typically set this if a DNS record does not allow the signature, e.g. due to algorithm mismatch or expiry.', + } + addRow( + 'dkim: ', + dom.span((dkim.Result === 'none' || dkim.Result === 'pass') ? dkim.Result : box(yellow, dkim.Result), attr.title((dkim.HumanResult ? 'additional information: ' + dkim.HumanResult + ';\n' : '') + dkim.Result + ': ' + (statuses[dkim.Result] || 'invalid status'))), + !dkim.Selector ? [] : [ + ', ', + dom.span(dkim.Selector, attr.title('Selector, the DKIM record is at "._domainkey.".' + (dkim.Domain === d ? '' : ';\ndomain: ' + dkim.Domain))), + ] + ) + } + for (const spf of spfs) { + const statuses = { + none: 'No SPF policy found.', + neutral: 'Policy states nothing about IP, typically due to "?" qualifier in SPF record.', + pass: 'IP is authorized.', + fail: 'IP is explicitly not authorized, due to "-" qualifier in SPF record.', + softfail: 'Weak statement that IP is probably not authorized, "~" qualifier in SPF record.', + temperror: 'Trying again later may succeed, e.g. for temporary DNS lookup error.', + permerror: 'Error requiring some intervention to correct. E.g. invalid DNS record.', + } + addRow( + 'spf: ', + dom.span((spf.Result === 'none' || spf.Result === 'neutral' || spf.Result === 'pass') ? spf.Result : box(yellow, spf.Result), attr.title(spf.Result + ': ' + (statuses[spf.Result] || 'invalid status'))), + ', ', + dom.span(spf.Scope, attr.title('scopes:\nhelo: "SMTP HELO"\nmfrom: SMTP "MAIL FROM"')), + ' ', + dom.span(spf.Domain), + ) + } + return rows + }) + }), + ), + ) + ) +} + +const domainDMARCReport = async (d: string, reportID: number) => { + const [report, dnsdomain] = await Promise.all([ + client.DMARCReportID(d, reportID), + client.Domain(d), + ]) + + dom._kids(page, + crumbs( + crumblink('Mox Admin', '#'), + crumblink('Domain ' + domainString(dnsdomain), '#domains/'+d), + crumblink('DMARC aggregate reports', '#domains/' + d + '/dmarc'), + 'Report ' + reportID + ), + dom.p('Below is the raw report as received from the remote mail server.'), + dom.div(dom._class('literal'), JSON.stringify(report, null, '\t')), + ) +} + +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.', + ), + ), + ) +} + +const tlsrptResults = async () => { + const [results, suppressAddresses] = await Promise.all([ + client.TLSRPTResults(), + client.TLSRPTSuppressList(), + ]) + + // todo: add a view where results are grouped by policy domain+dayutc. now each recipient domain gets a row. + + let fieldset: HTMLFieldSetElement + let reportingAddress: HTMLInputElement + let until: HTMLInputElement + let comment: HTMLInputElement + 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) => { + if (a.DayUTC !== b.DayUTC) { + return a.DayUTC < b.DayUTC ? -1 : 1 + } + if (a.RecipientDomain !== b.RecipientDomain) { + return a.RecipientDomain < b.RecipientDomain ? -1 : 1 + } + return a.PolicyDomain < b.PolicyDomain ? -1 : 1 + }).map(r => { + let success = 0 + let failed = 0 + let failureDetails = 0 + ;(r.Results || []).forEach(result => { + success += result.Summary.TotalSuccessfulSessionCount + failed += result.Summary.TotalFailureSessionCount + failureDetails += (result.FailureDetails || []).length + }) + const policyTypes: string[] = [] + for (const result of (r.Results || [])) { + const pt = result.Policy.Type + if (!policyTypes.includes(pt)) { + policyTypes.push(pt) + } + } + return dom.tr( + dom.td(r.DayUTC), + dom.td(r.RecipientDomain), + dom.td(dom.a(attr.href('#tlsrpt/results/'+ (r.RecipientDomain === r.PolicyDomain ? 'rcptdom/' : 'host/') + r.PolicyDomain), r.PolicyDomain)), + dom.td(r.IsHost ? '✓' : ''), + dom.td(policyTypes.join(', ')), + dom.td(style({textAlign: 'right'}), ''+success), + dom.td(style({textAlign: 'right'}), ''+failed), + dom.td(style({textAlign: 'right'}), ''+failureDetails), + dom.td(style({textAlign: 'right'}), r.SendReport ? '✓' : ''), + ) + }), + (results || []).length === 0 ? dom.tr(dom.td(attr.colspan('9'), 'No results.')) : [], + ), + ), + dom.br(), + dom.br(), + dom.h2('Suppressed reporting addresses'), + dom.p('In practice, sending a TLS report to a reporting address can cause DSN to be sent back. Such addresses can be added to a suppress list for a period, to reduce noise in the postmaster mailbox.'), + dom.form( + async function submit(e: SubmitEvent) { + e.stopPropagation() + e.preventDefault() + try { + fieldset.disabled = true + await client.TLSRPTSuppressAdd(reportingAddress.value, new Date(until.value), comment.value) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + fieldset.disabled = false + } + window.location.reload() // todo: add the address to the list, or only reload the list + }, + fieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + 'Reporting address', + dom.br(), + reportingAddress=dom.input(attr.required('')), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + 'Until', + dom.br(), + until=dom.input(attr.type('date'), attr.required(''), attr.value(nextmonth.getFullYear()+'-'+(1+nextmonth.getMonth())+'-'+nextmonth.getDate())), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + dom.span('Comment (optional)'), + dom.br(), + comment=dom.input(), + ), + ' ', + dom.submitbutton('Add', attr.title('Outgoing reports to this reporting address will be suppressed until the end time.')), + ), + ), + dom.br(), + dom.table(dom._class('hover'), + dom.thead( + dom.tr( + dom.th('Reporting address'), + dom.th('Until'), + dom.th('Comment'), + dom.th('Action'), + ), + ), + dom.tbody( + (suppressAddresses || []).length === 0 ? dom.tr(dom.td(attr.colspan('4'), 'No suppressed reporting addresses.')) : [], + (suppressAddresses || []).map(ba => + dom.tr( + dom.td(ba.ReportingAddress), + dom.td(ba.Until.toISOString()), + dom.td(ba.Comment), + dom.td( + dom.clickbutton('Remove', async function click(e: MouseEvent) { + const target = e.target! as HTMLButtonElement + try { + target.disabled = true + await client.TLSRPTSuppressRemove(ba.ID) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + target.disabled = false + } + window.location.reload() // todo: only reload the list + }), + ' ', + dom.clickbutton('Extend for 1 month', async function click(e: MouseEvent) { + const target = e.target! as HTMLButtonElement + try { + target.disabled = true + await client.TLSRPTSuppressExtend(ba.ID, new Date(new Date().getTime() + 31*24*3600*1000)) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + target.disabled = false + } + window.location.reload() // todo: only reload the list + }), + ), + ) + ), + ), + ), + ) +} + +const tlsrptResultsPolicyDomain = async (isrcptdom: boolean, domain: string) => { + const [d, tlsresults] = await client.TLSRPTResultsDomain(isrcptdom, domain) + const recordPromise = client.LookupTLSRPTRecord(domain) + + let recordBox: HTMLElement + 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: MouseEvent) { + e.preventDefault() + const target = e.target! as HTMLButtonElement + target.disabled = true + try { + await client.TLSRPTRemoveResults(isrcptdom, domain, '') + window.location.reload() // todo: only clear the table? + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + } finally { + target.disabled = false + } + }), + ), + dom.br(), + dom.div('Fetching TLSRPT DNS record...'), + recordBox=dom.div(), + dom.br(), + dom.p('Below are the results per day and ' + (isrcptdom ? 'policy' : 'recipient') + ' domain that may be sent in a report.'), + (tlsresults || []).map(tlsresult => [ + dom.h2(tlsresult.DayUTC, ' - ', dom.span(attr.title('Recipient domain, as used in SMTP MAIL TO, usually based on message To/Cc/Bcc.'), isrcptdom ? tlsresult.PolicyDomain : tlsresult.RecipientDomain)), + dom.p( + 'Send report (if TLSRPT policy exists and has address): '+(tlsresult.SendReport ? 'Yes' : 'No'), + dom.br(), + 'Report about (MX) host (instead of recipient domain): '+(tlsresult.IsHost ? 'Yes' : 'No'), + ), + dom.div(dom._class('literal'), JSON.stringify(tlsresult.Results, null, '\t')), + ]) + ) + + // In background so page load fade doesn't look weird. + ;(async () => { + let txt: string = '' + let error: string + try { + let [_, xtxt, xerror] = await recordPromise + txt = xtxt + error = xerror + } catch (err) { + error = 'error: '+errmsg(err) + } + const l = [] + if (txt) { + l.push(dom.div(dom._class('literal'), txt)) + } + if (error) { + l.push(box(red, error)) + } + dom._kids(recordBox, l) + })() +} + +const tlsrptReports = async () => { + const end = new Date() + const start = new Date(new Date().getTime() - 30*24*3600*1000) + const summaries = await client.TLSRPTSummaries(start, end, '') + + dom._kids(page, + crumbs( + crumblink('Mox Admin', '#'), + crumblink('TLSRPT', '#tlsrpt'), + 'Reports' + ), + dom.p('TLSRPT (TLS reporting) is a mechanism to request feedback from other mail servers about TLS connections to your mail server. If is typically used along with MTA-STS and/or DANE to enforce that SMTP connections are protected with TLS. Mail servers implementing TLSRPT will typically send a daily report with both successful and failed connection counts, including details about failures.'), + renderTLSRPTSummaries(summaries || []) + ) +} + +const renderTLSRPTSummaries = (summaries: api.TLSRPTSummary[]) => { + return [ + dom.p('Below a summary of TLS reports for the past 30 days.'), + summaries.length === 0 ? dom.div(box(yellow, 'No domains with TLS reports.')) : + dom.table(dom._class('hover'), + dom.thead( + dom.tr( + dom.th('Policy domain', attr.title('Policy domain the report is about. The recipient domain for MTA-STS, the TLSA base domain for DANE.')), + dom.th('Successes', attr.title('Number of successful SMTP STARTTLS sessions.')), + dom.th('Failures', attr.title('Number of failed SMTP STARTTLS sessions.')), + dom.th('Failure details', attr.title('Details about connection failures.')), + ) + ), + dom.tbody( + summaries.map(r => + dom.tr( + dom.td(dom.a(attr.href('#tlsrpt/reports/' + domainName(r.PolicyDomain)), attr.title('See report details.'), domainName(r.PolicyDomain))), + dom.td(style({textAlign: 'right'}), '' + r.Success), + dom.td(style({textAlign: 'right'}), '' + r.Failure), + dom.td(!r.ResultTypeCounts ? [] : Object.entries(r.ResultTypeCounts).map(kv => kv[0] + ': ' + kv[1]).join('; ')), + ) + ), + ), + ) + ] +} + +const domainTLSRPT = async (d: string) => { + const end = new Date() + const start = new Date(new Date().getTime() - 30*24*3600*1000) + const [records, dnsdomain] = await Promise.all([ + client.TLSReports(start, end, d), + client.ParseDomain(d), + ]) + + const policyType = (policy: api.ResultPolicy) => { + let s: string = policy.Type + if (s === 'sts') { + const mode = (policy.String || []).find(s => s.startsWith('mode:')) + if (mode) { + s += ': '+mode.replace('mode:', '').trim() + } + } + 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.') : + dom.table(dom._class('hover'), + dom.thead( + dom.tr( + dom.th('Report', attr.colspan('3')), + dom.th('Policy', attr.colspan('3')), + dom.th('Failure Details', attr.colspan('8')), + ), + dom.tr( + dom.th('ID'), + dom.th('From', attr.title('SMTP mail from from which we received the report.')), + dom.th('Period (UTC)', attr.title('Period this reporting period is about. Mail servers are recommended to stick to whole UTC days.')), + + dom.th('Policy', attr.title('The policy applied, typically STSv1.')), + dom.th('Successes', attr.title('Total number of successful TLS connections for policy.')), + dom.th('Failures', attr.title('Total number of failed TLS connections for policy.')), + + dom.th('Result Type', attr.title('Type of failure.')), + dom.th('Sending MTA', attr.title('IP of sending MTA.')), + dom.th('Receiving MX Host'), + dom.th('Receiving MX HELO'), + dom.th('Receiving IP'), + dom.th('Count', attr.title('Number of TLS connections that failed with these details.')), + dom.th('More', attr.title('Optional additional information about the failure.')), + dom.th('Code', attr.title('Optional API error code relating to the failure.')), + ), + ), + dom.tbody( + (records || []).map(record => { + const r = record.Report + let nrows = 0 + ;(r.Policies || []).forEach(pr => nrows += (pr.FailureDetails || []).length || 1) + const reportRowSpan = attr.rowspan(''+nrows) + const valignTop = style({verticalAlign: 'top'}) + const alignRight = style({textAlign: 'right'}) + return (r.Policies || []).map((result, index) => { + const rows: HTMLElement[] = [] + const details = result.FailureDetails || [] + const resultRowSpan = attr.rowspan(''+(details.length || 1)) + const addRow = (d: api.FailureDetails | undefined, di: number) => { + const row = dom.tr( + index > 0 || rows.length > 0 ? [] : [ + dom.td(reportRowSpan, valignTop, dom.a(''+record.ID, attr.href('#tlsrpt/reports/' + record.Domain + '/' + record.ID))), + dom.td(reportRowSpan, valignTop, r.OrganizationName || r.ContactInfo || record.MailFrom || '', attr.title('Organization: ' +r.OrganizationName + '; \nContact info: ' + r.ContactInfo + '; \nReport ID: ' + r.ReportID + '; \nMail from: ' + record.MailFrom)), + dom.td(reportRowSpan, valignTop, period(r.DateRange.Start, r.DateRange.End)), + ], + di > 0 ? [] : [ + dom.td(resultRowSpan, valignTop, policyType(result.Policy), attr.title((result.Policy.String || []).join('\n'))), + dom.td(resultRowSpan, valignTop, alignRight, '' + result.Summary.TotalSuccessfulSessionCount), + dom.td(resultRowSpan, valignTop, alignRight, '' + result.Summary.TotalFailureSessionCount), + ], + !d ? dom.td(attr.colspan('8')) : [ + dom.td(d.ResultType), + dom.td(d.SendingMTAIP), + dom.td(d.ReceivingMXHostname), + dom.td(d.ReceivingMXHelo), + dom.td(d.ReceivingIP), + dom.td(alignRight, '' + d.FailedSessionCount), + dom.td(d.AdditionalInformation), + dom.td(d.FailureReasonCode), + + ], + ) + rows.push(row) + } + let di = 0 + for (const d of details) { + addRow(d, di) + di++ + } + if (details.length === 0) { + addRow(undefined, 0) + } + return rows + }) + }) + ), + ) + ) +} + +const domainTLSRPTID = async (d: string, reportID: number) => { + const [report, dnsdomain] = await Promise.all([ + client.TLSReportID(d, reportID), + client.ParseDomain(d), + ]) + + dom._kids(page, + crumbs( + crumblink('Mox Admin', '#'), + crumblink('TLSRPT', '#tlsrpt'), + crumblink('Reports', '#tlsrpt/reports'), + crumblink('Domain '+domainString(dnsdomain), '#tlsrpt/reports/' + d + ''), + 'Report ' + reportID + ), + dom.p('Below is the raw report as received from the remote mail server.'), + dom.div(dom._class('literal'), JSON.stringify(report, null, '\t')), + ) +} + +const mtasts = async () => { + const policies = await client.MTASTSPolicies() + + dom._kids(page, + crumbs( + crumblink('Mox Admin', '#'), + 'MTA-STS policies', + ), + dom.p("MTA-STS is a mechanism allowing email domains to publish a policy for using SMTP STARTTLS and TLS verification. See ", link('https://www.rfc-editor.org/rfc/rfc8461.html', 'RFC 8461'), '.'), + dom.p("The SMTP protocol is unencrypted by default, though the SMTP STARTTLS command is typically used to enable TLS on a connection. However, MTA's using STARTTLS typically do not validate the TLS certificate. An MTA-STS policy can specify that validation of host name, non-expiration and webpki trust is required."), + makeMTASTSTable(policies || []), + ) +} + +const formatMTASTSMX = (mx: api.STSMX[]) => { + return mx.map(e => { + return (e.Wildcard ? '*.' : '') + e.Domain.ASCII + }).join(', ') +} + +const makeMTASTSTable = (items: api.PolicyRecord[]) => { + if (items.length === 0) { + return dom.div('No data') + } + // Elements: Field name in JSON, column name override, title for column name. + const keys = [ + ["LastUse", "", "Last time this policy was used."], + ["Domain", "Domain", "Domain this policy was retrieved from and this policy applies to."], + ["Backoff", "", "If true, a DNS record for MTA-STS exists, but a policy could not be fetched. This indicates a failure with MTA-STS."], + ["RecordID", "", "Unique ID for this policy. Each time a domain changes its policy, it must also change the record ID that is published in DNS to propagate the change."], + ["Version", "", "For valid MTA-STS policies, this must be 'STSv1'."], + ["Mode", "", "'enforce': TLS must be used and certificates must be validated; 'none': TLS and certificate validation is not required, typically only useful for removing once-used MTA-STS; 'testing': TLS should be used and certificated should be validated, but fallback to unverified TLS or plain text is allowed, but such cases must be reported"], + ["MX", "", "The MX hosts that are configured to do TLS. If TLS and validation is required, but an MX host is not on this list, delivery will not be attempted to that host."], + ["MaxAgeSeconds", "", "How long a policy can be cached and reused after it was fetched. Typically in the order of weeks."], + ["Extensions", "", "Free-form extensions in the MTA-STS policy."], + ["ValidEnd", "", "Until when this cached policy is valid, based on time the policy was fetched and the policy max age. Non-failure policies are automatically refreshed before they become invalid."], + ["LastUpdate", "", "Last time this policy was updated."], + ["Inserted", "", "Time when the policy was first inserted."], + ] + const nowSecs = new Date().getTime()/1000 + return dom.table(dom._class('hover'), + dom.thead( + dom.tr(keys.map(kt => dom.th(dom.span(attr.title(kt[2]), kt[1] || kt[0])))), + ), + dom.tbody( + items.map(e => + dom.tr( + [ + age(e.LastUse, false, nowSecs), + e.Domain, + e.Backoff, + e.RecordID, + e.Version, + e.Mode, + formatMTASTSMX(e.MX || []), + e.MaxAgeSeconds, + e.Extensions, + age(e.ValidEnd, true, nowSecs), + age(e.LastUpdate, false, nowSecs), + age(e.Inserted, false, nowSecs), + ].map(v => dom.td(v === null ? [] : (v instanceof HTMLElement ? v : ''+v))), + ) + ), + ), + ) +} + +const dnsbl = async () => { + const ipZoneResults = await client.DNSBLStatus() + + const url = (ip: string) => { + return 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html' + } + + 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 => { + const [ip, zoneResults] = ipZones + return dom.li( + link(url(ip), ip), + !ipZones.length ? [] : dom.ul( + Object.entries(zoneResults).sort().map(zoneResult => + dom.li( + zoneResult[0] + ': ', + zoneResult[1] === 'pass' ? 'pass' : box(red, zoneResult[1]), + ), + ), + ), + ) + }) + ), + !Object.entries(ipZoneResults).length ? box(red, 'No IPs found.') : [], + ) +} + +const queueList = async () => { + const [msgs, transports] = await Promise.all([ + client.QueueList(), + client.Transports(), + ]) + + const nowSecs = new Date().getTime()/1000 + + dom._kids(page, + crumbs( + crumblink('Mox Admin', '#'), + 'Queue', + ), + (msgs || []).length === 0 ? 'Currently no messages in the queue.' : [ + dom.p('The messages below are currently in the queue.'), + // todo: sorting by address/timestamps/attempts. perhaps filtering. + dom.table(dom._class('hover'), + dom.thead( + dom.tr( + dom.th('ID'), + dom.th('Submitted'), + dom.th('From'), + dom.th('To'), + dom.th('Size'), + dom.th('Attempts'), + dom.th('Next attempt'), + dom.th('Last attempt'), + dom.th('Last error'), + dom.th('Require TLS'), + dom.th('Transport/Retry'), + dom.th('Remove'), + ), + ), + dom.tbody( + (msgs || []).map(m => { + let requiretlsFieldset: HTMLFieldSetElement + let requiretls: HTMLSelectElement + let transport: HTMLSelectElement + return dom.tr( + dom.td(''+m.ID), + dom.td(age(new Date(m.Queued), false, nowSecs)), + dom.td(m.SenderLocalpart+"@"+ipdomainString(m.SenderDomain)), // todo: escaping of localpart + dom.td(m.RecipientLocalpart+"@"+ipdomainString(m.RecipientDomain)), // todo: escaping of localpart + dom.td(formatSize(m.Size)), + dom.td(''+m.Attempts), + dom.td(age(new Date(m.NextAttempt), true, nowSecs)), + dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'), + dom.td(m.LastError || '-'), + dom.td( + dom.form( + requiretlsFieldset=dom.fieldset( + requiretls=dom.select( + attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'), + dom.option('Default', attr.value('')), + dom.option('With RequireTLS', attr.value('yes'), m.RequireTLS === true ? attr.selected('') : []), + dom.option('Fallback to insecure', attr.value('no'), m.RequireTLS === false ? attr.selected('') : []), + ), + ' ', + dom.submitbutton('Save'), + ), + async function submit(e: SubmitEvent) { + e.preventDefault() + try { + requiretlsFieldset.disabled = true + await client.QueueSaveRequireTLS(m.ID, requiretls.value === '' ? null : requiretls.value === 'yes') + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + requiretlsFieldset.disabled = false + } + } + ), + ), + dom.td( + dom.form( + transport=dom.select( + attr.title('Transport to use for delivery attempts. The default is direct delivery, connecting to the MX hosts of the domain.'), + dom.option('(default)', attr.value('')), + Object.keys(transports || []).sort().map(t => dom.option(t, m.Transport === t ? attr.checked('') : [])), + ), + ' ', + dom.submitbutton('Retry now'), + async function submit(e: SubmitEvent) { + e.preventDefault() + const target = e.target! as HTMLButtonElement + try { + target.disabled = true + await client.QueueKick(m.ID, transport.value) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + target.disabled = false + } + window.location.reload() // todo: only refresh the list + } + ), + ), + dom.td( + dom.clickbutton('Remove', async function click(e: MouseEvent) { + e.preventDefault() + if (!window.confirm('Are you sure you want to remove this message? It will be removed completely.')) { + return + } + const target = e.target! as HTMLButtonElement + try { + target.disabled = true + await client.QueueDrop(m.ID) + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + return + } finally { + target.disabled = false + } + window.location.reload() // todo: only refresh the list + }), + ), + ) + }) + ), + ), + ], + ) +} + +const webserver = async () => { + let conf = await client.WebserverConfig() + + // We disable this while saving the form. + let fieldset: HTMLFieldSetElement + + type RedirectRow = { + root: HTMLElement + from: HTMLInputElement + to: HTMLInputElement + get: () => [string, string] + } + + // Keep track of redirects. Rows are objects that hold both the DOM and allows + // retrieving the visible (modified) data to construct a config for saving. + let redirectRows: RedirectRow[] = [] + let redirectsTbody: HTMLElement + let noredirect: HTMLElement + + // Make a new redirect rows, adding it to the list. The caller typically uses this + // while building the DOM, the element is added because this object has it as + // "root" field. + const redirectRow = (t: [api.Domain, api.Domain]) => { + let row: RedirectRow + let from: HTMLInputElement + let to: HTMLInputElement + + const root = dom.tr( + dom.td( + from=dom.input(attr.required(''), attr.value(domainName(t[0]))), + ), + dom.td( + to=dom.input(attr.required(''), attr.value(domainName(t[1]))), + ), + dom.td( + dom.clickbutton('Remove', function click() { + redirectRows = redirectRows.filter(r => r !== row) + row.root.remove() + noredirect.style.display = redirectRows.length ? 'none' : '' + }), + ), + ) + // "get" is the common function to retrieve the data from an object with a root field as DOM element. + const get = (): [string, string] => [from.value, to.value] + row = {root: root, from: from, to: to, get: get} + redirectRows.push(row) + return row + } + + type HeadersView = { + root: HTMLElement + + add: HTMLButtonElement + get: () => { [k: string]: string } + } + + // Reusable component for managing headers. Just a table with a header key and + // value. We can remove existing rows, and add new rows, and edit existing. + const makeHeaders = (h: { [key: string]: string }) => { + let view: HeadersView + let rows: HeaderRow[] = [] + let tbody: HTMLElement + let norow: HTMLElement + + type HeaderRow = { + root: HTMLElement + key: HTMLInputElement + value: HTMLInputElement + get: () => [string, string] + } + + const headerRow = (k: string, v: string) => { + let row: HeaderRow + let key: HTMLInputElement + let value: HTMLInputElement + + const root = dom.tr( + dom.td( + key=dom.input(attr.required(''), attr.value(k)), + ), + dom.td( + value=dom.input(attr.required(''), attr.value(v)), + ), + dom.td( + dom.clickbutton('Remove', function click() { + rows = rows.filter(x => x !== row) + row.root.remove() + norow.style.display = rows.length ? 'none' : '' + }) + ), + ) + const get = (): [string, string] => [row.key.value, row.value.value] + row = {root: root, key: key, value: value, get: get} + rows.push(row) + return row + } + const add = dom.clickbutton('Add', function click() { + const row = headerRow('', '') + tbody.appendChild(row.root) + norow.style.display = rows.length ? 'none' : '' + }) + const root = dom.table( + tbody=dom.tbody( + Object.entries(h).sort().map(t => headerRow(t[0], t[1])), + norow=dom.tr( + style({display: rows.length ? 'none' : ''}), + dom.td(attr.colspan('3'), 'None added.'), + ) + ), + ) + const get = (): { [k: string]: string } => Object.fromEntries(rows.map(row => row.get())) + view = {root: root, add: add, get: get} + return view + } + + + // todo: make a mechanism to get the ../config/config.go sconf-doc struct tags + // here. So we can use them for the titles, as documentation. Instead of current + // approach of copy/pasting those texts, inevitably will get out of date. + + // todo: perhaps lay these out in the same way as in the config file? will help admins mentally map between the two. will take a bit more vertical screen space, but current approach looks messy/garbled. we could use that mechanism for more parts of the configuration file. we can even show the same sconf-doc struct tags. the html admin page will then just be a glorified guided text editor! + + type HandlerRow = { + root: HTMLElement + moveButtons: HTMLElement + get: () => api.WebHandler + } + + // Similar to redirects, but for web handlers. + let handlerRows: HandlerRow[] = [] + let handlersTbody: HTMLElement + let nohandler: HTMLElement + + type WebStaticView = { + root: HTMLElement + get: () => api.WebStatic + } + type WebRedirectView = { + root: HTMLElement + get: () => api.WebRedirect + } + type WebForwardView = { + root: HTMLElement + get: () => api.WebForward + } + + // Make a handler row. This is more complicated, since it can be one of the three + // types (static, redirect, forward), and can change between those types. + const handlerRow = (wh: api.WebHandler) => { + let row: HandlerRow // Shared between the handler types. + let handlerType: string + let staticView: WebStaticView | null = null + let redirectView: WebRedirectView | null = null + let forwardView: WebForwardView | null = null + + let moveButtons: HTMLElement + + const makeWebStatic = (ws: api.WebStatic) => { + let view: WebStaticView + + let stripPrefix: HTMLInputElement + let rootPath: HTMLInputElement + let listFiles: HTMLInputElement + let continueNotFound: HTMLInputElement + let responseHeaders: HeadersView = makeHeaders(ws.ResponseHeaders || {}) + + const get = (): api.WebStatic => { + return { + StripPrefix: stripPrefix.value, + Root: rootPath.value, + ListFiles: listFiles.checked, + ContinueNotFound: continueNotFound.checked, + ResponseHeaders: responseHeaders.get(), + } + } + const root = dom.table( + dom.tr( + dom.td('Type'), + dom.td( + 'StripPrefix', + attr.title('Path to strip from the request URL before evaluating to a local path. If the requested URL path does not start with this prefix and ContinueNotFound it is considered non-matching and next WebHandlers are tried. If ContinueNotFound is not set, a file not found (404) is returned in that case.'), + ), + dom.td( + 'Root', + attr.title('Directory to serve files from for this handler. Keep in mind that relative paths are relative to the working directory of mox.'), + ), + dom.td( + 'ListFiles', + attr.title('If set, and a directory is requested, and no index.html is present that can be served, a file listing is returned. Results in 403 if ListFiles is not set. If a directory is requested and the URL does not end with a slash, the response is a redirect to the path with trailing slash.'), + ), + dom.td( + 'ContinueNotFound', + attr.title("If a requested URL does not exist, don't return a file not found (404) response, but consider this handler non-matching and continue attempts to serve with later WebHandlers, which may be a reverse proxy generating dynamic content, possibly even writing a static file for a next request to serve statically. If ContinueNotFound is set, HTTP requests other than GET and HEAD do not match. This mechanism can be used to implement the equivalent of 'try_files' in other webservers."), + ), + dom.td( + dom.span( + 'Response headers', + attr.title('Headers to add to the response. Useful for cache-control, content-type, etc. By default, Content-Type headers are automatically added for recognized file types, unless added explicitly through this setting. For directory listings, a content-type header is skipped.'), + ), + ' ', + responseHeaders.add, + ), + ), + dom.tr( + dom.td( + dom.select( + attr.required(''), + dom.option('Static', attr.selected('')), + dom.option('Redirect'), + dom.option('Forward'), + function change(e: MouseEvent) { + makeType((e.target! as HTMLSelectElement).value) + }, + ), + ), + dom.td( + stripPrefix=dom.input(attr.value(ws.StripPrefix || '')), + ), + dom.td( + rootPath=dom.input(attr.required(''), attr.placeholder('web/...'), attr.value(ws.Root || '')), + ), + dom.td( + listFiles=dom.input(attr.type('checkbox'), ws.ListFiles ? attr.checked('') : []), + ), + dom.td( + continueNotFound=dom.input(attr.type('checkbox'), ws.ContinueNotFound ? attr.checked('') : []), + ), + dom.td( + responseHeaders, + ), + ) + ) + view = {root: root, get: get} + return view + } + + const makeWebRedirect = (wr: api.WebRedirect) => { + let view: WebRedirectView + + let baseURL: HTMLInputElement + let origPathRegexp: HTMLInputElement + let replacePath: HTMLInputElement + let statusCode: HTMLInputElement + + const get = (): api.WebRedirect => { + return { + BaseURL: baseURL.value, + OrigPathRegexp: origPathRegexp.value, + ReplacePath: replacePath.value, + StatusCode: statusCode.value ? parseInt(statusCode.value) : 0, + } + } + const root = dom.table( + dom.tr( + dom.td('Type'), + dom.td( + 'BaseURL', + attr.title('Base URL to redirect to. The path must be empty and will be replaced, either by the request URL path, or by OrigPathRegexp/ReplacePath. Scheme, host, port and fragment stay intact, and query strings are combined. If empty, the response redirects to a different path through OrigPathRegexp and ReplacePath, which must then be set. Use a URL without scheme to redirect without changing the protocol, e.g. //newdomain/. If a redirect would send a request to a URL with the same scheme, host and path, the WebRedirect does not match so a next WebHandler can be tried. This can be used to redirect all plain http traffic to https.'), + ), + dom.td( + 'OrigPathRegexp', + attr.title('Regular expression for matching path. If set and path does not match, a 404 is returned. The HTTP path used for matching always starts with a slash.'), + ), + dom.td( + 'ReplacePath', + attr.title("Replacement path for destination URL based on OrigPathRegexp. Implemented with Go's Regexp.ReplaceAllString: $1 is replaced with the text of the first submatch, etc. If both OrigPathRegexp and ReplacePath are empty, BaseURL must be set and all paths are redirected unaltered."), + ), + dom.td( + 'StatusCode', + attr.title('Status code to use in redirect, e.g. 307. By default, a permanent redirect (308) is returned.'), + ), + ), + dom.tr( + dom.td( + dom.select( + attr.required(''), + dom.option('Static'), + dom.option('Redirect', attr.selected('')), + dom.option('Forward'), + function change(e: MouseEvent) { + makeType((e.target! as HTMLSelectElement).value) + }, + ), + ), + dom.td( + baseURL=dom.input(attr.placeholder('empty or https://target/path?q=1#frag or //target/...'), attr.value(wr.BaseURL || '')), + ), + dom.td( + origPathRegexp=dom.input(attr.placeholder('^/old/(.*)'), attr.value(wr.OrigPathRegexp || '')), + ), + dom.td( + replacePath=dom.input(attr.placeholder('/new/$1'), attr.value(wr.ReplacePath || '')), + ), + dom.td( + statusCode=dom.input(style({width: '4em'}), attr.type('number'), attr.value(wr.StatusCode ? ''+wr.StatusCode : ''), attr.min('300'), attr.max('399')), + ), + ), + ) + view = {root: root, get: get} + return view + } + + const makeWebForward = (wf: api.WebForward) => { + let view: WebForwardView + + let stripPath: HTMLInputElement + let url: HTMLInputElement + let responseHeaders: HeadersView = makeHeaders(wf.ResponseHeaders || {}) + + const get = (): api.WebForward => { + return { + StripPath: stripPath.checked, + URL: url.value, + ResponseHeaders: responseHeaders.get(), + } + } + const root = dom.table( + dom.tr( + dom.td('Type'), + dom.td( + 'StripPath', + attr.title('Strip the matching WebHandler path from the WebHandler before forwarding the request.'), + ), + dom.td( + 'URL', + attr.title("URL to forward HTTP requests to, e.g. http://127.0.0.1:8123/base. If StripPath is false the full request path is added to the URL. Host headers are sent unmodified. New X-Forwarded-{For,Host,Proto} headers are set. Any query string in the URL is ignored. Requests are made using Go's net/http.DefaultTransport that takes environment variables HTTP_PROXY and HTTPS_PROXY into account. Websocket connections are forwarded and data is copied between client and backend without looking at the framing. The websocket 'version' and 'key'/'accept' headers are verified during the handshake, but other websocket headers, including 'origin', 'protocol' and 'extensions' headers, are not inspected and the backend is responsible for verifying/interpreting them."), + ), + dom.td( + dom.span( + 'Response headers', + attr.title('Headers to add to the response. Useful for adding security- and cache-related headers.'), + ), + ' ', + responseHeaders.add, + ), + ), + dom.tr( + dom.td( + dom.select( + attr.required(''), + dom.option('Static', ), + dom.option('Redirect'), + dom.option('Forward', attr.selected('')), + function change(e: MouseEvent) { + makeType((e.target! as HTMLSelectElement).value) + }, + ), + ), + dom.td( + stripPath=dom.input(attr.type('checkbox'), wf.StripPath || wf.StripPath === undefined ? attr.checked('') : []), + ), + dom.td( + url=dom.input(attr.required(''), attr.placeholder('http://127.0.0.1:8888'), attr.value(wf.URL || '')), + ), + dom.td( + responseHeaders, + ), + ), + ) + view = {root: root, get: get} + return view + } + + let logName: HTMLInputElement + let domain: HTMLInputElement + let pathRegexp: HTMLInputElement + let toHTTPS: HTMLInputElement + let compress: HTMLInputElement + + let details: HTMLElement + + const detailsRoot = (root: HTMLElement) => { + details.replaceWith(root) + details = root + } + + // Transform the input fields to match the type of WebHandler. + const makeType = (s: string) => { + if (s === 'Static') { + staticView = makeWebStatic(wh.WebStatic || { + StripPrefix: '', + Root: '', + ListFiles: false, + ContinueNotFound: false, + ResponseHeaders: {}, + }) + detailsRoot(staticView.root) + } else if (s === 'Redirect') { + redirectView = makeWebRedirect(wh.WebRedirect || { + BaseURL: '', + OrigPathRegexp: '', + ReplacePath: '', + StatusCode: 0, + }) + detailsRoot(redirectView.root) + } else if (s === 'Forward') { + forwardView = makeWebForward(wh.WebForward || { + StripPath: false, + URL: '', + ResponseHeaders: {}, + }) + detailsRoot(forwardView.root) + } else { + throw new Error('unknown handler type') + } + handlerType = s + } + + // Remove row from oindex, insert it in nindex. Both in handlerRows and in the DOM. + const moveHandler = (row: HandlerRow, oindex: number, nindex: number) => { + row.root.remove() + handlersTbody.insertBefore(row.root, handlersTbody.children[nindex]) + handlerRows.splice(oindex, 1) + handlerRows.splice(nindex, 0, row) + } + + // Row that starts starts with two tables: one for the fields all WebHandlers have + // (in common). And one for the details, i.e. WebStatic, WebRedirect, WebForward. + const root = dom.tr( + dom.td( + dom.table( + dom.tr( + dom.td('LogName', attr.title('Name used during logging for requests matching this handler. If empty, the index of the handler in the list is used.')), + dom.td('Domain', attr.title('Request must be for this domain to match this handler.')), + dom.td('Path Regexp', attr.title('Request must match this path regular expression to match this handler. Must start with with a ^.')), + dom.td('To HTTPS', attr.title('Redirect plain HTTP (non-TLS) requests to HTTPS.')), + dom.td('Compress', attr.title('Transparently compress responses (currently with gzip) if the client supports it, the status is 200 OK, no Content-Encoding is set on the response yet and the Content-Type of the response hints that the data is compressible (text/..., specific application/... and .../...+json and .../...+xml). For static files only, a cache with compressed files is kept.')), + ), + dom.tr( + dom.td( + logName=dom.input(attr.value(wh.LogName || '')), + ), + dom.td( + domain=dom.input(attr.required(''), attr.placeholder('example.org'), attr.value(domainName(wh.DNSDomain))), + ), + dom.td( + pathRegexp=dom.input(attr.required(''), attr.placeholder('^/'), attr.value(wh.PathRegexp || '')), + ), + dom.td( + toHTTPS=dom.input(attr.type('checkbox'), attr.title('Redirect plain HTTP (non-TLS) requests to HTTPS'), !wh.DontRedirectPlainHTTP ? attr.checked('') : []), + ), + dom.td( + compress=dom.input(attr.type('checkbox'), attr.title('Transparently compress responses.'), wh.Compress ? attr.checked('') : []), + ), + ), + ), + // Replaced with a call to makeType, below (and later when switching types). + details=dom.table(), + ), + dom.td( + dom.td( + dom.clickbutton('Remove', function click() { + handlerRows = handlerRows.filter(r => r !== row) + row.root.remove() + nohandler.style.display = handlerRows.length ? 'none' : '' + }), + ' ', + // We show/hide the buttons to move when clicking the Move button. + moveButtons=dom.span( + style({display: 'none'}), + dom.clickbutton('↑↑', attr.title('Move to top.'), function click() { + const index = handlerRows.findIndex(r => r === row) + if (index > 0) { + moveHandler(row, index, 0) + } + }), + ' ', + dom.clickbutton('↑', attr.title('Move one up.'), function click() { + const index = handlerRows.findIndex(r => r === row) + if (index > 0) { + moveHandler(row, index, index-1) + } + }), + ' ', + dom.clickbutton('↓', attr.title('Move one down.'), function click() { + const index = handlerRows.findIndex(r => r === row) + if (index+1 < handlerRows.length) { + moveHandler(row, index, index+1) + } + }), + ' ', + dom.clickbutton('↓↓', attr.title('Move to bottom.'), function click() { + const index = handlerRows.findIndex(r => r === row) + if (index+1 < handlerRows.length) { + moveHandler(row, index, handlerRows.length-1) + } + }), + ), + ), + ), + ) + + // Final "get" that returns a WebHandler that reflects the UI. + const get = (): api.WebHandler => { + const wh: api.WebHandler = { + LogName: logName.value, + Domain: domain.value, + PathRegexp: pathRegexp.value, + DontRedirectPlainHTTP: !toHTTPS.checked, + Compress: compress.checked, + Name: '', + DNSDomain: {ASCII: '', Unicode: ''}, + } + if (handlerType === 'Static' && staticView != null) { + wh.WebStatic = staticView.get() + } else if (handlerType === 'Redirect' && redirectView !== null) { + wh.WebRedirect = redirectView.get() + } else if (handlerType === 'Forward' && forwardView !== null) { + wh.WebForward = forwardView.get() + } else { + throw new Error('unknown WebHandler type') + } + return wh + } + + // Initialize one of the Web* types. + if (wh.WebStatic) { + handlerType = 'Static' + } else if (wh.WebRedirect) { + handlerType = 'Redirect' + } else if (wh.WebForward) { + handlerType = 'Forward' + } else { + throw new Error('unknown WebHandler type') + } + makeType(handlerType) + + row = {root: root, moveButtons: moveButtons, get: get} + handlerRows.push(row) + return row + } + + // Return webserver config to store. + const gatherConf = () => { + return { + WebDomainRedirects: redirectRows.map(row => row.get()), + WebHandlers: handlerRows.map(row => row.get()), + } + } + + // Add and move buttons, both above and below the table for quick access, hence a function. + const handlerActions = () => { + return [ + 'Action ', + dom.clickbutton('Add', function click() { + // New WebHandler added as WebForward. Good chance this is what the user wants. And + // it has the least fields. (; + const nwh: api.WebHandler = { + LogName: '', + Domain: '', + PathRegexp: '^/', + DontRedirectPlainHTTP: false, + Compress: false, + WebForward: { + StripPath: true, + URL: '', + }, + Name: '', + DNSDomain: {ASCII: '', Unicode: ''}, + } + const row = handlerRow(nwh) + handlersTbody.appendChild(row.root) + nohandler.style.display = handlerRows.length ? 'none' : '' + }), + ' ', + dom.clickbutton('Move', function click() { + for(const row of handlerRows) { + row.moveButtons.style.display = row.moveButtons.style.display === 'none' ? '' : 'none' + } + }), + ] + } + + 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() { + const row = redirectRow([{ASCII: '', Unicode: ''}, {ASCII: '', Unicode: ''}]) + redirectsTbody.appendChild(row.root) + noredirect.style.display = redirectRows.length ? 'none' : '' + }), + ), + ), + ), + redirectsTbody=dom.tbody( + (conf.WebDNSDomainRedirects || []).sort().map(t => redirectRow([t![0], t![1]])), + noredirect=dom.tr( + style({display: redirectRows.length ? 'none' : ''}), + dom.td(attr.colspan('3'), 'No redirects.'), + ), + ), + ), + dom.br(), + dom.h2('Handlers', attr.title('Corresponds with WebHandlers in domains.conf')), + dom.p('Each incoming request is check against these handlers, in order. The first matching handler serves the request. Don\'t forget to save after making a change.'), + dom.table(dom._class('long'), + dom.thead( + dom.tr( + dom.th(), + dom.th(handlerActions()), + ), + ), + handlersTbody=dom.tbody( + (conf.WebHandlers || []).map(wh => handlerRow(wh)), + nohandler=dom.tr( + style({display: handlerRows.length ? 'none' : ''}), + dom.td(attr.colspan('2'), 'No handlers.'), + ), + ), + dom.tfoot( + dom.tr( + dom.th(), + dom.th(handlerActions()), + ), + ), + ), + dom.br(), + dom.submitbutton('Save', attr.title('Save config. If the configuration has changed since this page was loaded, an error will be returned. After saving, the changes take effect immediately.')), + ), + async function submit(e: SubmitEvent) { + e.preventDefault() + e.stopPropagation() + + fieldset.disabled = true + try { + const newConf = gatherConf() + const savedConf = await client.WebserverConfigSave(conf, newConf) + conf = savedConf + } catch (err) { + console.log({err}) + window.alert('Error: ' + errmsg(err)) + } finally { + fieldset.disabled = false + } + } + ), + ) +} + +const init = async () => { + let curhash: string | undefined + + const hashChange = async () => { + if (curhash === window.location.hash) { + return + } + let h = decodeURIComponent(window.location.hash) + if (h !== '' && h.substring(0, 1) == '#') { + h = h.substring(1) + } + const t = h.split('/') + page.classList.add('loading') + try { + if (h == '') { + await index() + } else if (h === 'config') { + await config() + } else if (h === 'loglevels') { + await loglevels() + } else if (h === 'accounts') { + await accounts() + } else if (t[0] === 'accounts' && t.length === 2) { + await account(t[1]) + } else if (t[0] === 'domains' && t.length === 2) { + await domain(t[1]) + } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dmarc') { + await domainDMARC(t[1]) + } else if (t[0] === 'domains' && t.length === 4 && t[2] === 'dmarc' && parseInt(t[3])) { + await domainDMARCReport(t[1], parseInt(t[3])) + } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnscheck') { + await domainDNSCheck(t[1]) + } else if (t[0] === 'domains' && t.length === 3 && t[2] === 'dnsrecords') { + await domainDNSRecords(t[1]) + } else if (h === 'queue') { + await queueList() + } else if (h === 'tlsrpt') { + await tlsrptIndex() + } else if (h === 'tlsrpt/reports') { + await tlsrptReports() + } else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 3) { + await domainTLSRPT(t[2]) + } else if (t[0] === 'tlsrpt' && t[1] === 'reports' && t.length === 4 && parseInt(t[3])) { + await domainTLSRPTID(t[2], parseInt(t[3])) + } else if (h === 'tlsrpt/results') { + await tlsrptResults() + } else if (t[0] == 'tlsrpt' && t[1] == 'results' && (t[2] === 'rcptdom' || t[2] == 'host') && t.length === 4) { + await tlsrptResultsPolicyDomain(t[2] === 'rcptdom', t[3]) + } else if (h === 'dmarc') { + await dmarcIndex() + } else if (h === 'dmarc/reports') { + await dmarcReports() + } else if (h === 'dmarc/evaluations') { + await dmarcEvaluations() + } else if (t[0] == 'dmarc' && t[1] == 'evaluations' && t.length === 3) { + await dmarcEvaluationsDomain(t[2]) + } else if (h === 'mtasts') { + await mtasts() + } else if (h === 'dnsbl') { + await dnsbl() + } else if (h === 'webserver') { + await webserver() + } else { + dom._kids(page, 'page not found') + } + } catch (err) { + console.log('error', err) + window.alert('Error: ' + errmsg(err)) + curhash = window.location.hash + return + } + curhash = window.location.hash + page.classList.remove('loading') + } + window.addEventListener('hashchange', hashChange) + hashChange() +} + +window.addEventListener('load', init) diff --git a/webadmin/adminapi.json b/webadmin/api.json similarity index 98% rename from webadmin/adminapi.json rename to webadmin/api.json index 7351c5e..90513d5 100644 --- a/webadmin/adminapi.json +++ b/webadmin/api.json @@ -2157,7 +2157,6 @@ "Typewords": [ "{}", "[]", - "nullable", "SRV" ] }, @@ -2496,38 +2495,38 @@ }, { "Name": "Report", - "Docs": "Report is a TLSRPT report, transmitted in JSON format.", + "Docs": "Report is a TLSRPT report.", "Fields": [ { - "Name": "organization-name", + "Name": "OrganizationName", "Docs": "", "Typewords": [ "string" ] }, { - "Name": "date-range", + "Name": "DateRange", "Docs": "", "Typewords": [ "TLSRPTDateRange" ] }, { - "Name": "contact-info", - "Docs": "Email address.", - "Typewords": [ - "string" - ] - }, - { - "Name": "report-id", + "Name": "ContactInfo", "Docs": "", "Typewords": [ "string" ] }, { - "Name": "policies", + "Name": "ReportID", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "Policies", "Docs": "", "Typewords": [ "[]", @@ -2541,14 +2540,14 @@ "Docs": "note: with TLSRPT prefix to prevent clash in sherpadoc types.", "Fields": [ { - "Name": "start-datetime", + "Name": "Start", "Docs": "", "Typewords": [ "timestamp" ] }, { - "Name": "end-datetime", + "Name": "End", "Docs": "", "Typewords": [ "timestamp" @@ -2561,21 +2560,21 @@ "Docs": "", "Fields": [ { - "Name": "policy", + "Name": "Policy", "Docs": "", "Typewords": [ "ResultPolicy" ] }, { - "Name": "summary", + "Name": "Summary", "Docs": "", "Typewords": [ "Summary" ] }, { - "Name": "failure-details", + "Name": "FailureDetails", "Docs": "", "Typewords": [ "[]", @@ -2589,14 +2588,14 @@ "Docs": "", "Fields": [ { - "Name": "policy-type", + "Name": "Type", "Docs": "", "Typewords": [ "PolicyType" ] }, { - "Name": "policy-string", + "Name": "String", "Docs": "", "Typewords": [ "[]", @@ -2604,15 +2603,15 @@ ] }, { - "Name": "policy-domain", + "Name": "Domain", "Docs": "", "Typewords": [ "string" ] }, { - "Name": "mx-host", - "Docs": "Example in RFC has errata, it originally was a single string. ../rfc/8460-eid6241 ../rfc/8460:1779", + "Name": "MXHost", + "Docs": "", "Typewords": [ "[]", "string" @@ -2625,14 +2624,14 @@ "Docs": "", "Fields": [ { - "Name": "total-successful-session-count", + "Name": "TotalSuccessfulSessionCount", "Docs": "", "Typewords": [ "int64" ] }, { - "Name": "total-failure-session-count", + "Name": "TotalFailureSessionCount", "Docs": "", "Typewords": [ "int64" @@ -2645,57 +2644,56 @@ "Docs": "", "Fields": [ { - "Name": "result-type", + "Name": "ResultType", "Docs": "", "Typewords": [ "ResultType" ] }, { - "Name": "sending-mta-ip", + "Name": "SendingMTAIP", "Docs": "", "Typewords": [ "string" ] }, { - "Name": "receiving-mx-hostname", + "Name": "ReceivingMXHostname", "Docs": "", "Typewords": [ "string" ] }, { - "Name": "receiving-mx-helo", - "Docs": "", - "Typewords": [ - "nullable", - "string" - ] - }, - { - "Name": "receiving-ip", + "Name": "ReceivingMXHelo", "Docs": "", "Typewords": [ "string" ] }, { - "Name": "failed-session-count", + "Name": "ReceivingIP", + "Docs": "", + "Typewords": [ + "string" + ] + }, + { + "Name": "FailedSessionCount", "Docs": "", "Typewords": [ "int64" ] }, { - "Name": "additional-information", + "Name": "AdditionalInformation", "Docs": "", "Typewords": [ "string" ] }, { - "Name": "failure-reason-code", + "Name": "FailureReasonCode", "Docs": "", "Typewords": [ "string" diff --git a/webadmin/api.ts b/webadmin/api.ts new file mode 100644 index 0000000..f58be5c --- /dev/null +++ b/webadmin/api.ts @@ -0,0 +1,1879 @@ +// NOTE: GENERATED by github.com/mjl-/sherpats, DO NOT MODIFY + +namespace api { + +// CheckResult is the analysis of a domain, its actual configuration (DNS, TLS, +// connectivity) and the mox configuration. It includes configuration instructions +// (e.g. DNS records), and warnings and errors encountered. +export interface CheckResult { + Domain: string + DNSSEC: DNSSECResult + IPRev: IPRevCheckResult + MX: MXCheckResult + TLS: TLSCheckResult + DANE: DANECheckResult + SPF: SPFCheckResult + DKIM: DKIMCheckResult + DMARC: DMARCCheckResult + HostTLSRPT: TLSRPTCheckResult + DomainTLSRPT: TLSRPTCheckResult + MTASTS: MTASTSCheckResult + SRVConf: SRVConfCheckResult + Autoconf: AutoconfCheckResult + Autodiscover: AutodiscoverCheckResult +} + +export interface DNSSECResult { + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +export interface IPRevCheckResult { + Hostname: Domain // This hostname, IPs must resolve back to this. + IPNames?: { [key: string]: string[] | null } // IP to names. + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +// Domain is a domain name, with one or more labels, with at least an ASCII +// representation, and for IDNA non-ASCII domains a unicode representation. +// The ASCII string must be used for DNS lookups. The strings do not have a +// trailing dot. When using with StrictResolver, add the trailing dot. +export interface Domain { + ASCII: string // A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case. No trailing dot. + Unicode: string // Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot. +} + +export interface MXCheckResult { + Records?: MX[] | null + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +export interface MX { + Host: string + Pref: number + IPs?: string[] | null +} + +export interface TLSCheckResult { + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +export interface DANECheckResult { + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +export interface SPFCheckResult { + DomainTXT: string + DomainRecord?: SPFRecord | null + HostTXT: string + HostRecord?: SPFRecord | null + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +export interface SPFRecord { + Version: string // Must be "spf1". + Directives?: Directive[] | null // An IP is evaluated against each directive until a match is found. + Redirect: string // Modifier that redirects SPF checks to other domain after directives did not match. Optional. For "redirect=". + Explanation: string // Modifier for creating a user-friendly error message when an IP results in status "fail". + Other?: Modifier[] | null // Other modifiers. +} + +// Directive consists of a mechanism that describes how to check if an IP matches, +// an (optional) qualifier indicating the policy for a match, and optional +// parameters specific to the mechanism. +export interface Directive { + Qualifier: string // Sets the result if this directive matches. "" and "+" are "pass", "-" is "fail", "?" is "neutral", "~" is "softfail". + Mechanism: string // "all", "include", "a", "mx", "ptr", "ip4", "ip6", "exists". + DomainSpec: string // For include, a, mx, ptr, exists. Always in lower-case when parsed using ParseRecord. + IPstr: string // Original string for IP, always with /subnet. + IP4CIDRLen?: number | null // For a, mx, ip4. + IP6CIDRLen?: number | null // For a, mx, ip6. +} + +// Modifier provides additional information for a policy. +// "redirect" and "exp" are not represented as a Modifier but explicitly in a Record. +export interface Modifier { + Key: string // Key is case-insensitive. + Value: string +} + +export interface DKIMCheckResult { + Records?: DKIMRecord[] | null + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +export interface DKIMRecord { + Selector: string + TXT: string + Record?: Record | null +} + +// Record is a DKIM DNS record, served on ._domainkey. for a +// given selector and domain (s= and d= in the DKIM-Signature). +// +// The record is a semicolon-separated list of "="-separated field value pairs. +// Strings should be compared case-insensitively, e.g. k=ed25519 is equivalent to k=ED25519. +// +// Example: +// +// v=DKIM1;h=sha256;k=ed25519;p=ln5zd/JEX4Jy60WAhUOv33IYm2YZMyTQAdr9stML504= +export interface Record { + Version: string // Version, fixed "DKIM1" (case sensitive). Field "v". + Hashes?: string[] | null // Acceptable hash algorithms, e.g. "sha1", "sha256". Optional, defaults to all algorithms. Field "h". + Key: string // Key type, "rsa" or "ed25519". Optional, default "rsa". Field "k". + Notes: string // Debug notes. Field "n". + Pubkey?: string | null // Public key, as base64 in record. If empty, the key has been revoked. Field "p". + Services?: string[] | null // Service types. Optional, default "*" for all services. Other values: "email". Field "s". + Flags?: string[] | null // Flags, colon-separated. Optional, default is no flags. Other values: "y" for testing DKIM, "s" for "i=" must have same domain as "d" in signatures. Field "t". +} + +export interface DMARCCheckResult { + Domain: string + TXT: string + Record?: DMARCRecord | null + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +export interface DMARCRecord { + Version: string // "v=DMARC1", fixed. + Policy: DMARCPolicy // Required, for "p=". + SubdomainPolicy: DMARCPolicy // Like policy but for subdomains. Optional, for "sp=". + AggregateReportAddresses?: URI[] | null // Optional, for "rua=". Destination addresses for aggregate reports. + FailureReportAddresses?: URI[] | null // Optional, for "ruf=". Destination addresses for failure reports. + ADKIM: Align // Alignment: "r" (default) for relaxed or "s" for simple. For "adkim=". + ASPF: Align // Alignment: "r" (default) for relaxed or "s" for simple. For "aspf=". + AggregateReportingInterval: number // In seconds, default 86400. For "ri=" + FailureReportingOptions?: string[] | null // "0" (default), "1", "d", "s". For "fo=". + ReportingFormat?: string[] | null // "afrf" (default). For "rf=". + Percentage: number // Between 0 and 100, default 100. For "pct=". Policy applies randomly to this percentage of messages. +} + +// URI is a destination address for reporting. +export interface URI { + Address: string // Should start with "mailto:". + MaxSize: number // Optional maximum message size, subject to Unit. + Unit: string // "" (b), "k", "m", "g", "t" (case insensitive), unit size, where k is 2^10 etc. +} + +export interface TLSRPTCheckResult { + TXT: string + Record?: TLSRPTRecord | null + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +export interface TLSRPTRecord { + Version: string // "TLSRPTv1", for "v=". + RUAs?: (RUA[] | null)[] | null // Aggregate reporting URI, for "rua=". "rua=" can occur multiple times, each can be a list. + Extensions?: Extension[] | null +} + +// Extension is an additional key/value pair for a TLSRPT record. +export interface Extension { + Key: string + Value: string +} + +export interface MTASTSCheckResult { + TXT: string + Record?: MTASTSRecord | null + PolicyText: string + Policy?: Policy | null + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +export interface MTASTSRecord { + Version: string // "STSv1", for "v=". Required. + ID: string // Record version, for "id=". Required. + Extensions?: Pair[] | null // Optional extensions. +} + +// Pair is an extension key/value pair in a MTA-STS DNS record or policy. +export interface Pair { + Key: string + Value: string +} + +// Policy is an MTA-STS policy as served at "https://mta-sts./.well-known/mta-sts.txt". +export interface Policy { + Version: string // "STSv1" + Mode: Mode + MX?: STSMX[] | null + MaxAgeSeconds: number // How long this policy can be cached. Suggested values are in weeks or more. + Extensions?: Pair[] | null +} + +// STSMX is an allowlisted MX host name/pattern. +// todo: find a way to name this just STSMX without getting duplicate names for "MX" in the sherpa api. +export interface STSMX { + Wildcard: boolean // "*." wildcard, e.g. if a subdomain matches. A wildcard must match exactly one label. *.example.com matches mail.example.com, but not example.com, and not foor.bar.example.com. + Domain: Domain +} + +export interface SRVConfCheckResult { + SRVs?: { [key: string]: SRV[] | null } // Service (e.g. "_imaps") to records. + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +// An SRV represents a single DNS SRV record. +export interface SRV { + Target: string + Port: number + Priority: number + Weight: number +} + +export interface AutoconfCheckResult { + ClientSettingsDomainIPs?: string[] | null + IPs?: string[] | null + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +export interface AutodiscoverCheckResult { + Records?: AutodiscoverSRV[] | null + Errors?: string[] | null + Warnings?: string[] | null + Instructions?: string[] | null +} + +export interface AutodiscoverSRV { + Target: string + Port: number + Priority: number + Weight: number + IPs?: string[] | null +} + +// PolicyRecord is a cached policy or absence of a policy. +export interface PolicyRecord { + Domain: string // Domain name, with unicode characters. + Inserted: Date + ValidEnd: Date + LastUpdate: Date // Policies are refreshed on use and periodically. + LastUse: Date + Backoff: boolean + RecordID: string // As retrieved from DNS. + Version: string // "STSv1" + Mode: Mode + MX?: STSMX[] | null + MaxAgeSeconds: number // How long this policy can be cached. Suggested values are in weeks or more. + Extensions?: Pair[] | null + PolicyText: string // Text that make up the policy, as retrieved. We didn't store this in the past. If empty, policy can be reconstructed from Policy field. Needed by TLSRPT. +} + +// TLSReportRecord is a TLS report as a database record, including information +// about the sender. +// +// todo: should be named just Record, but it would cause a sherpa type name conflict. +export interface TLSReportRecord { + ID: number + Domain: string // Policy domain to which the TLS report applies. Unicode. + FromDomain: string + MailFrom: string + HostReport: boolean // Report for host TLSRPT record, as opposed to domain TLSRPT record. + Report: Report +} + +// Report is a TLSRPT report. +export interface Report { + OrganizationName: string + DateRange: TLSRPTDateRange + ContactInfo: string + ReportID: string + Policies?: Result[] | null +} + +// note: with TLSRPT prefix to prevent clash in sherpadoc types. +export interface TLSRPTDateRange { + Start: Date + End: Date +} + +export interface Result { + Policy: ResultPolicy + Summary: Summary + FailureDetails?: FailureDetails[] | null +} + +export interface ResultPolicy { + Type: PolicyType + String?: string[] | null + Domain: string + MXHost?: string[] | null +} + +export interface Summary { + TotalSuccessfulSessionCount: number + TotalFailureSessionCount: number +} + +export interface FailureDetails { + ResultType: ResultType + SendingMTAIP: string + ReceivingMXHostname: string + ReceivingMXHelo: string + ReceivingIP: string + FailedSessionCount: number + AdditionalInformation: string + FailureReasonCode: string +} + +// TLSRPTSummary presents TLS reporting statistics for a single domain +// over a period. +export interface TLSRPTSummary { + PolicyDomain: Domain + Success: number + Failure: number + ResultTypeCounts?: { [key: string]: number } +} + +// DomainFeedback is a single report stored in the database. +export interface DomainFeedback { + ID: number + Domain: string // Domain where DMARC DNS record was found, could be organizational domain. + FromDomain: string // Domain in From-header. + Version: string + ReportMetadata: ReportMetadata + PolicyPublished: PolicyPublished + Records?: ReportRecord[] | null +} + +export interface ReportMetadata { + OrgName: string + Email: string + ExtraContactInfo: string + ReportID: string + DateRange: DateRange + Errors?: string[] | null +} + +export interface DateRange { + Begin: number + End: number +} + +// PolicyPublished is the policy as found in DNS for the domain. +export interface PolicyPublished { + Domain: string // Domain is where DMARC record was found, not necessarily message From. Reports we generate use unicode names, incoming reports may have either ASCII-only or Unicode domains. + ADKIM: Alignment + ASPF: Alignment + Policy: Disposition + SubdomainPolicy: Disposition + Percentage: number + ReportingOptions: string +} + +export interface ReportRecord { + Row: Row + Identifiers: Identifiers + AuthResults: AuthResults +} + +export interface Row { + SourceIP: string // SourceIP must match the pattern ((1?[0-9]?[0-9]|2[0-4][0-9]|25[0-5]).){3} (1?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])| ([A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4} + Count: number + PolicyEvaluated: PolicyEvaluated +} + +export interface PolicyEvaluated { + Disposition: Disposition + DKIM: DMARCResult + SPF: DMARCResult + Reasons?: PolicyOverrideReason[] | null +} + +export interface PolicyOverrideReason { + Type: PolicyOverride + Comment: string +} + +export interface Identifiers { + EnvelopeTo: string + EnvelopeFrom: string + HeaderFrom: string +} + +export interface AuthResults { + DKIM?: DKIMAuthResult[] | null + SPF?: SPFAuthResult[] | null +} + +export interface DKIMAuthResult { + Domain: string + Selector: string + Result: DKIMResult + HumanResult: string +} + +export interface SPFAuthResult { + Domain: string + Scope: SPFDomainScope + Result: SPFResult +} + +// DMARCSummary presents DMARC aggregate reporting statistics for a single domain +// over a period. +export interface DMARCSummary { + Domain: string + Total: number + DispositionNone: number + DispositionQuarantine: number + DispositionReject: number + DKIMFail: number + SPFFail: number + PolicyOverrides?: { [key: string]: number } +} + +// Reverse is the result of a reverse lookup. +export interface Reverse { + Hostnames?: string[] | null +} + +// ClientConfigs holds the client configuration for IMAP/Submission for a +// domain. +export interface ClientConfigs { + Entries?: ClientConfigsEntry[] | null +} + +export interface ClientConfigsEntry { + Protocol: string + Host: Domain + Port: number + Listener: string + Note: string +} + +// Msg is a message in the queue. +// +// Use MakeMsg to make a message with fields that Add needs. Add will further set +// queueing related fields. +export interface Msg { + ID: number + Queued: Date + SenderAccount: string // Failures are delivered back to this local account. Also used for routing. + SenderLocalpart: Localpart // Should be a local user and domain. + SenderDomain: IPDomain + RecipientLocalpart: Localpart // Typically a remote user and domain. + RecipientDomain: IPDomain + RecipientDomainStr: string // For filtering. + Attempts: number // Next attempt is based on last attempt and exponential back off based on attempts. + MaxAttempts: number // Max number of attempts before giving up. If 0, then the default of 8 attempts is used instead. + DialedIPs?: { [key: string]: IP[] | null } // For each host, the IPs that were dialed. Used for IP selection for later attempts. + NextAttempt: Date // For scheduling. + LastAttempt?: Date | null + LastError: string + Has8bit: boolean // Whether message contains bytes with high bit set, determines whether 8BITMIME SMTP extension is needed. + SMTPUTF8: boolean // Whether message requires use of SMTPUTF8. + IsDMARCReport: boolean // Delivery failures for DMARC reports are handled differently. + IsTLSReport: boolean // Delivery failures for TLS reports are handled differently. + Size: number // Full size of message, combined MsgPrefix with contents of message file. + MessageID: string // Used when composing a DSN, in its References header. + MsgPrefix?: string | null + DSNUTF8?: string | null // If set, this message is a DSN and this is a version using utf-8, for the case the remote MTA supports smtputf8. In this case, Size and MsgPrefix are not relevant. + Transport: string // If non-empty, the transport to use for this message. Can be set through cli or admin interface. If empty (the default for a submitted message), regular routing rules apply. + RequireTLS?: boolean | null // RequireTLS influences TLS verification during delivery. If nil, the recipient domain policy is followed (MTA-STS and/or DANE), falling back to optional opportunistic non-verified STARTTLS. If RequireTLS is true (through SMTP REQUIRETLS extension or webmail submit), MTA-STS or DANE is required, as well as REQUIRETLS support by the next hop server. If RequireTLS is false (through messag header "TLS-Required: No"), the recipient domain's policy is ignored if it does not lead to a successful TLS connection, i.e. falling back to SMTP delivery with unverified STARTTLS or plain text. +} + +// IPDomain is an ip address, a domain, or empty. +export interface IPDomain { + IP: IP + Domain: Domain +} + +// WebserverConfig is the combination of WebDomainRedirects and WebHandlers +// from the domains.conf configuration file. +export interface WebserverConfig { + WebDNSDomainRedirects?: (Domain[] | null)[] | null // From server to frontend. + WebDomainRedirects?: (string[] | null)[] | null // From frontend to server, it's not convenient to create dns.Domain in the frontend. + WebHandlers?: WebHandler[] | null +} + +export interface WebHandler { + LogName: string + Domain: string + PathRegexp: string + DontRedirectPlainHTTP: boolean + Compress: boolean + WebStatic?: WebStatic | null + WebRedirect?: WebRedirect | null + WebForward?: WebForward | null + Name: string // Either LogName, or numeric index if LogName was empty. Used instead of LogName in logging/metrics. + DNSDomain: Domain +} + +export interface WebStatic { + StripPrefix: string + Root: string + ListFiles: boolean + ContinueNotFound: boolean + ResponseHeaders?: { [key: string]: string } +} + +export interface WebRedirect { + BaseURL: string + OrigPathRegexp: string + ReplacePath: string + StatusCode: number +} + +export interface WebForward { + StripPath: boolean + URL: string + ResponseHeaders?: { [key: string]: string } +} + +// Transport is a method to delivery a message. At most one of the fields can +// be non-nil. The non-nil field represents the type of transport. For a +// transport with all fields nil, regular email delivery is done. +export interface Transport { + Submissions?: TransportSMTP | null + Submission?: TransportSMTP | null + SMTP?: TransportSMTP | null + Socks?: TransportSocks | null +} + +// TransportSMTP delivers messages by "submission" (SMTP, typically +// authenticated) to the queue of a remote host (smarthost), or by relaying +// (SMTP, typically unauthenticated). +export interface TransportSMTP { + Host: string + Port: number + STARTTLSInsecureSkipVerify: boolean + NoSTARTTLS: boolean + Auth?: SMTPAuth | null +} + +// SMTPAuth hold authentication credentials used when delivering messages +// through a smarthost. +export interface SMTPAuth { + Username: string + Password: string + Mechanisms?: string[] | null +} + +export interface TransportSocks { + Address: string + RemoteIPs?: string[] | null + RemoteHostname: string +} + +// EvaluationStat summarizes stored evaluations, for inclusion in an upcoming +// aggregate report, for a domain. +export interface EvaluationStat { + Domain: Domain + Dispositions?: string[] | null + Count: number + SendReport: boolean +} + +// Evaluation is the result of an evaluation of a DMARC policy, to be included +// in a DMARC report. +export interface Evaluation { + ID: number + PolicyDomain: string // Domain where DMARC policy was found, could be the organizational domain while evaluation was for a subdomain. Unicode. Same as domain found in PolicyPublished. A separate field for its index. + Evaluated: Date // Time of evaluation, determines which report (covering whole hours) this evaluation will be included in. + Optional: boolean // If optional, this evaluation is not a reason to send a DMARC report, but it will be included when a report is sent due to other non-optional evaluations. Set for evaluations of incoming DMARC reports. We don't want such deliveries causing us to send a report, or we would keep exchanging reporting messages forever. Also set for when evaluation is a DMARC reject for domains we haven't positively interacted with, to prevent being used to flood an unsuspecting domain with reports. + IntervalHours: number // Effective aggregate reporting interval in hours. Between 1 and 24, rounded up from seconds from policy to first number that can divide 24. + Addresses?: string[] | null // "rua" in DMARC record, we only store evaluations for records with aggregate reporting addresses, so always non-empty. + PolicyPublished: PolicyPublished // Policy used for evaluation. We don't store the "fo" field for failure reporting options, since we don't send failure reports for individual messages. + SourceIP: string // For "row" in a report record. + Disposition: Disposition + AlignedDKIMPass: boolean + AlignedSPFPass: boolean + OverrideReasons?: PolicyOverrideReason[] | null + EnvelopeTo: string // For "identifiers" in a report record. + EnvelopeFrom: string + HeaderFrom: string + DKIMResults?: DKIMAuthResult[] | null // For "auth_results" in a report record. + SPFResults?: SPFAuthResult[] | null +} + +// SuppressAddress is a reporting address for which outgoing DMARC reports +// will be suppressed for a period. +export interface SuppressAddress { + ID: number + Inserted: Date + ReportingAddress: string + Until: Date + Comment: string +} + +// TLSResult is stored in the database to track TLS results per policy domain, day +// and recipient domain. These records will be included in TLS reports. +export interface TLSResult { + ID: number + PolicyDomain: string // Domain potentially with TLSRPT DNS record, with addresses that will receive reports. Either a recipient domain (for MTA-STS policies) or an (MX) host (for DANE policies). Unicode. + DayUTC: string // DayUTC is of the form yyyymmdd. + RecipientDomain: string // Reports are sent per recipient domain and per MX host. For reports to a recipient domain, we type send a result for MTA-STS and one or more MX host (DANE) results. Unicode. + Created: Date + Updated: Date + IsHost: boolean // Result is for MX host (DANE), not recipient domain (MTA-STS). + SendReport: boolean // Whether to send a report. TLS results for delivering messages with TLS reports will be recorded, but will not cause a report to be sent. + SentToRecipientDomain: boolean // Set after sending to recipient domain, before sending results to policy domain (after which the record is removed). + RecipientDomainReportingAddresses?: string[] | null // Reporting addresses from the recipient domain TLSRPT record, not necessarily those we sent to (e.g. due to failure). Used to leave results to MX target (DANE) policy domains out that were already sent in the report to the recipient domain, so we don't report twice. + SentToPolicyDomain: boolean // Set after sending report to policy domain. + Results?: Result[] | null // Results is updated for each TLS attempt. +} + +// TLSRPTSuppressAddress is a reporting address for which outgoing TLS reports +// will be suppressed for a period. +export interface TLSRPTSuppressAddress { + ID: number + Inserted: Date + ReportingAddress: string + Until: Date + Comment: string +} + +// Policy as used in DMARC DNS record for "p=" or "sp=". +export enum DMARCPolicy { + PolicyEmpty = "", // Only for the optional Record.SubdomainPolicy. + PolicyNone = "none", + PolicyQuarantine = "quarantine", + PolicyReject = "reject", +} + +// Align specifies the required alignment of a domain name. +export enum Align { + AlignStrict = "s", // Strict requires an exact domain name match. + AlignRelaxed = "r", // Relaxed requires either an exact or subdomain name match. +} + +// RUA is a reporting address with scheme and special characters ",", "!" and +// ";" not encoded. +export type RUA = string + +// Mode indicates how the policy should be interpreted. +export enum Mode { + ModeEnforce = "enforce", // Policy must be followed, i.e. deliveries must fail if a TLS connection cannot be made. + ModeTesting = "testing", // In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLSRPT. + ModeNone = "none", // In case MTA-STS is not or no longer implemented. +} + +// PolicyType indicates the policy success/failure results are for. +export enum PolicyType { + TLSA = "tlsa", // For DANE, against a mail host (not recipient domain). + STS = "sts", // For MTA-STS, against a recipient domain (not a mail host). + // Recipient domain did not have MTA-STS policy, or mail host (TSLA base domain) + // did not have DANE TLSA records. + NoPolicyFound = "no-policy-found", +} + +// ResultType represents a TLS error. +export enum ResultType { + ResultSTARTTLSNotSupported = "starttls-not-supported", + ResultCertificateHostMismatch = "certificate-host-mismatch", + ResultCertificateExpired = "certificate-expired", + ResultTLSAInvalid = "tlsa-invalid", + ResultDNSSECInvalid = "dnssec-invalid", + ResultDANERequired = "dane-required", + ResultCertificateNotTrusted = "certificate-not-trusted", + ResultSTSPolicyInvalid = "sts-policy-invalid", + ResultSTSWebPKIInvalid = "sts-webpki-invalid", + ResultValidationFailure = "validation-failure", // Other error. + ResultSTSPolicyFetch = "sts-policy-fetch-error", +} + +// Alignment is the identifier alignment. +export enum Alignment { + AlignmentRelaxed = "r", // Subdomains match the DMARC from-domain. + AlignmentStrict = "s", // Only exact from-domain match. +} + +// Disposition is the requested action for a DMARC fail as specified in the +// DMARC policy in DNS. +export enum Disposition { + DispositionNone = "none", + DispositionQuarantine = "quarantine", + DispositionReject = "reject", +} + +// DMARCResult is the final validation and alignment verdict for SPF and DKIM. +export enum DMARCResult { + DMARCPass = "pass", + DMARCFail = "fail", +} + +// PolicyOverride is a reason the requested DMARC policy from the DNS record +// was not applied. +export enum PolicyOverride { + PolicyOverrideForwarded = "forwarded", + PolicyOverrideSampledOut = "sampled_out", + PolicyOverrideTrustedForwarder = "trusted_forwarder", + PolicyOverrideMailingList = "mailing_list", + PolicyOverrideLocalPolicy = "local_policy", + PolicyOverrideOther = "other", +} + +export enum DKIMResult { + DKIMNone = "none", + DKIMPass = "pass", + DKIMFail = "fail", + DKIMPolicy = "policy", + DKIMNeutral = "neutral", + DKIMTemperror = "temperror", + DKIMPermerror = "permerror", +} + +export enum SPFDomainScope { + SPFDomainScopeHelo = "helo", // SMTP EHLO + SPFDomainScopeMailFrom = "mfrom", // SMTP "MAIL FROM". +} + +export enum SPFResult { + SPFNone = "none", + SPFNeutral = "neutral", + SPFPass = "pass", + SPFFail = "fail", + SPFSoftfail = "softfail", + SPFTemperror = "temperror", + SPFPermerror = "permerror", +} + +// Localpart is a decoded local part of an email address, before the "@". +// For quoted strings, values do not hold the double quote or escaping backslashes. +// An empty string can be a valid localpart. +export type Localpart = string + +// An IP is a single IP address, a slice of bytes. +// Functions in this package accept either 4-byte (IPv4) +// or 16-byte (IPv6) slices as input. +// +// Note that in this documentation, referring to an +// IP address as an IPv4 address or an IPv6 address +// is a semantic property of the address, not just the +// length of the byte slice: a 16-byte slice can still +// be an IPv4 address. +export type IP = string + +export const structTypes: {[typename: string]: boolean} = {"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"DANECheckResult":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"Reverse":true,"Row":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true} +export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"Alignment":true,"DKIMResult":true,"DMARCPolicy":true,"DMARCResult":true,"Disposition":true,"IP":true,"Localpart":true,"Mode":true,"PolicyOverride":true,"PolicyType":true,"RUA":true,"ResultType":true,"SPFDomainScope":true,"SPFResult":true} +export const intsTypes: {[typename: string]: boolean} = {} +export const types: TypenameMap = { + "CheckResult": {"Name":"CheckResult","Docs":"","Fields":[{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"DNSSEC","Docs":"","Typewords":["DNSSECResult"]},{"Name":"IPRev","Docs":"","Typewords":["IPRevCheckResult"]},{"Name":"MX","Docs":"","Typewords":["MXCheckResult"]},{"Name":"TLS","Docs":"","Typewords":["TLSCheckResult"]},{"Name":"DANE","Docs":"","Typewords":["DANECheckResult"]},{"Name":"SPF","Docs":"","Typewords":["SPFCheckResult"]},{"Name":"DKIM","Docs":"","Typewords":["DKIMCheckResult"]},{"Name":"DMARC","Docs":"","Typewords":["DMARCCheckResult"]},{"Name":"HostTLSRPT","Docs":"","Typewords":["TLSRPTCheckResult"]},{"Name":"DomainTLSRPT","Docs":"","Typewords":["TLSRPTCheckResult"]},{"Name":"MTASTS","Docs":"","Typewords":["MTASTSCheckResult"]},{"Name":"SRVConf","Docs":"","Typewords":["SRVConfCheckResult"]},{"Name":"Autoconf","Docs":"","Typewords":["AutoconfCheckResult"]},{"Name":"Autodiscover","Docs":"","Typewords":["AutodiscoverCheckResult"]}]}, + "DNSSECResult": {"Name":"DNSSECResult","Docs":"","Fields":[{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "IPRevCheckResult": {"Name":"IPRevCheckResult","Docs":"","Fields":[{"Name":"Hostname","Docs":"","Typewords":["Domain"]},{"Name":"IPNames","Docs":"","Typewords":["{}","[]","string"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "Domain": {"Name":"Domain","Docs":"","Fields":[{"Name":"ASCII","Docs":"","Typewords":["string"]},{"Name":"Unicode","Docs":"","Typewords":["string"]}]}, + "MXCheckResult": {"Name":"MXCheckResult","Docs":"","Fields":[{"Name":"Records","Docs":"","Typewords":["[]","MX"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "MX": {"Name":"MX","Docs":"","Fields":[{"Name":"Host","Docs":"","Typewords":["string"]},{"Name":"Pref","Docs":"","Typewords":["int32"]},{"Name":"IPs","Docs":"","Typewords":["[]","string"]}]}, + "TLSCheckResult": {"Name":"TLSCheckResult","Docs":"","Fields":[{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "DANECheckResult": {"Name":"DANECheckResult","Docs":"","Fields":[{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "SPFCheckResult": {"Name":"SPFCheckResult","Docs":"","Fields":[{"Name":"DomainTXT","Docs":"","Typewords":["string"]},{"Name":"DomainRecord","Docs":"","Typewords":["nullable","SPFRecord"]},{"Name":"HostTXT","Docs":"","Typewords":["string"]},{"Name":"HostRecord","Docs":"","Typewords":["nullable","SPFRecord"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "SPFRecord": {"Name":"SPFRecord","Docs":"","Fields":[{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"Directives","Docs":"","Typewords":["[]","Directive"]},{"Name":"Redirect","Docs":"","Typewords":["string"]},{"Name":"Explanation","Docs":"","Typewords":["string"]},{"Name":"Other","Docs":"","Typewords":["[]","Modifier"]}]}, + "Directive": {"Name":"Directive","Docs":"","Fields":[{"Name":"Qualifier","Docs":"","Typewords":["string"]},{"Name":"Mechanism","Docs":"","Typewords":["string"]},{"Name":"DomainSpec","Docs":"","Typewords":["string"]},{"Name":"IPstr","Docs":"","Typewords":["string"]},{"Name":"IP4CIDRLen","Docs":"","Typewords":["nullable","int32"]},{"Name":"IP6CIDRLen","Docs":"","Typewords":["nullable","int32"]}]}, + "Modifier": {"Name":"Modifier","Docs":"","Fields":[{"Name":"Key","Docs":"","Typewords":["string"]},{"Name":"Value","Docs":"","Typewords":["string"]}]}, + "DKIMCheckResult": {"Name":"DKIMCheckResult","Docs":"","Fields":[{"Name":"Records","Docs":"","Typewords":["[]","DKIMRecord"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "DKIMRecord": {"Name":"DKIMRecord","Docs":"","Fields":[{"Name":"Selector","Docs":"","Typewords":["string"]},{"Name":"TXT","Docs":"","Typewords":["string"]},{"Name":"Record","Docs":"","Typewords":["nullable","Record"]}]}, + "Record": {"Name":"Record","Docs":"","Fields":[{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"Hashes","Docs":"","Typewords":["[]","string"]},{"Name":"Key","Docs":"","Typewords":["string"]},{"Name":"Notes","Docs":"","Typewords":["string"]},{"Name":"Pubkey","Docs":"","Typewords":["nullable","string"]},{"Name":"Services","Docs":"","Typewords":["[]","string"]},{"Name":"Flags","Docs":"","Typewords":["[]","string"]}]}, + "DMARCCheckResult": {"Name":"DMARCCheckResult","Docs":"","Fields":[{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"TXT","Docs":"","Typewords":["string"]},{"Name":"Record","Docs":"","Typewords":["nullable","DMARCRecord"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "DMARCRecord": {"Name":"DMARCRecord","Docs":"","Fields":[{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"Policy","Docs":"","Typewords":["DMARCPolicy"]},{"Name":"SubdomainPolicy","Docs":"","Typewords":["DMARCPolicy"]},{"Name":"AggregateReportAddresses","Docs":"","Typewords":["[]","URI"]},{"Name":"FailureReportAddresses","Docs":"","Typewords":["[]","URI"]},{"Name":"ADKIM","Docs":"","Typewords":["Align"]},{"Name":"ASPF","Docs":"","Typewords":["Align"]},{"Name":"AggregateReportingInterval","Docs":"","Typewords":["int32"]},{"Name":"FailureReportingOptions","Docs":"","Typewords":["[]","string"]},{"Name":"ReportingFormat","Docs":"","Typewords":["[]","string"]},{"Name":"Percentage","Docs":"","Typewords":["int32"]}]}, + "URI": {"Name":"URI","Docs":"","Fields":[{"Name":"Address","Docs":"","Typewords":["string"]},{"Name":"MaxSize","Docs":"","Typewords":["uint64"]},{"Name":"Unit","Docs":"","Typewords":["string"]}]}, + "TLSRPTCheckResult": {"Name":"TLSRPTCheckResult","Docs":"","Fields":[{"Name":"TXT","Docs":"","Typewords":["string"]},{"Name":"Record","Docs":"","Typewords":["nullable","TLSRPTRecord"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "TLSRPTRecord": {"Name":"TLSRPTRecord","Docs":"","Fields":[{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"RUAs","Docs":"","Typewords":["[]","[]","RUA"]},{"Name":"Extensions","Docs":"","Typewords":["[]","Extension"]}]}, + "Extension": {"Name":"Extension","Docs":"","Fields":[{"Name":"Key","Docs":"","Typewords":["string"]},{"Name":"Value","Docs":"","Typewords":["string"]}]}, + "MTASTSCheckResult": {"Name":"MTASTSCheckResult","Docs":"","Fields":[{"Name":"TXT","Docs":"","Typewords":["string"]},{"Name":"Record","Docs":"","Typewords":["nullable","MTASTSRecord"]},{"Name":"PolicyText","Docs":"","Typewords":["string"]},{"Name":"Policy","Docs":"","Typewords":["nullable","Policy"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "MTASTSRecord": {"Name":"MTASTSRecord","Docs":"","Fields":[{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"ID","Docs":"","Typewords":["string"]},{"Name":"Extensions","Docs":"","Typewords":["[]","Pair"]}]}, + "Pair": {"Name":"Pair","Docs":"","Fields":[{"Name":"Key","Docs":"","Typewords":["string"]},{"Name":"Value","Docs":"","Typewords":["string"]}]}, + "Policy": {"Name":"Policy","Docs":"","Fields":[{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"Mode","Docs":"","Typewords":["Mode"]},{"Name":"MX","Docs":"","Typewords":["[]","STSMX"]},{"Name":"MaxAgeSeconds","Docs":"","Typewords":["int32"]},{"Name":"Extensions","Docs":"","Typewords":["[]","Pair"]}]}, + "STSMX": {"Name":"STSMX","Docs":"","Fields":[{"Name":"Wildcard","Docs":"","Typewords":["bool"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]}, + "SRVConfCheckResult": {"Name":"SRVConfCheckResult","Docs":"","Fields":[{"Name":"SRVs","Docs":"","Typewords":["{}","[]","SRV"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "SRV": {"Name":"SRV","Docs":"","Fields":[{"Name":"Target","Docs":"","Typewords":["string"]},{"Name":"Port","Docs":"","Typewords":["uint16"]},{"Name":"Priority","Docs":"","Typewords":["uint16"]},{"Name":"Weight","Docs":"","Typewords":["uint16"]}]}, + "AutoconfCheckResult": {"Name":"AutoconfCheckResult","Docs":"","Fields":[{"Name":"ClientSettingsDomainIPs","Docs":"","Typewords":["[]","string"]},{"Name":"IPs","Docs":"","Typewords":["[]","string"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "AutodiscoverCheckResult": {"Name":"AutodiscoverCheckResult","Docs":"","Fields":[{"Name":"Records","Docs":"","Typewords":["[]","AutodiscoverSRV"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]},{"Name":"Warnings","Docs":"","Typewords":["[]","string"]},{"Name":"Instructions","Docs":"","Typewords":["[]","string"]}]}, + "AutodiscoverSRV": {"Name":"AutodiscoverSRV","Docs":"","Fields":[{"Name":"Target","Docs":"","Typewords":["string"]},{"Name":"Port","Docs":"","Typewords":["uint16"]},{"Name":"Priority","Docs":"","Typewords":["uint16"]},{"Name":"Weight","Docs":"","Typewords":["uint16"]},{"Name":"IPs","Docs":"","Typewords":["[]","string"]}]}, + "PolicyRecord": {"Name":"PolicyRecord","Docs":"","Fields":[{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Inserted","Docs":"","Typewords":["timestamp"]},{"Name":"ValidEnd","Docs":"","Typewords":["timestamp"]},{"Name":"LastUpdate","Docs":"","Typewords":["timestamp"]},{"Name":"LastUse","Docs":"","Typewords":["timestamp"]},{"Name":"Backoff","Docs":"","Typewords":["bool"]},{"Name":"RecordID","Docs":"","Typewords":["string"]},{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"Mode","Docs":"","Typewords":["Mode"]},{"Name":"MX","Docs":"","Typewords":["[]","STSMX"]},{"Name":"MaxAgeSeconds","Docs":"","Typewords":["int32"]},{"Name":"Extensions","Docs":"","Typewords":["[]","Pair"]},{"Name":"PolicyText","Docs":"","Typewords":["string"]}]}, + "TLSReportRecord": {"Name":"TLSReportRecord","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"FromDomain","Docs":"","Typewords":["string"]},{"Name":"MailFrom","Docs":"","Typewords":["string"]},{"Name":"HostReport","Docs":"","Typewords":["bool"]},{"Name":"Report","Docs":"","Typewords":["Report"]}]}, + "Report": {"Name":"Report","Docs":"","Fields":[{"Name":"OrganizationName","Docs":"","Typewords":["string"]},{"Name":"DateRange","Docs":"","Typewords":["TLSRPTDateRange"]},{"Name":"ContactInfo","Docs":"","Typewords":["string"]},{"Name":"ReportID","Docs":"","Typewords":["string"]},{"Name":"Policies","Docs":"","Typewords":["[]","Result"]}]}, + "TLSRPTDateRange": {"Name":"TLSRPTDateRange","Docs":"","Fields":[{"Name":"Start","Docs":"","Typewords":["timestamp"]},{"Name":"End","Docs":"","Typewords":["timestamp"]}]}, + "Result": {"Name":"Result","Docs":"","Fields":[{"Name":"Policy","Docs":"","Typewords":["ResultPolicy"]},{"Name":"Summary","Docs":"","Typewords":["Summary"]},{"Name":"FailureDetails","Docs":"","Typewords":["[]","FailureDetails"]}]}, + "ResultPolicy": {"Name":"ResultPolicy","Docs":"","Fields":[{"Name":"Type","Docs":"","Typewords":["PolicyType"]},{"Name":"String","Docs":"","Typewords":["[]","string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"MXHost","Docs":"","Typewords":["[]","string"]}]}, + "Summary": {"Name":"Summary","Docs":"","Fields":[{"Name":"TotalSuccessfulSessionCount","Docs":"","Typewords":["int64"]},{"Name":"TotalFailureSessionCount","Docs":"","Typewords":["int64"]}]}, + "FailureDetails": {"Name":"FailureDetails","Docs":"","Fields":[{"Name":"ResultType","Docs":"","Typewords":["ResultType"]},{"Name":"SendingMTAIP","Docs":"","Typewords":["string"]},{"Name":"ReceivingMXHostname","Docs":"","Typewords":["string"]},{"Name":"ReceivingMXHelo","Docs":"","Typewords":["string"]},{"Name":"ReceivingIP","Docs":"","Typewords":["string"]},{"Name":"FailedSessionCount","Docs":"","Typewords":["int64"]},{"Name":"AdditionalInformation","Docs":"","Typewords":["string"]},{"Name":"FailureReasonCode","Docs":"","Typewords":["string"]}]}, + "TLSRPTSummary": {"Name":"TLSRPTSummary","Docs":"","Fields":[{"Name":"PolicyDomain","Docs":"","Typewords":["Domain"]},{"Name":"Success","Docs":"","Typewords":["int64"]},{"Name":"Failure","Docs":"","Typewords":["int64"]},{"Name":"ResultTypeCounts","Docs":"","Typewords":["{}","int64"]}]}, + "DomainFeedback": {"Name":"DomainFeedback","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"FromDomain","Docs":"","Typewords":["string"]},{"Name":"Version","Docs":"","Typewords":["string"]},{"Name":"ReportMetadata","Docs":"","Typewords":["ReportMetadata"]},{"Name":"PolicyPublished","Docs":"","Typewords":["PolicyPublished"]},{"Name":"Records","Docs":"","Typewords":["[]","ReportRecord"]}]}, + "ReportMetadata": {"Name":"ReportMetadata","Docs":"","Fields":[{"Name":"OrgName","Docs":"","Typewords":["string"]},{"Name":"Email","Docs":"","Typewords":["string"]},{"Name":"ExtraContactInfo","Docs":"","Typewords":["string"]},{"Name":"ReportID","Docs":"","Typewords":["string"]},{"Name":"DateRange","Docs":"","Typewords":["DateRange"]},{"Name":"Errors","Docs":"","Typewords":["[]","string"]}]}, + "DateRange": {"Name":"DateRange","Docs":"","Fields":[{"Name":"Begin","Docs":"","Typewords":["int64"]},{"Name":"End","Docs":"","Typewords":["int64"]}]}, + "PolicyPublished": {"Name":"PolicyPublished","Docs":"","Fields":[{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"ADKIM","Docs":"","Typewords":["Alignment"]},{"Name":"ASPF","Docs":"","Typewords":["Alignment"]},{"Name":"Policy","Docs":"","Typewords":["Disposition"]},{"Name":"SubdomainPolicy","Docs":"","Typewords":["Disposition"]},{"Name":"Percentage","Docs":"","Typewords":["int32"]},{"Name":"ReportingOptions","Docs":"","Typewords":["string"]}]}, + "ReportRecord": {"Name":"ReportRecord","Docs":"","Fields":[{"Name":"Row","Docs":"","Typewords":["Row"]},{"Name":"Identifiers","Docs":"","Typewords":["Identifiers"]},{"Name":"AuthResults","Docs":"","Typewords":["AuthResults"]}]}, + "Row": {"Name":"Row","Docs":"","Fields":[{"Name":"SourceIP","Docs":"","Typewords":["string"]},{"Name":"Count","Docs":"","Typewords":["int32"]},{"Name":"PolicyEvaluated","Docs":"","Typewords":["PolicyEvaluated"]}]}, + "PolicyEvaluated": {"Name":"PolicyEvaluated","Docs":"","Fields":[{"Name":"Disposition","Docs":"","Typewords":["Disposition"]},{"Name":"DKIM","Docs":"","Typewords":["DMARCResult"]},{"Name":"SPF","Docs":"","Typewords":["DMARCResult"]},{"Name":"Reasons","Docs":"","Typewords":["[]","PolicyOverrideReason"]}]}, + "PolicyOverrideReason": {"Name":"PolicyOverrideReason","Docs":"","Fields":[{"Name":"Type","Docs":"","Typewords":["PolicyOverride"]},{"Name":"Comment","Docs":"","Typewords":["string"]}]}, + "Identifiers": {"Name":"Identifiers","Docs":"","Fields":[{"Name":"EnvelopeTo","Docs":"","Typewords":["string"]},{"Name":"EnvelopeFrom","Docs":"","Typewords":["string"]},{"Name":"HeaderFrom","Docs":"","Typewords":["string"]}]}, + "AuthResults": {"Name":"AuthResults","Docs":"","Fields":[{"Name":"DKIM","Docs":"","Typewords":["[]","DKIMAuthResult"]},{"Name":"SPF","Docs":"","Typewords":["[]","SPFAuthResult"]}]}, + "DKIMAuthResult": {"Name":"DKIMAuthResult","Docs":"","Fields":[{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Selector","Docs":"","Typewords":["string"]},{"Name":"Result","Docs":"","Typewords":["DKIMResult"]},{"Name":"HumanResult","Docs":"","Typewords":["string"]}]}, + "SPFAuthResult": {"Name":"SPFAuthResult","Docs":"","Fields":[{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Scope","Docs":"","Typewords":["SPFDomainScope"]},{"Name":"Result","Docs":"","Typewords":["SPFResult"]}]}, + "DMARCSummary": {"Name":"DMARCSummary","Docs":"","Fields":[{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Total","Docs":"","Typewords":["int32"]},{"Name":"DispositionNone","Docs":"","Typewords":["int32"]},{"Name":"DispositionQuarantine","Docs":"","Typewords":["int32"]},{"Name":"DispositionReject","Docs":"","Typewords":["int32"]},{"Name":"DKIMFail","Docs":"","Typewords":["int32"]},{"Name":"SPFFail","Docs":"","Typewords":["int32"]},{"Name":"PolicyOverrides","Docs":"","Typewords":["{}","int32"]}]}, + "Reverse": {"Name":"Reverse","Docs":"","Fields":[{"Name":"Hostnames","Docs":"","Typewords":["[]","string"]}]}, + "ClientConfigs": {"Name":"ClientConfigs","Docs":"","Fields":[{"Name":"Entries","Docs":"","Typewords":["[]","ClientConfigsEntry"]}]}, + "ClientConfigsEntry": {"Name":"ClientConfigsEntry","Docs":"","Fields":[{"Name":"Protocol","Docs":"","Typewords":["string"]},{"Name":"Host","Docs":"","Typewords":["Domain"]},{"Name":"Port","Docs":"","Typewords":["int32"]},{"Name":"Listener","Docs":"","Typewords":["string"]},{"Name":"Note","Docs":"","Typewords":["string"]}]}, + "Msg": {"Name":"Msg","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Queued","Docs":"","Typewords":["timestamp"]},{"Name":"SenderAccount","Docs":"","Typewords":["string"]},{"Name":"SenderLocalpart","Docs":"","Typewords":["Localpart"]},{"Name":"SenderDomain","Docs":"","Typewords":["IPDomain"]},{"Name":"RecipientLocalpart","Docs":"","Typewords":["Localpart"]},{"Name":"RecipientDomain","Docs":"","Typewords":["IPDomain"]},{"Name":"RecipientDomainStr","Docs":"","Typewords":["string"]},{"Name":"Attempts","Docs":"","Typewords":["int32"]},{"Name":"MaxAttempts","Docs":"","Typewords":["int32"]},{"Name":"DialedIPs","Docs":"","Typewords":["{}","[]","IP"]},{"Name":"NextAttempt","Docs":"","Typewords":["timestamp"]},{"Name":"LastAttempt","Docs":"","Typewords":["nullable","timestamp"]},{"Name":"LastError","Docs":"","Typewords":["string"]},{"Name":"Has8bit","Docs":"","Typewords":["bool"]},{"Name":"SMTPUTF8","Docs":"","Typewords":["bool"]},{"Name":"IsDMARCReport","Docs":"","Typewords":["bool"]},{"Name":"IsTLSReport","Docs":"","Typewords":["bool"]},{"Name":"Size","Docs":"","Typewords":["int64"]},{"Name":"MessageID","Docs":"","Typewords":["string"]},{"Name":"MsgPrefix","Docs":"","Typewords":["nullable","string"]},{"Name":"DSNUTF8","Docs":"","Typewords":["nullable","string"]},{"Name":"Transport","Docs":"","Typewords":["string"]},{"Name":"RequireTLS","Docs":"","Typewords":["nullable","bool"]}]}, + "IPDomain": {"Name":"IPDomain","Docs":"","Fields":[{"Name":"IP","Docs":"","Typewords":["IP"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]}, + "WebserverConfig": {"Name":"WebserverConfig","Docs":"","Fields":[{"Name":"WebDNSDomainRedirects","Docs":"","Typewords":["[]","[]","Domain"]},{"Name":"WebDomainRedirects","Docs":"","Typewords":["[]","[]","string"]},{"Name":"WebHandlers","Docs":"","Typewords":["[]","WebHandler"]}]}, + "WebHandler": {"Name":"WebHandler","Docs":"","Fields":[{"Name":"LogName","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"PathRegexp","Docs":"","Typewords":["string"]},{"Name":"DontRedirectPlainHTTP","Docs":"","Typewords":["bool"]},{"Name":"Compress","Docs":"","Typewords":["bool"]},{"Name":"WebStatic","Docs":"","Typewords":["nullable","WebStatic"]},{"Name":"WebRedirect","Docs":"","Typewords":["nullable","WebRedirect"]},{"Name":"WebForward","Docs":"","Typewords":["nullable","WebForward"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]}]}, + "WebStatic": {"Name":"WebStatic","Docs":"","Fields":[{"Name":"StripPrefix","Docs":"","Typewords":["string"]},{"Name":"Root","Docs":"","Typewords":["string"]},{"Name":"ListFiles","Docs":"","Typewords":["bool"]},{"Name":"ContinueNotFound","Docs":"","Typewords":["bool"]},{"Name":"ResponseHeaders","Docs":"","Typewords":["{}","string"]}]}, + "WebRedirect": {"Name":"WebRedirect","Docs":"","Fields":[{"Name":"BaseURL","Docs":"","Typewords":["string"]},{"Name":"OrigPathRegexp","Docs":"","Typewords":["string"]},{"Name":"ReplacePath","Docs":"","Typewords":["string"]},{"Name":"StatusCode","Docs":"","Typewords":["int32"]}]}, + "WebForward": {"Name":"WebForward","Docs":"","Fields":[{"Name":"StripPath","Docs":"","Typewords":["bool"]},{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"ResponseHeaders","Docs":"","Typewords":["{}","string"]}]}, + "Transport": {"Name":"Transport","Docs":"","Fields":[{"Name":"Submissions","Docs":"","Typewords":["nullable","TransportSMTP"]},{"Name":"Submission","Docs":"","Typewords":["nullable","TransportSMTP"]},{"Name":"SMTP","Docs":"","Typewords":["nullable","TransportSMTP"]},{"Name":"Socks","Docs":"","Typewords":["nullable","TransportSocks"]}]}, + "TransportSMTP": {"Name":"TransportSMTP","Docs":"","Fields":[{"Name":"Host","Docs":"","Typewords":["string"]},{"Name":"Port","Docs":"","Typewords":["int32"]},{"Name":"STARTTLSInsecureSkipVerify","Docs":"","Typewords":["bool"]},{"Name":"NoSTARTTLS","Docs":"","Typewords":["bool"]},{"Name":"Auth","Docs":"","Typewords":["nullable","SMTPAuth"]}]}, + "SMTPAuth": {"Name":"SMTPAuth","Docs":"","Fields":[{"Name":"Username","Docs":"","Typewords":["string"]},{"Name":"Password","Docs":"","Typewords":["string"]},{"Name":"Mechanisms","Docs":"","Typewords":["[]","string"]}]}, + "TransportSocks": {"Name":"TransportSocks","Docs":"","Fields":[{"Name":"Address","Docs":"","Typewords":["string"]},{"Name":"RemoteIPs","Docs":"","Typewords":["[]","string"]},{"Name":"RemoteHostname","Docs":"","Typewords":["string"]}]}, + "EvaluationStat": {"Name":"EvaluationStat","Docs":"","Fields":[{"Name":"Domain","Docs":"","Typewords":["Domain"]},{"Name":"Dispositions","Docs":"","Typewords":["[]","string"]},{"Name":"Count","Docs":"","Typewords":["int32"]},{"Name":"SendReport","Docs":"","Typewords":["bool"]}]}, + "Evaluation": {"Name":"Evaluation","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"PolicyDomain","Docs":"","Typewords":["string"]},{"Name":"Evaluated","Docs":"","Typewords":["timestamp"]},{"Name":"Optional","Docs":"","Typewords":["bool"]},{"Name":"IntervalHours","Docs":"","Typewords":["int32"]},{"Name":"Addresses","Docs":"","Typewords":["[]","string"]},{"Name":"PolicyPublished","Docs":"","Typewords":["PolicyPublished"]},{"Name":"SourceIP","Docs":"","Typewords":["string"]},{"Name":"Disposition","Docs":"","Typewords":["Disposition"]},{"Name":"AlignedDKIMPass","Docs":"","Typewords":["bool"]},{"Name":"AlignedSPFPass","Docs":"","Typewords":["bool"]},{"Name":"OverrideReasons","Docs":"","Typewords":["[]","PolicyOverrideReason"]},{"Name":"EnvelopeTo","Docs":"","Typewords":["string"]},{"Name":"EnvelopeFrom","Docs":"","Typewords":["string"]},{"Name":"HeaderFrom","Docs":"","Typewords":["string"]},{"Name":"DKIMResults","Docs":"","Typewords":["[]","DKIMAuthResult"]},{"Name":"SPFResults","Docs":"","Typewords":["[]","SPFAuthResult"]}]}, + "SuppressAddress": {"Name":"SuppressAddress","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Inserted","Docs":"","Typewords":["timestamp"]},{"Name":"ReportingAddress","Docs":"","Typewords":["string"]},{"Name":"Until","Docs":"","Typewords":["timestamp"]},{"Name":"Comment","Docs":"","Typewords":["string"]}]}, + "TLSResult": {"Name":"TLSResult","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"PolicyDomain","Docs":"","Typewords":["string"]},{"Name":"DayUTC","Docs":"","Typewords":["string"]},{"Name":"RecipientDomain","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Updated","Docs":"","Typewords":["timestamp"]},{"Name":"IsHost","Docs":"","Typewords":["bool"]},{"Name":"SendReport","Docs":"","Typewords":["bool"]},{"Name":"SentToRecipientDomain","Docs":"","Typewords":["bool"]},{"Name":"RecipientDomainReportingAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"SentToPolicyDomain","Docs":"","Typewords":["bool"]},{"Name":"Results","Docs":"","Typewords":["[]","Result"]}]}, + "TLSRPTSuppressAddress": {"Name":"TLSRPTSuppressAddress","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Inserted","Docs":"","Typewords":["timestamp"]},{"Name":"ReportingAddress","Docs":"","Typewords":["string"]},{"Name":"Until","Docs":"","Typewords":["timestamp"]},{"Name":"Comment","Docs":"","Typewords":["string"]}]}, + "DMARCPolicy": {"Name":"DMARCPolicy","Docs":"","Values":[{"Name":"PolicyEmpty","Value":"","Docs":""},{"Name":"PolicyNone","Value":"none","Docs":""},{"Name":"PolicyQuarantine","Value":"quarantine","Docs":""},{"Name":"PolicyReject","Value":"reject","Docs":""}]}, + "Align": {"Name":"Align","Docs":"","Values":[{"Name":"AlignStrict","Value":"s","Docs":""},{"Name":"AlignRelaxed","Value":"r","Docs":""}]}, + "RUA": {"Name":"RUA","Docs":"","Values":null}, + "Mode": {"Name":"Mode","Docs":"","Values":[{"Name":"ModeEnforce","Value":"enforce","Docs":""},{"Name":"ModeTesting","Value":"testing","Docs":""},{"Name":"ModeNone","Value":"none","Docs":""}]}, + "PolicyType": {"Name":"PolicyType","Docs":"","Values":[{"Name":"TLSA","Value":"tlsa","Docs":""},{"Name":"STS","Value":"sts","Docs":""},{"Name":"NoPolicyFound","Value":"no-policy-found","Docs":""}]}, + "ResultType": {"Name":"ResultType","Docs":"","Values":[{"Name":"ResultSTARTTLSNotSupported","Value":"starttls-not-supported","Docs":""},{"Name":"ResultCertificateHostMismatch","Value":"certificate-host-mismatch","Docs":""},{"Name":"ResultCertificateExpired","Value":"certificate-expired","Docs":""},{"Name":"ResultTLSAInvalid","Value":"tlsa-invalid","Docs":""},{"Name":"ResultDNSSECInvalid","Value":"dnssec-invalid","Docs":""},{"Name":"ResultDANERequired","Value":"dane-required","Docs":""},{"Name":"ResultCertificateNotTrusted","Value":"certificate-not-trusted","Docs":""},{"Name":"ResultSTSPolicyInvalid","Value":"sts-policy-invalid","Docs":""},{"Name":"ResultSTSWebPKIInvalid","Value":"sts-webpki-invalid","Docs":""},{"Name":"ResultValidationFailure","Value":"validation-failure","Docs":""},{"Name":"ResultSTSPolicyFetch","Value":"sts-policy-fetch-error","Docs":""}]}, + "Alignment": {"Name":"Alignment","Docs":"","Values":[{"Name":"AlignmentRelaxed","Value":"r","Docs":""},{"Name":"AlignmentStrict","Value":"s","Docs":""}]}, + "Disposition": {"Name":"Disposition","Docs":"","Values":[{"Name":"DispositionNone","Value":"none","Docs":""},{"Name":"DispositionQuarantine","Value":"quarantine","Docs":""},{"Name":"DispositionReject","Value":"reject","Docs":""}]}, + "DMARCResult": {"Name":"DMARCResult","Docs":"","Values":[{"Name":"DMARCPass","Value":"pass","Docs":""},{"Name":"DMARCFail","Value":"fail","Docs":""}]}, + "PolicyOverride": {"Name":"PolicyOverride","Docs":"","Values":[{"Name":"PolicyOverrideForwarded","Value":"forwarded","Docs":""},{"Name":"PolicyOverrideSampledOut","Value":"sampled_out","Docs":""},{"Name":"PolicyOverrideTrustedForwarder","Value":"trusted_forwarder","Docs":""},{"Name":"PolicyOverrideMailingList","Value":"mailing_list","Docs":""},{"Name":"PolicyOverrideLocalPolicy","Value":"local_policy","Docs":""},{"Name":"PolicyOverrideOther","Value":"other","Docs":""}]}, + "DKIMResult": {"Name":"DKIMResult","Docs":"","Values":[{"Name":"DKIMNone","Value":"none","Docs":""},{"Name":"DKIMPass","Value":"pass","Docs":""},{"Name":"DKIMFail","Value":"fail","Docs":""},{"Name":"DKIMPolicy","Value":"policy","Docs":""},{"Name":"DKIMNeutral","Value":"neutral","Docs":""},{"Name":"DKIMTemperror","Value":"temperror","Docs":""},{"Name":"DKIMPermerror","Value":"permerror","Docs":""}]}, + "SPFDomainScope": {"Name":"SPFDomainScope","Docs":"","Values":[{"Name":"SPFDomainScopeHelo","Value":"helo","Docs":""},{"Name":"SPFDomainScopeMailFrom","Value":"mfrom","Docs":""}]}, + "SPFResult": {"Name":"SPFResult","Docs":"","Values":[{"Name":"SPFNone","Value":"none","Docs":""},{"Name":"SPFNeutral","Value":"neutral","Docs":""},{"Name":"SPFPass","Value":"pass","Docs":""},{"Name":"SPFFail","Value":"fail","Docs":""},{"Name":"SPFSoftfail","Value":"softfail","Docs":""},{"Name":"SPFTemperror","Value":"temperror","Docs":""},{"Name":"SPFPermerror","Value":"permerror","Docs":""}]}, + "Localpart": {"Name":"Localpart","Docs":"","Values":null}, + "IP": {"Name":"IP","Docs":"","Values":[]}, +} + +export const parser = { + CheckResult: (v: any) => parse("CheckResult", v) as CheckResult, + DNSSECResult: (v: any) => parse("DNSSECResult", v) as DNSSECResult, + IPRevCheckResult: (v: any) => parse("IPRevCheckResult", v) as IPRevCheckResult, + Domain: (v: any) => parse("Domain", v) as Domain, + MXCheckResult: (v: any) => parse("MXCheckResult", v) as MXCheckResult, + MX: (v: any) => parse("MX", v) as MX, + TLSCheckResult: (v: any) => parse("TLSCheckResult", v) as TLSCheckResult, + DANECheckResult: (v: any) => parse("DANECheckResult", v) as DANECheckResult, + SPFCheckResult: (v: any) => parse("SPFCheckResult", v) as SPFCheckResult, + SPFRecord: (v: any) => parse("SPFRecord", v) as SPFRecord, + Directive: (v: any) => parse("Directive", v) as Directive, + Modifier: (v: any) => parse("Modifier", v) as Modifier, + DKIMCheckResult: (v: any) => parse("DKIMCheckResult", v) as DKIMCheckResult, + DKIMRecord: (v: any) => parse("DKIMRecord", v) as DKIMRecord, + Record: (v: any) => parse("Record", v) as Record, + DMARCCheckResult: (v: any) => parse("DMARCCheckResult", v) as DMARCCheckResult, + DMARCRecord: (v: any) => parse("DMARCRecord", v) as DMARCRecord, + URI: (v: any) => parse("URI", v) as URI, + TLSRPTCheckResult: (v: any) => parse("TLSRPTCheckResult", v) as TLSRPTCheckResult, + TLSRPTRecord: (v: any) => parse("TLSRPTRecord", v) as TLSRPTRecord, + Extension: (v: any) => parse("Extension", v) as Extension, + MTASTSCheckResult: (v: any) => parse("MTASTSCheckResult", v) as MTASTSCheckResult, + MTASTSRecord: (v: any) => parse("MTASTSRecord", v) as MTASTSRecord, + Pair: (v: any) => parse("Pair", v) as Pair, + Policy: (v: any) => parse("Policy", v) as Policy, + STSMX: (v: any) => parse("STSMX", v) as STSMX, + SRVConfCheckResult: (v: any) => parse("SRVConfCheckResult", v) as SRVConfCheckResult, + SRV: (v: any) => parse("SRV", v) as SRV, + AutoconfCheckResult: (v: any) => parse("AutoconfCheckResult", v) as AutoconfCheckResult, + AutodiscoverCheckResult: (v: any) => parse("AutodiscoverCheckResult", v) as AutodiscoverCheckResult, + AutodiscoverSRV: (v: any) => parse("AutodiscoverSRV", v) as AutodiscoverSRV, + PolicyRecord: (v: any) => parse("PolicyRecord", v) as PolicyRecord, + TLSReportRecord: (v: any) => parse("TLSReportRecord", v) as TLSReportRecord, + Report: (v: any) => parse("Report", v) as Report, + TLSRPTDateRange: (v: any) => parse("TLSRPTDateRange", v) as TLSRPTDateRange, + Result: (v: any) => parse("Result", v) as Result, + ResultPolicy: (v: any) => parse("ResultPolicy", v) as ResultPolicy, + Summary: (v: any) => parse("Summary", v) as Summary, + FailureDetails: (v: any) => parse("FailureDetails", v) as FailureDetails, + TLSRPTSummary: (v: any) => parse("TLSRPTSummary", v) as TLSRPTSummary, + DomainFeedback: (v: any) => parse("DomainFeedback", v) as DomainFeedback, + ReportMetadata: (v: any) => parse("ReportMetadata", v) as ReportMetadata, + DateRange: (v: any) => parse("DateRange", v) as DateRange, + PolicyPublished: (v: any) => parse("PolicyPublished", v) as PolicyPublished, + ReportRecord: (v: any) => parse("ReportRecord", v) as ReportRecord, + Row: (v: any) => parse("Row", v) as Row, + PolicyEvaluated: (v: any) => parse("PolicyEvaluated", v) as PolicyEvaluated, + PolicyOverrideReason: (v: any) => parse("PolicyOverrideReason", v) as PolicyOverrideReason, + Identifiers: (v: any) => parse("Identifiers", v) as Identifiers, + AuthResults: (v: any) => parse("AuthResults", v) as AuthResults, + DKIMAuthResult: (v: any) => parse("DKIMAuthResult", v) as DKIMAuthResult, + SPFAuthResult: (v: any) => parse("SPFAuthResult", v) as SPFAuthResult, + DMARCSummary: (v: any) => parse("DMARCSummary", v) as DMARCSummary, + Reverse: (v: any) => parse("Reverse", v) as Reverse, + ClientConfigs: (v: any) => parse("ClientConfigs", v) as ClientConfigs, + ClientConfigsEntry: (v: any) => parse("ClientConfigsEntry", v) as ClientConfigsEntry, + Msg: (v: any) => parse("Msg", v) as Msg, + IPDomain: (v: any) => parse("IPDomain", v) as IPDomain, + WebserverConfig: (v: any) => parse("WebserverConfig", v) as WebserverConfig, + WebHandler: (v: any) => parse("WebHandler", v) as WebHandler, + WebStatic: (v: any) => parse("WebStatic", v) as WebStatic, + WebRedirect: (v: any) => parse("WebRedirect", v) as WebRedirect, + WebForward: (v: any) => parse("WebForward", v) as WebForward, + Transport: (v: any) => parse("Transport", v) as Transport, + TransportSMTP: (v: any) => parse("TransportSMTP", v) as TransportSMTP, + SMTPAuth: (v: any) => parse("SMTPAuth", v) as SMTPAuth, + TransportSocks: (v: any) => parse("TransportSocks", v) as TransportSocks, + EvaluationStat: (v: any) => parse("EvaluationStat", v) as EvaluationStat, + Evaluation: (v: any) => parse("Evaluation", v) as Evaluation, + SuppressAddress: (v: any) => parse("SuppressAddress", v) as SuppressAddress, + TLSResult: (v: any) => parse("TLSResult", v) as TLSResult, + TLSRPTSuppressAddress: (v: any) => parse("TLSRPTSuppressAddress", v) as TLSRPTSuppressAddress, + DMARCPolicy: (v: any) => parse("DMARCPolicy", v) as DMARCPolicy, + Align: (v: any) => parse("Align", v) as Align, + RUA: (v: any) => parse("RUA", v) as RUA, + Mode: (v: any) => parse("Mode", v) as Mode, + PolicyType: (v: any) => parse("PolicyType", v) as PolicyType, + ResultType: (v: any) => parse("ResultType", v) as ResultType, + Alignment: (v: any) => parse("Alignment", v) as Alignment, + Disposition: (v: any) => parse("Disposition", v) as Disposition, + DMARCResult: (v: any) => parse("DMARCResult", v) as DMARCResult, + PolicyOverride: (v: any) => parse("PolicyOverride", v) as PolicyOverride, + DKIMResult: (v: any) => parse("DKIMResult", v) as DKIMResult, + SPFDomainScope: (v: any) => parse("SPFDomainScope", v) as SPFDomainScope, + SPFResult: (v: any) => parse("SPFResult", v) as SPFResult, + Localpart: (v: any) => parse("Localpart", v) as Localpart, + IP: (v: any) => parse("IP", v) as IP, +} + +// Admin exports web API functions for the admin web interface. All its methods are +// exported under api/. Function calls require valid HTTP Authentication +// credentials of a user. +let defaultOptions: ClientOptions = {slicesNullable: true, mapsNullable: true, nullableOptional: true} + +export class Client { + constructor(private baseURL=defaultBaseURL, public options?: ClientOptions) { + if (!options) { + this.options = defaultOptions + } + } + + withOptions(options: ClientOptions): Client { + return new Client(this.baseURL, { ...this.options, ...options }) + } + + // CheckDomain checks the configuration for the domain, such as MX, SMTP STARTTLS, + // SPF, DKIM, DMARC, TLSRPT, MTASTS, autoconfig, autodiscover. + async CheckDomain(domainName: string): Promise { + const fn: string = "CheckDomain" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["CheckResult"]] + const params: any[] = [domainName] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as CheckResult + } + + // Domains returns all configured domain names, in UTF-8 for IDNA domains. + async Domains(): Promise { + const fn: string = "Domains" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["[]","Domain"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as Domain[] | null + } + + // Domain returns the dns domain for a (potentially unicode as IDNA) domain name. + async Domain(domain: string): Promise { + const fn: string = "Domain" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["Domain"]] + const params: any[] = [domain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as Domain + } + + // ParseDomain parses a domain, possibly an IDNA domain. + async ParseDomain(domain: string): Promise { + const fn: string = "ParseDomain" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["Domain"]] + const params: any[] = [domain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as Domain + } + + // DomainLocalparts returns the encoded localparts and accounts configured in domain. + async DomainLocalparts(domain: string): Promise<{ [key: string]: string }> { + const fn: string = "DomainLocalparts" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["{}","string"]] + const params: any[] = [domain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: string } + } + + // Accounts returns the names of all configured accounts. + async Accounts(): Promise { + const fn: string = "Accounts" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["[]","string"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as string[] | null + } + + // Account returns the parsed configuration of an account. + async Account(account: string): Promise<{ [key: string]: any }> { + const fn: string = "Account" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["{}","any"]] + const params: any[] = [account] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: any } + } + + // ConfigFiles returns the paths and contents of the static and dynamic configuration files. + async ConfigFiles(): Promise<[string, string, string, string]> { + const fn: string = "ConfigFiles" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["string"],["string"],["string"],["string"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [string, string, string, string] + } + + // MTASTSPolicies returns all mtasts policies from the cache. + async MTASTSPolicies(): Promise { + const fn: string = "MTASTSPolicies" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["[]","PolicyRecord"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as PolicyRecord[] | null + } + + // TLSReports returns TLS reports overlapping with period start/end, for the given + // policy domain (or all domains if empty). The reports are sorted first by period + // end (most recent first), then by policy domain. + async TLSReports(start: Date, end: Date, policyDomain: string): Promise { + const fn: string = "TLSReports" + const paramTypes: string[][] = [["timestamp"],["timestamp"],["string"]] + const returnTypes: string[][] = [["[]","TLSReportRecord"]] + const params: any[] = [start, end, policyDomain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSReportRecord[] | null + } + + // TLSReportID returns a single TLS report. + async TLSReportID(domain: string, reportID: number): Promise { + const fn: string = "TLSReportID" + const paramTypes: string[][] = [["string"],["int64"]] + const returnTypes: string[][] = [["TLSReportRecord"]] + const params: any[] = [domain, reportID] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSReportRecord + } + + // TLSRPTSummaries returns a summary of received TLS reports overlapping with + // period start/end for one or all domains (when domain is empty). + // The returned summaries are ordered by domain name. + async TLSRPTSummaries(start: Date, end: Date, policyDomain: string): Promise { + const fn: string = "TLSRPTSummaries" + const paramTypes: string[][] = [["timestamp"],["timestamp"],["string"]] + const returnTypes: string[][] = [["[]","TLSRPTSummary"]] + const params: any[] = [start, end, policyDomain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSRPTSummary[] | null + } + + // DMARCReports returns DMARC reports overlapping with period start/end, for the + // given domain (or all domains if empty). The reports are sorted first by period + // end (most recent first), then by domain. + async DMARCReports(start: Date, end: Date, domain: string): Promise { + const fn: string = "DMARCReports" + const paramTypes: string[][] = [["timestamp"],["timestamp"],["string"]] + const returnTypes: string[][] = [["[]","DomainFeedback"]] + const params: any[] = [start, end, domain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as DomainFeedback[] | null + } + + // DMARCReportID returns a single DMARC report. + async DMARCReportID(domain: string, reportID: number): Promise { + const fn: string = "DMARCReportID" + const paramTypes: string[][] = [["string"],["int64"]] + const returnTypes: string[][] = [["DomainFeedback"]] + const params: any[] = [domain, reportID] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as DomainFeedback + } + + // DMARCSummaries returns a summary of received DMARC reports overlapping with + // period start/end for one or all domains (when domain is empty). + // The returned summaries are ordered by domain name. + async DMARCSummaries(start: Date, end: Date, domain: string): Promise { + const fn: string = "DMARCSummaries" + const paramTypes: string[][] = [["timestamp"],["timestamp"],["string"]] + const returnTypes: string[][] = [["[]","DMARCSummary"]] + const params: any[] = [start, end, domain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as DMARCSummary[] | null + } + + // LookupIP does a reverse lookup of ip. + async LookupIP(ip: string): Promise { + const fn: string = "LookupIP" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["Reverse"]] + const params: any[] = [ip] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as Reverse + } + + // DNSBLStatus returns the IPs from which outgoing connections may be made and + // their current status in DNSBLs that are configured. The IPs are typically the + // configured listen IPs, or otherwise IPs on the machines network interfaces, with + // internal/private IPs removed. + // + // The returned value maps IPs to per DNSBL statuses, where "pass" means not listed and + // anything else is an error string, e.g. "fail: ..." or "temperror: ...". + async DNSBLStatus(): Promise<{ [key: string]: { [key: string]: string } }> { + const fn: string = "DNSBLStatus" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["{}","{}","string"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: { [key: string]: string } } + } + + // DomainRecords returns lines describing DNS records that should exist for the + // configured domain. + async DomainRecords(domain: string): Promise { + const fn: string = "DomainRecords" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["[]","string"]] + const params: any[] = [domain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as string[] | null + } + + // DomainAdd adds a new domain and reloads the configuration. + async DomainAdd(domain: string, accountName: string, localpart: string): Promise { + const fn: string = "DomainAdd" + const paramTypes: string[][] = [["string"],["string"],["string"]] + const returnTypes: string[][] = [] + const params: any[] = [domain, accountName, localpart] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // DomainRemove removes an existing domain and reloads the configuration. + async DomainRemove(domain: string): Promise { + const fn: string = "DomainRemove" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [] + const params: any[] = [domain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // AccountAdd adds existing a new account, with an initial email address, and + // reloads the configuration. + async AccountAdd(accountName: string, address: string): Promise { + const fn: string = "AccountAdd" + const paramTypes: string[][] = [["string"],["string"]] + const returnTypes: string[][] = [] + const params: any[] = [accountName, address] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // AccountRemove removes an existing account and reloads the configuration. + async AccountRemove(accountName: string): Promise { + const fn: string = "AccountRemove" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [] + const params: any[] = [accountName] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // AddressAdd adds a new address to the account, which must already exist. + async AddressAdd(address: string, accountName: string): Promise { + const fn: string = "AddressAdd" + const paramTypes: string[][] = [["string"],["string"]] + const returnTypes: string[][] = [] + const params: any[] = [address, accountName] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // AddressRemove removes an existing address. + async AddressRemove(address: string): Promise { + const fn: string = "AddressRemove" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [] + const params: any[] = [address] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // SetPassword saves a new password for an account, invalidating the previous password. + // Sessions are not interrupted, and will keep working. New login attempts must use the new password. + // Password must be at least 8 characters. + async SetPassword(accountName: string, password: string): Promise { + const fn: string = "SetPassword" + const paramTypes: string[][] = [["string"],["string"]] + const returnTypes: string[][] = [] + const params: any[] = [accountName, password] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // SetAccountLimits set new limits on outgoing messages for an account. + async SetAccountLimits(accountName: string, maxOutgoingMessagesPerDay: number, maxFirstTimeRecipientsPerDay: number, maxMsgSize: number): Promise { + const fn: string = "SetAccountLimits" + const paramTypes: string[][] = [["string"],["int32"],["int32"],["int64"]] + const returnTypes: string[][] = [] + const params: any[] = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // ClientConfigsDomain returns configurations for email clients, IMAP and + // Submission (SMTP) for the domain. + async ClientConfigsDomain(domain: string): Promise { + const fn: string = "ClientConfigsDomain" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["ClientConfigs"]] + const params: any[] = [domain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as ClientConfigs + } + + // QueueList returns the messages currently in the outgoing queue. + async QueueList(): Promise { + const fn: string = "QueueList" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["[]","Msg"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as Msg[] | null + } + + // QueueSize returns the number of messages currently in the outgoing queue. + async QueueSize(): Promise { + const fn: string = "QueueSize" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["int32"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as number + } + + // QueueKick initiates delivery of a message from the queue and sets the transport + // to use for delivery. + async QueueKick(id: number, transport: string): Promise { + const fn: string = "QueueKick" + const paramTypes: string[][] = [["int64"],["string"]] + const returnTypes: string[][] = [] + const params: any[] = [id, transport] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // QueueDrop removes a message from the queue. + async QueueDrop(id: number): Promise { + const fn: string = "QueueDrop" + const paramTypes: string[][] = [["int64"]] + const returnTypes: string[][] = [] + const params: any[] = [id] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // QueueSaveRequireTLS updates the requiretls field for a message in the queue, + // to be used for the next delivery. + async QueueSaveRequireTLS(id: number, requireTLS: boolean | null): Promise { + const fn: string = "QueueSaveRequireTLS" + const paramTypes: string[][] = [["int64"],["nullable","bool"]] + const returnTypes: string[][] = [] + const params: any[] = [id, requireTLS] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // LogLevels returns the current log levels. + async LogLevels(): Promise<{ [key: string]: string }> { + const fn: string = "LogLevels" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["{}","string"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: string } + } + + // LogLevelSet sets a log level for a package. + async LogLevelSet(pkg: string, levelStr: string): Promise { + const fn: string = "LogLevelSet" + const paramTypes: string[][] = [["string"],["string"]] + const returnTypes: string[][] = [] + const params: any[] = [pkg, levelStr] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // LogLevelRemove removes a log level for a package, which cannot be the empty string. + async LogLevelRemove(pkg: string): Promise { + const fn: string = "LogLevelRemove" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [] + const params: any[] = [pkg] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // CheckUpdatesEnabled returns whether checking for updates is enabled. + async CheckUpdatesEnabled(): Promise { + const fn: string = "CheckUpdatesEnabled" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["bool"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as boolean + } + + // WebserverConfig returns the current webserver config + async WebserverConfig(): Promise { + const fn: string = "WebserverConfig" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["WebserverConfig"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as WebserverConfig + } + + // WebserverConfigSave saves a new webserver config. If oldConf is not equal to + // the current config, an error is returned. + async WebserverConfigSave(oldConf: WebserverConfig, newConf: WebserverConfig): Promise { + const fn: string = "WebserverConfigSave" + const paramTypes: string[][] = [["WebserverConfig"],["WebserverConfig"]] + const returnTypes: string[][] = [["WebserverConfig"]] + const params: any[] = [oldConf, newConf] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as WebserverConfig + } + + // Transports returns the configured transports, for sending email. + async Transports(): Promise<{ [key: string]: Transport }> { + const fn: string = "Transports" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["{}","Transport"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: Transport } + } + + // DMARCEvaluationStats returns a map of all domains with evaluations to a count of + // the evaluations and whether those evaluations will cause a report to be sent. + async DMARCEvaluationStats(): Promise<{ [key: string]: EvaluationStat }> { + const fn: string = "DMARCEvaluationStats" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["{}","EvaluationStat"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: EvaluationStat } + } + + // DMARCEvaluationsDomain returns all evaluations for aggregate reports for the + // domain, sorted from oldest to most recent. + async DMARCEvaluationsDomain(domain: string): Promise<[Domain, Evaluation[] | null]> { + const fn: string = "DMARCEvaluationsDomain" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["Domain"],["[]","Evaluation"]] + const params: any[] = [domain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [Domain, Evaluation[] | null] + } + + // DMARCRemoveEvaluations removes evaluations for a domain. + async DMARCRemoveEvaluations(domain: string): Promise { + const fn: string = "DMARCRemoveEvaluations" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [] + const params: any[] = [domain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // DMARCSuppressAdd adds a reporting address to the suppress list. Outgoing + // reports will be suppressed for a period. + async DMARCSuppressAdd(reportingAddress: string, until: Date, comment: string): Promise { + const fn: string = "DMARCSuppressAdd" + const paramTypes: string[][] = [["string"],["timestamp"],["string"]] + const returnTypes: string[][] = [] + const params: any[] = [reportingAddress, until, comment] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // DMARCSuppressList returns all reporting addresses on the suppress list. + async DMARCSuppressList(): Promise { + const fn: string = "DMARCSuppressList" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["[]","SuppressAddress"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as SuppressAddress[] | null + } + + // DMARCSuppressRemove removes a reporting address record from the suppress list. + async DMARCSuppressRemove(id: number): Promise { + const fn: string = "DMARCSuppressRemove" + const paramTypes: string[][] = [["int64"]] + const returnTypes: string[][] = [] + const params: any[] = [id] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // DMARCSuppressExtend updates the until field of a suppressed reporting address record. + async DMARCSuppressExtend(id: number, until: Date): Promise { + const fn: string = "DMARCSuppressExtend" + const paramTypes: string[][] = [["int64"],["timestamp"]] + const returnTypes: string[][] = [] + const params: any[] = [id, until] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // TLSRPTResults returns all TLSRPT results in the database. + async TLSRPTResults(): Promise { + const fn: string = "TLSRPTResults" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["[]","TLSResult"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSResult[] | null + } + + // TLSRPTResultsPolicyDomain returns the TLS results for a domain. + async TLSRPTResultsDomain(isRcptDom: boolean, policyDomain: string): Promise<[Domain, TLSResult[] | null]> { + const fn: string = "TLSRPTResultsDomain" + const paramTypes: string[][] = [["bool"],["string"]] + const returnTypes: string[][] = [["Domain"],["[]","TLSResult"]] + const params: any[] = [isRcptDom, policyDomain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [Domain, TLSResult[] | null] + } + + // LookupTLSRPTRecord looks up a TLSRPT record and returns the parsed form, original txt + // form from DNS, and error with the TLSRPT record as a string. + async LookupTLSRPTRecord(domain: string): Promise<[TLSRPTRecord | null, string, string]> { + const fn: string = "LookupTLSRPTRecord" + const paramTypes: string[][] = [["string"]] + const returnTypes: string[][] = [["nullable","TLSRPTRecord"],["string"],["string"]] + const params: any[] = [domain] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [TLSRPTRecord | null, string, string] + } + + // TLSRPTRemoveResults removes the TLS results for a domain for the given day. If + // day is empty, all results are removed. + async TLSRPTRemoveResults(isRcptDom: boolean, domain: string, day: string): Promise { + const fn: string = "TLSRPTRemoveResults" + const paramTypes: string[][] = [["bool"],["string"],["string"]] + const returnTypes: string[][] = [] + const params: any[] = [isRcptDom, domain, day] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // TLSRPTSuppressAdd adds a reporting address to the suppress list. Outgoing + // reports will be suppressed for a period. + async TLSRPTSuppressAdd(reportingAddress: string, until: Date, comment: string): Promise { + const fn: string = "TLSRPTSuppressAdd" + const paramTypes: string[][] = [["string"],["timestamp"],["string"]] + const returnTypes: string[][] = [] + const params: any[] = [reportingAddress, until, comment] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // TLSRPTSuppressList returns all reporting addresses on the suppress list. + async TLSRPTSuppressList(): Promise { + const fn: string = "TLSRPTSuppressList" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["[]","TLSRPTSuppressAddress"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSRPTSuppressAddress[] | null + } + + // TLSRPTSuppressRemove removes a reporting address record from the suppress list. + async TLSRPTSuppressRemove(id: number): Promise { + const fn: string = "TLSRPTSuppressRemove" + const paramTypes: string[][] = [["int64"]] + const returnTypes: string[][] = [] + const params: any[] = [id] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } + + // TLSRPTSuppressExtend updates the until field of a suppressed reporting address record. + async TLSRPTSuppressExtend(id: number, until: Date): Promise { + const fn: string = "TLSRPTSuppressExtend" + const paramTypes: string[][] = [["int64"],["timestamp"]] + const returnTypes: string[][] = [] + const params: any[] = [id, until] + return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void + } +} + +export const defaultBaseURL = (function() { + let p = location.pathname + if (p && p[p.length - 1] !== '/') { + let l = location.pathname.split('/') + l = l.slice(0, l.length - 1) + p = '/' + l.join('/') + '/' + } + return location.protocol + '//' + location.host + p + 'api/' +})() + +// NOTE: code below is shared between github.com/mjl-/sherpaweb and github.com/mjl-/sherpats. +// KEEP IN SYNC. + +export const supportedSherpaVersion = 1 + +export interface Section { + Name: string + Docs: string + Functions: Function[] + Sections: Section[] + Structs: Struct[] + Ints: Ints[] + Strings: Strings[] + Version: string // only for top-level section + SherpaVersion: number // only for top-level section + SherpadocVersion: number // only for top-level section +} + +export interface Function { + Name: string + Docs: string + Params: Arg[] + Returns: Arg[] +} + +export interface Arg { + Name: string + Typewords: string[] +} + +export interface Struct { + Name: string + Docs: string + Fields: Field[] +} + +export interface Field { + Name: string + Docs: string + Typewords: string[] +} + +export interface Ints { + Name: string + Docs: string + Values: { + Name: string + Value: number + Docs: string + }[] | null +} + +export interface Strings { + Name: string + Docs: string + Values: { + Name: string + Value: string + Docs: string + }[] | null +} + +export type NamedType = Struct | Strings | Ints +export type TypenameMap = { [k: string]: NamedType } + +// verifyArg typechecks "v" against "typewords", returning a new (possibly modified) value for JSON-encoding. +// toJS indicate if the data is coming into JS. If so, timestamps are turned into JS Dates. Otherwise, JS Dates are turned into strings. +// allowUnknownKeys configures whether unknown keys in structs are allowed. +// types are the named types of the API. +export const verifyArg = (path: string, v: any, typewords: string[], toJS: boolean, allowUnknownKeys: boolean, types: TypenameMap, opts: ClientOptions): any => { + return new verifier(types, toJS, allowUnknownKeys, opts).verify(path, v, typewords) +} + +export const parse = (name: string, v: any): any => verifyArg(name, v, [name], true, false, types, defaultOptions) + +class verifier { + constructor(private types: TypenameMap, private toJS: boolean, private allowUnknownKeys: boolean, private opts: ClientOptions) { + } + + verify(path: string, v: any, typewords: string[]): any { + typewords = typewords.slice(0) + const ww = typewords.shift() + + const error = (msg: string) => { + if (path != '') { + msg = path + ': ' + msg + } + throw new Error(msg) + } + + if (typeof ww !== 'string') { + error('bad typewords') + return // should not be necessary, typescript doesn't see error always throws an exception? + } + const w: string = ww + + const ensure = (ok: boolean, expect: string): any => { + if (!ok) { + error('got ' + JSON.stringify(v) + ', expected ' + expect) + } + return v + } + + switch (w) { + case 'nullable': + if (v === null || v === undefined && this.opts.nullableOptional) { + return v + } + return this.verify(path, v, typewords) + case '[]': + if (v === null && this.opts.slicesNullable || v === undefined && this.opts.slicesNullable && this.opts.nullableOptional) { + return v + } + ensure(Array.isArray(v), "array") + return v.map((e: any, i: number) => this.verify(path + '[' + i + ']', e, typewords)) + case '{}': + if (v === null && this.opts.mapsNullable || v === undefined && this.opts.mapsNullable && this.opts.nullableOptional) { + return v + } + ensure(v !== null || typeof v === 'object', "object") + const r: any = {} + for (const k in v) { + r[k] = this.verify(path + '.' + k, v[k], typewords) + } + return r + } + + ensure(typewords.length == 0, "empty typewords") + const t = typeof v + switch (w) { + case 'any': + return v + case 'bool': + ensure(t === 'boolean', 'bool') + return v + case 'int8': + case 'uint8': + case 'int16': + case 'uint16': + case 'int32': + case 'uint32': + case 'int64': + case 'uint64': + ensure(t === 'number' && Number.isInteger(v), 'integer') + return v + case 'float32': + case 'float64': + ensure(t === 'number', 'float') + return v + case 'int64s': + case 'uint64s': + ensure(t === 'number' && Number.isInteger(v) || t === 'string', 'integer fitting in float without precision loss, or string') + return '' + v + case 'string': + ensure(t === 'string', 'string') + return v + case 'timestamp': + if (this.toJS) { + ensure(t === 'string', 'string, with timestamp') + const d = new Date(v) + if (d instanceof Date && !isNaN(d.getTime())) { + return d + } + error('invalid date ' + v) + } else { + ensure(t === 'object' && v !== null, 'non-null object') + ensure(v.__proto__ === Date.prototype, 'Date') + return v.toISOString() + } + } + + // We're left with named types. + const nt = this.types[w] + if (!nt) { + error('unknown type ' + w) + } + if (v === null) { + error('bad value ' + v + ' for named type ' + w) + } + + if (structTypes[nt.Name]) { + const t = nt as Struct + if (typeof v !== 'object') { + error('bad value ' + v + ' for struct ' + w) + } + + const r: any = {} + for (const f of t.Fields) { + r[f.Name] = this.verify(path + '.' + f.Name, v[f.Name], f.Typewords) + } + // If going to JSON also verify no unknown fields are present. + if (!this.allowUnknownKeys) { + const known: { [key: string]: boolean } = {} + for (const f of t.Fields) { + known[f.Name] = true + } + Object.keys(v).forEach((k) => { + if (!known[k]) { + error('unknown key ' + k + ' for struct ' + w) + } + }) + } + return r + } else if (stringsTypes[nt.Name]) { + const t = nt as Strings + if (typeof v !== 'string') { + error('mistyped value ' + v + ' for named strings ' + t.Name) + } + if (!t.Values || t.Values.length === 0) { + return v + } + for (const sv of t.Values) { + if (sv.Value === v) { + return v + } + } + error('unknkown value ' + v + ' for named strings ' + t.Name) + } else if (intsTypes[nt.Name]) { + const t = nt as Ints + if (typeof v !== 'number' || !Number.isInteger(v)) { + error('mistyped value ' + v + ' for named ints ' + t.Name) + } + if (!t.Values || t.Values.length === 0) { + return v + } + for (const sv of t.Values) { + if (sv.Value === v) { + return v + } + } + error('unknkown value ' + v + ' for named ints ' + t.Name) + } else { + throw new Error('unexpected named type ' + nt) + } + } +} + + +export interface ClientOptions { + aborter?: {abort?: () => void} + timeoutMsec?: number + skipParamCheck?: boolean + skipReturnCheck?: boolean + slicesNullable?: boolean + mapsNullable?: boolean + nullableOptional?: boolean +} + +const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise => { + if (!options.skipParamCheck) { + if (params.length !== paramTypes.length) { + return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length }) + } + params = params.map((v: any, index: number) => verifyArg('params[' + index + ']', v, paramTypes[index], false, false, types, options)) + } + const simulate = async (json: string) => { + const config = JSON.parse(json || 'null') || {} + const waitMinMsec = config.waitMinMsec || 0 + const waitMaxMsec = config.waitMaxMsec || 0 + const wait = Math.random() * (waitMaxMsec - waitMinMsec) + const failRate = config.failRate || 0 + return new Promise((resolve, reject) => { + if (options.aborter) { + options.aborter.abort = () => { + reject({ message: 'call to ' + name + ' aborted by user', code: 'sherpa:aborted' }) + reject = resolve = () => { } + } + } + setTimeout(() => { + const r = Math.random() + if (r < failRate) { + reject({ message: 'injected failure on ' + name, code: 'server:injected' }) + } else { + resolve() + } + reject = resolve = () => { } + }, waitMinMsec + wait) + }) + } + // Only simulate when there is a debug string. Otherwise it would always interfere + // with setting options.aborter. + let json: string = '' + try { + json = window.localStorage.getItem('sherpats-debug') || '' + } catch (err) {} + if (json) { + await simulate(json) + } + + // Immediately create promise, so options.aborter is changed before returning. + const promise = new Promise((resolve, reject) => { + let resolve1 = (v: { code: string, message: string }) => { + resolve(v) + resolve1 = () => { } + reject1 = () => { } + } + let reject1 = (v: { code: string, message: string }) => { + reject(v) + resolve1 = () => { } + reject1 = () => { } + } + + const url = baseURL + name + const req = new window.XMLHttpRequest() + if (options.aborter) { + options.aborter.abort = () => { + req.abort() + reject1({ code: 'sherpa:aborted', message: 'request aborted' }) + } + } + req.open('POST', url, true) + if (options.timeoutMsec) { + req.timeout = options.timeoutMsec + } + req.onload = () => { + if (req.status !== 200) { + if (req.status === 404) { + reject1({ code: 'sherpa:badFunction', message: 'function does not exist' }) + } else { + reject1({ code: 'sherpa:http', message: 'error calling function, HTTP status: ' + req.status }) + } + return + } + + let resp: any + try { + resp = JSON.parse(req.responseText) + } catch (err) { + reject1({ code: 'sherpa:badResponse', message: 'bad JSON from server' }) + return + } + if (resp && resp.error) { + const err = resp.error + reject1({ code: err.code, message: err.message }) + return + } else if (!resp || !resp.hasOwnProperty('result')) { + reject1({ code: 'sherpa:badResponse', message: "invalid sherpa response object, missing 'result'" }) + return + } + + if (options.skipReturnCheck) { + resolve1(resp.result) + return + } + let result = resp.result + try { + if (returnTypes.length === 0) { + if (result) { + throw new Error('function ' + name + ' returned a value while prototype says it returns "void"') + } + } else if (returnTypes.length === 1) { + result = verifyArg('result', result, returnTypes[0], true, true, types, options) + } else { + if (result.length != returnTypes.length) { + throw new Error('wrong number of values returned by ' + name + ', saw ' + result.length + ' != expected ' + returnTypes.length) + } + result = result.map((v: any, index: number) => verifyArg('result[' + index + ']', v, returnTypes[index], true, true, types, options)) + } + } catch (err) { + let errmsg = 'bad types' + if (err instanceof Error) { + errmsg = err.message + } + reject1({ code: 'sherpa:badTypes', message: errmsg }) + } + resolve1(result) + } + req.onerror = () => { + reject1({ code: 'sherpa:connection', message: 'connection failed' }) + } + req.ontimeout = () => { + reject1({ code: 'sherpa:timeout', message: 'request timeout' }) + } + req.setRequestHeader('Content-Type', 'application/json') + try { + req.send(JSON.stringify({ params: params })) + } catch (err) { + reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' }) + } + }) + return await promise +} + +} diff --git a/webmail/lib.ts b/webmail/lib.ts index ac4ca5e..f580e50 100644 --- a/webmail/lib.ts +++ b/webmail/lib.ts @@ -1,217 +1,5 @@ // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. -type ElemArg = string | String | Element | Function | {_class: string[]} | {_attrs: {[k: string]: string}} | {_styles: {[k: string]: string | number}} | {_props: {[k: string]: any}} | {root: HTMLElement} | ElemArg[] - -const [dom, style, attr, prop] = (function() { - -// Start of unicode block (rough approximation of script), from https://www.unicode.org/Public/UNIDATA/Blocks.txt -const scriptblocks = [0x0000, 0x0080, 0x0100, 0x0180, 0x0250, 0x02B0, 0x0300, 0x0370, 0x0400, 0x0500, 0x0530, 0x0590, 0x0600, 0x0700, 0x0750, 0x0780, 0x07C0, 0x0800, 0x0840, 0x0860, 0x0870, 0x08A0, 0x0900, 0x0980, 0x0A00, 0x0A80, 0x0B00, 0x0B80, 0x0C00, 0x0C80, 0x0D00, 0x0D80, 0x0E00, 0x0E80, 0x0F00, 0x1000, 0x10A0, 0x1100, 0x1200, 0x1380, 0x13A0, 0x1400, 0x1680, 0x16A0, 0x1700, 0x1720, 0x1740, 0x1760, 0x1780, 0x1800, 0x18B0, 0x1900, 0x1950, 0x1980, 0x19E0, 0x1A00, 0x1A20, 0x1AB0, 0x1B00, 0x1B80, 0x1BC0, 0x1C00, 0x1C50, 0x1C80, 0x1C90, 0x1CC0, 0x1CD0, 0x1D00, 0x1D80, 0x1DC0, 0x1E00, 0x1F00, 0x2000, 0x2070, 0x20A0, 0x20D0, 0x2100, 0x2150, 0x2190, 0x2200, 0x2300, 0x2400, 0x2440, 0x2460, 0x2500, 0x2580, 0x25A0, 0x2600, 0x2700, 0x27C0, 0x27F0, 0x2800, 0x2900, 0x2980, 0x2A00, 0x2B00, 0x2C00, 0x2C60, 0x2C80, 0x2D00, 0x2D30, 0x2D80, 0x2DE0, 0x2E00, 0x2E80, 0x2F00, 0x2FF0, 0x3000, 0x3040, 0x30A0, 0x3100, 0x3130, 0x3190, 0x31A0, 0x31C0, 0x31F0, 0x3200, 0x3300, 0x3400, 0x4DC0, 0x4E00, 0xA000, 0xA490, 0xA4D0, 0xA500, 0xA640, 0xA6A0, 0xA700, 0xA720, 0xA800, 0xA830, 0xA840, 0xA880, 0xA8E0, 0xA900, 0xA930, 0xA960, 0xA980, 0xA9E0, 0xAA00, 0xAA60, 0xAA80, 0xAAE0, 0xAB00, 0xAB30, 0xAB70, 0xABC0, 0xAC00, 0xD7B0, 0xD800, 0xDB80, 0xDC00, 0xE000, 0xF900, 0xFB00, 0xFB50, 0xFE00, 0xFE10, 0xFE20, 0xFE30, 0xFE50, 0xFE70, 0xFF00, 0xFFF0, 0x10000, 0x10080, 0x10100, 0x10140, 0x10190, 0x101D0, 0x10280, 0x102A0, 0x102E0, 0x10300, 0x10330, 0x10350, 0x10380, 0x103A0, 0x10400, 0x10450, 0x10480, 0x104B0, 0x10500, 0x10530, 0x10570, 0x10600, 0x10780, 0x10800, 0x10840, 0x10860, 0x10880, 0x108E0, 0x10900, 0x10920, 0x10980, 0x109A0, 0x10A00, 0x10A60, 0x10A80, 0x10AC0, 0x10B00, 0x10B40, 0x10B60, 0x10B80, 0x10C00, 0x10C80, 0x10D00, 0x10E60, 0x10E80, 0x10EC0, 0x10F00, 0x10F30, 0x10F70, 0x10FB0, 0x10FE0, 0x11000, 0x11080, 0x110D0, 0x11100, 0x11150, 0x11180, 0x111E0, 0x11200, 0x11280, 0x112B0, 0x11300, 0x11400, 0x11480, 0x11580, 0x11600, 0x11660, 0x11680, 0x11700, 0x11800, 0x118A0, 0x11900, 0x119A0, 0x11A00, 0x11A50, 0x11AB0, 0x11AC0, 0x11B00, 0x11C00, 0x11C70, 0x11D00, 0x11D60, 0x11EE0, 0x11F00, 0x11FB0, 0x11FC0, 0x12000, 0x12400, 0x12480, 0x12F90, 0x13000, 0x13430, 0x14400, 0x16800, 0x16A40, 0x16A70, 0x16AD0, 0x16B00, 0x16E40, 0x16F00, 0x16FE0, 0x17000, 0x18800, 0x18B00, 0x18D00, 0x1AFF0, 0x1B000, 0x1B100, 0x1B130, 0x1B170, 0x1BC00, 0x1BCA0, 0x1CF00, 0x1D000, 0x1D100, 0x1D200, 0x1D2C0, 0x1D2E0, 0x1D300, 0x1D360, 0x1D400, 0x1D800, 0x1DF00, 0x1E000, 0x1E030, 0x1E100, 0x1E290, 0x1E2C0, 0x1E4D0, 0x1E7E0, 0x1E800, 0x1E900, 0x1EC70, 0x1ED00, 0x1EE00, 0x1F000, 0x1F030, 0x1F0A0, 0x1F100, 0x1F200, 0x1F300, 0x1F600, 0x1F650, 0x1F680, 0x1F700, 0x1F780, 0x1F800, 0x1F900, 0x1FA00, 0x1FA70, 0x1FB00, 0x20000, 0x2A700, 0x2B740, 0x2B820, 0x2CEB0, 0x2F800, 0x30000, 0x31350, 0xE0000, 0xE0100, 0xF0000, 0x100000] - -// Find block code belongs in. -const findBlock = (code: number): number => { - let s = 0 - let e = scriptblocks.length - while (s < e-1) { - let i = Math.floor((s+e)/2) - if (code < scriptblocks[i]) { - e = i - } else { - s = i - } - } - return s -} - -// formatText adds s to element e, in a way that makes switching unicode scripts -// clear, with alternating DOM TextNode and span elements with a "switchscript" -// class. Useful for highlighting look alikes, e.g. a (ascii 0x61) and а (cyrillic -// 0x430). -// -// This is only called one string at a time, so the UI can still display strings -// without highlighting switching scripts, by calling formatText on the parts. -const formatText = (e: HTMLElement, s: string): void => { - // Handle some common cases quickly. - if (!s) { - return - } - let ascii = true - for (const c of s) { - const cp = c.codePointAt(0) // For typescript, to check for undefined. - if (cp !== undefined && cp >= 0x0080) { - ascii = false - break - } - } - if (ascii) { - e.appendChild(document.createTextNode(s)) - return - } - - // todo: handle grapheme clusters? wait for Intl.Segmenter? - - let n = 0 // Number of text/span parts added. - let str = '' // Collected so far. - let block = -1 // Previous block/script. - let mod = 1 - const put = (nextblock: number) => { - if (n === 0 && nextblock === 0) { - // Start was non-ascii, second block is ascii, we'll start marked as switched. - mod = 0 - } - if (n % 2 === mod) { - const x = document.createElement('span') - x.classList.add('scriptswitch') - x.appendChild(document.createTextNode(str)) - e.appendChild(x) - } else { - e.appendChild(document.createTextNode(str)) - } - n++ - str = '' - } - for (const c of s) { - // Basic whitespace does not switch blocks. Will probably need to extend with more - // punctuation in the future. Possibly for digits too. But perhaps not in all - // scripts. - if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { - str += c - continue - } - const code: number = c.codePointAt(0) as number - if (block < 0 || !(code >= scriptblocks[block] && (code < scriptblocks[block+1] || block === scriptblocks.length-1))) { - const nextblock = code < 0x0080 ? 0 : findBlock(code) - if (block >= 0) { - put(nextblock) - } - block = nextblock - } - str += c - } - put(-1) -} - -const _domKids = (e: T, l: ElemArg[]): T => { - l.forEach((c) => { - const xc = c as {[k: string]: any} - if (typeof c === 'string') { - formatText(e, c) - } else if (c instanceof String) { - // String is an escape-hatch for text that should not be formatted with - // unicode-block-change-highlighting, e.g. for textarea values. - e.appendChild(document.createTextNode(''+c)) - } else if (c instanceof Element) { - e.appendChild(c) - } else if (c instanceof Function) { - if (!c.name) { - throw new Error('function without name') - } - e.addEventListener(c.name as string, c as EventListener) - } else if (Array.isArray(xc)) { - _domKids(e, c as ElemArg[]) - } else if (xc._class) { - for (const s of xc._class) { - e.classList.toggle(s, true) - } - } else if (xc._attrs) { - for (const k in xc._attrs) { - e.setAttribute(k, xc._attrs[k]) - } - } else if (xc._styles) { - for (const k in xc._styles) { - const estyle: {[k: string]: any} = e.style - estyle[k as string] = xc._styles[k] - } - } else if (xc._props) { - for (const k in xc._props) { - const eprops: {[k: string]: any} = e - eprops[k] = xc._props[k] - } - } else if (xc.root) { - e.appendChild(xc.root) - } else { - console.log('bad kid', c) - throw new Error('bad kid') - } - }) - return e -} -const dom = { - _kids: function(e: HTMLElement, ...kl: ElemArg[]) { - while(e.firstChild) { - e.removeChild(e.firstChild) - } - _domKids(e, kl) - }, - _attrs: (x: {[k: string]: string}) => { return {_attrs: x}}, - _class: (...x: string[]) => { return {_class: x}}, - // The createElement calls are spelled out so typescript can derive function - // signatures with a specific HTML*Element return type. - div: (...l: ElemArg[]) => _domKids(document.createElement('div'), l), - span: (...l: ElemArg[]) => _domKids(document.createElement('span'), l), - a: (...l: ElemArg[]) => _domKids(document.createElement('a'), l), - input: (...l: ElemArg[]) => _domKids(document.createElement('input'), l), - textarea: (...l: ElemArg[]) => _domKids(document.createElement('textarea'), l), - select: (...l: ElemArg[]) => _domKids(document.createElement('select'), l), - option: (...l: ElemArg[]) => _domKids(document.createElement('option'), l), - clickbutton: (...l: ElemArg[]) => _domKids(document.createElement('button'), [attr.type('button'), ...l]), - submitbutton: (...l: ElemArg[]) => _domKids(document.createElement('button'), [attr.type('submit'), ...l]), - form: (...l: ElemArg[]) => _domKids(document.createElement('form'), l), - fieldset: (...l: ElemArg[]) => _domKids(document.createElement('fieldset'), l), - table: (...l: ElemArg[]) => _domKids(document.createElement('table'), l), - thead: (...l: ElemArg[]) => _domKids(document.createElement('thead'), l), - tbody: (...l: ElemArg[]) => _domKids(document.createElement('tbody'), l), - tr: (...l: ElemArg[]) => _domKids(document.createElement('tr'), l), - td: (...l: ElemArg[]) => _domKids(document.createElement('td'), l), - th: (...l: ElemArg[]) => _domKids(document.createElement('th'), l), - datalist: (...l: ElemArg[]) => _domKids(document.createElement('datalist'), l), - h1: (...l: ElemArg[]) => _domKids(document.createElement('h1'), l), - h2: (...l: ElemArg[]) => _domKids(document.createElement('h2'), l), - br: (...l: ElemArg[]) => _domKids(document.createElement('br'), l), - hr: (...l: ElemArg[]) => _domKids(document.createElement('hr'), l), - pre: (...l: ElemArg[]) => _domKids(document.createElement('pre'), l), - label: (...l: ElemArg[]) => _domKids(document.createElement('label'), l), - ul: (...l: ElemArg[]) => _domKids(document.createElement('ul'), l), - li: (...l: ElemArg[]) => _domKids(document.createElement('li'), l), - iframe: (...l: ElemArg[]) => _domKids(document.createElement('iframe'), l), - b: (...l: ElemArg[]) => _domKids(document.createElement('b'), l), - img: (...l: ElemArg[]) => _domKids(document.createElement('img'), l), - style: (...l: ElemArg[]) => _domKids(document.createElement('style'), l), - search: (...l: ElemArg[]) => _domKids(document.createElement('search'), l), -} -const _attr = (k: string, v: string) => { const o: {[key: string]: string} = {}; o[k] = v; return {_attrs: o} } -const attr = { - title: (s: string) => _attr('title', s), - value: (s: string) => _attr('value', s), - type: (s: string) => _attr('type', s), - tabindex: (s: string) => _attr('tabindex', s), - src: (s: string) => _attr('src', s), - placeholder: (s: string) => _attr('placeholder', s), - href: (s: string) => _attr('href', s), - checked: (s: string) => _attr('checked', s), - selected: (s: string) => _attr('selected', s), - id: (s: string) => _attr('id', s), - datalist: (s: string) => _attr('datalist', s), - rows: (s: string) => _attr('rows', s), - target: (s: string) => _attr('target', s), - rel: (s: string) => _attr('rel', s), - required: (s: string) => _attr('required', s), - multiple: (s: string) => _attr('multiple', s), - download: (s: string) => _attr('download', s), - disabled: (s: string) => _attr('disabled', s), - draggable: (s: string) => _attr('draggable', s), - rowspan: (s: string) => _attr('rowspan', s), - colspan: (s: string) => _attr('colspan', s), - for: (s: string) => _attr('for', s), - role: (s: string) => _attr('role', s), - arialabel: (s: string) => _attr('aria-label', s), - arialive: (s: string) => _attr('aria-live', s), - name: (s: string) => _attr('name', s) -} -const style = (x: {[k: string]: string | number}) => { return {_styles: x}} -const prop = (x: {[k: string]: any}) => { return {_props: x}} -return [dom, style, attr, prop] -})() - // For authentication/security results. const underlineGreen = '#50c40f' const underlineRed = '#e15d1c' diff --git a/webmail/msg.js b/webmail/msg.js index e2cda4f..7808591 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -1,4 +1,225 @@ "use strict"; +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. +const [dom, style, attr, prop] = (function () { + // Start of unicode block (rough approximation of script), from https://www.unicode.org/Public/UNIDATA/Blocks.txt + const scriptblocks = [0x0000, 0x0080, 0x0100, 0x0180, 0x0250, 0x02B0, 0x0300, 0x0370, 0x0400, 0x0500, 0x0530, 0x0590, 0x0600, 0x0700, 0x0750, 0x0780, 0x07C0, 0x0800, 0x0840, 0x0860, 0x0870, 0x08A0, 0x0900, 0x0980, 0x0A00, 0x0A80, 0x0B00, 0x0B80, 0x0C00, 0x0C80, 0x0D00, 0x0D80, 0x0E00, 0x0E80, 0x0F00, 0x1000, 0x10A0, 0x1100, 0x1200, 0x1380, 0x13A0, 0x1400, 0x1680, 0x16A0, 0x1700, 0x1720, 0x1740, 0x1760, 0x1780, 0x1800, 0x18B0, 0x1900, 0x1950, 0x1980, 0x19E0, 0x1A00, 0x1A20, 0x1AB0, 0x1B00, 0x1B80, 0x1BC0, 0x1C00, 0x1C50, 0x1C80, 0x1C90, 0x1CC0, 0x1CD0, 0x1D00, 0x1D80, 0x1DC0, 0x1E00, 0x1F00, 0x2000, 0x2070, 0x20A0, 0x20D0, 0x2100, 0x2150, 0x2190, 0x2200, 0x2300, 0x2400, 0x2440, 0x2460, 0x2500, 0x2580, 0x25A0, 0x2600, 0x2700, 0x27C0, 0x27F0, 0x2800, 0x2900, 0x2980, 0x2A00, 0x2B00, 0x2C00, 0x2C60, 0x2C80, 0x2D00, 0x2D30, 0x2D80, 0x2DE0, 0x2E00, 0x2E80, 0x2F00, 0x2FF0, 0x3000, 0x3040, 0x30A0, 0x3100, 0x3130, 0x3190, 0x31A0, 0x31C0, 0x31F0, 0x3200, 0x3300, 0x3400, 0x4DC0, 0x4E00, 0xA000, 0xA490, 0xA4D0, 0xA500, 0xA640, 0xA6A0, 0xA700, 0xA720, 0xA800, 0xA830, 0xA840, 0xA880, 0xA8E0, 0xA900, 0xA930, 0xA960, 0xA980, 0xA9E0, 0xAA00, 0xAA60, 0xAA80, 0xAAE0, 0xAB00, 0xAB30, 0xAB70, 0xABC0, 0xAC00, 0xD7B0, 0xD800, 0xDB80, 0xDC00, 0xE000, 0xF900, 0xFB00, 0xFB50, 0xFE00, 0xFE10, 0xFE20, 0xFE30, 0xFE50, 0xFE70, 0xFF00, 0xFFF0, 0x10000, 0x10080, 0x10100, 0x10140, 0x10190, 0x101D0, 0x10280, 0x102A0, 0x102E0, 0x10300, 0x10330, 0x10350, 0x10380, 0x103A0, 0x10400, 0x10450, 0x10480, 0x104B0, 0x10500, 0x10530, 0x10570, 0x10600, 0x10780, 0x10800, 0x10840, 0x10860, 0x10880, 0x108E0, 0x10900, 0x10920, 0x10980, 0x109A0, 0x10A00, 0x10A60, 0x10A80, 0x10AC0, 0x10B00, 0x10B40, 0x10B60, 0x10B80, 0x10C00, 0x10C80, 0x10D00, 0x10E60, 0x10E80, 0x10EC0, 0x10F00, 0x10F30, 0x10F70, 0x10FB0, 0x10FE0, 0x11000, 0x11080, 0x110D0, 0x11100, 0x11150, 0x11180, 0x111E0, 0x11200, 0x11280, 0x112B0, 0x11300, 0x11400, 0x11480, 0x11580, 0x11600, 0x11660, 0x11680, 0x11700, 0x11800, 0x118A0, 0x11900, 0x119A0, 0x11A00, 0x11A50, 0x11AB0, 0x11AC0, 0x11B00, 0x11C00, 0x11C70, 0x11D00, 0x11D60, 0x11EE0, 0x11F00, 0x11FB0, 0x11FC0, 0x12000, 0x12400, 0x12480, 0x12F90, 0x13000, 0x13430, 0x14400, 0x16800, 0x16A40, 0x16A70, 0x16AD0, 0x16B00, 0x16E40, 0x16F00, 0x16FE0, 0x17000, 0x18800, 0x18B00, 0x18D00, 0x1AFF0, 0x1B000, 0x1B100, 0x1B130, 0x1B170, 0x1BC00, 0x1BCA0, 0x1CF00, 0x1D000, 0x1D100, 0x1D200, 0x1D2C0, 0x1D2E0, 0x1D300, 0x1D360, 0x1D400, 0x1D800, 0x1DF00, 0x1E000, 0x1E030, 0x1E100, 0x1E290, 0x1E2C0, 0x1E4D0, 0x1E7E0, 0x1E800, 0x1E900, 0x1EC70, 0x1ED00, 0x1EE00, 0x1F000, 0x1F030, 0x1F0A0, 0x1F100, 0x1F200, 0x1F300, 0x1F600, 0x1F650, 0x1F680, 0x1F700, 0x1F780, 0x1F800, 0x1F900, 0x1FA00, 0x1FA70, 0x1FB00, 0x20000, 0x2A700, 0x2B740, 0x2B820, 0x2CEB0, 0x2F800, 0x30000, 0x31350, 0xE0000, 0xE0100, 0xF0000, 0x100000]; + // Find block code belongs in. + const findBlock = (code) => { + let s = 0; + let e = scriptblocks.length; + while (s < e - 1) { + let i = Math.floor((s + e) / 2); + if (code < scriptblocks[i]) { + e = i; + } + else { + s = i; + } + } + return s; + }; + // formatText adds s to element e, in a way that makes switching unicode scripts + // clear, with alternating DOM TextNode and span elements with a "switchscript" + // class. Useful for highlighting look alikes, e.g. a (ascii 0x61) and а (cyrillic + // 0x430). + // + // This is only called one string at a time, so the UI can still display strings + // without highlighting switching scripts, by calling formatText on the parts. + const formatText = (e, s) => { + // Handle some common cases quickly. + if (!s) { + return; + } + let ascii = true; + for (const c of s) { + const cp = c.codePointAt(0); // For typescript, to check for undefined. + if (cp !== undefined && cp >= 0x0080) { + ascii = false; + break; + } + } + if (ascii) { + e.appendChild(document.createTextNode(s)); + return; + } + // todo: handle grapheme clusters? wait for Intl.Segmenter? + let n = 0; // Number of text/span parts added. + let str = ''; // Collected so far. + let block = -1; // Previous block/script. + let mod = 1; + const put = (nextblock) => { + if (n === 0 && nextblock === 0) { + // Start was non-ascii, second block is ascii, we'll start marked as switched. + mod = 0; + } + if (n % 2 === mod) { + const x = document.createElement('span'); + x.classList.add('scriptswitch'); + x.appendChild(document.createTextNode(str)); + e.appendChild(x); + } + else { + e.appendChild(document.createTextNode(str)); + } + n++; + str = ''; + }; + for (const c of s) { + // Basic whitespace does not switch blocks. Will probably need to extend with more + // punctuation in the future. Possibly for digits too. But perhaps not in all + // scripts. + if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { + str += c; + continue; + } + const code = c.codePointAt(0); + if (block < 0 || !(code >= scriptblocks[block] && (code < scriptblocks[block + 1] || block === scriptblocks.length - 1))) { + const nextblock = code < 0x0080 ? 0 : findBlock(code); + if (block >= 0) { + put(nextblock); + } + block = nextblock; + } + str += c; + } + put(-1); + }; + const _domKids = (e, l) => { + l.forEach((c) => { + const xc = c; + if (typeof c === 'string') { + formatText(e, c); + } + else if (c instanceof String) { + // String is an escape-hatch for text that should not be formatted with + // unicode-block-change-highlighting, e.g. for textarea values. + e.appendChild(document.createTextNode('' + c)); + } + else if (c instanceof Element) { + e.appendChild(c); + } + else if (c instanceof Function) { + if (!c.name) { + throw new Error('function without name'); + } + e.addEventListener(c.name, c); + } + else if (Array.isArray(xc)) { + _domKids(e, c); + } + else if (xc._class) { + for (const s of xc._class) { + e.classList.toggle(s, true); + } + } + else if (xc._attrs) { + for (const k in xc._attrs) { + e.setAttribute(k, xc._attrs[k]); + } + } + else if (xc._styles) { + for (const k in xc._styles) { + const estyle = e.style; + estyle[k] = xc._styles[k]; + } + } + else if (xc._props) { + for (const k in xc._props) { + const eprops = e; + eprops[k] = xc._props[k]; + } + } + else if (xc.root) { + e.appendChild(xc.root); + } + else { + console.log('bad kid', c); + throw new Error('bad kid'); + } + }); + return e; + }; + const dom = { + _kids: function (e, ...kl) { + while (e.firstChild) { + e.removeChild(e.firstChild); + } + _domKids(e, kl); + }, + _attrs: (x) => { return { _attrs: x }; }, + _class: (...x) => { return { _class: x }; }, + // The createElement calls are spelled out so typescript can derive function + // signatures with a specific HTML*Element return type. + div: (...l) => _domKids(document.createElement('div'), l), + span: (...l) => _domKids(document.createElement('span'), l), + a: (...l) => _domKids(document.createElement('a'), l), + input: (...l) => _domKids(document.createElement('input'), l), + textarea: (...l) => _domKids(document.createElement('textarea'), l), + select: (...l) => _domKids(document.createElement('select'), l), + option: (...l) => _domKids(document.createElement('option'), l), + clickbutton: (...l) => _domKids(document.createElement('button'), [attr.type('button'), ...l]), + submitbutton: (...l) => _domKids(document.createElement('button'), [attr.type('submit'), ...l]), + form: (...l) => _domKids(document.createElement('form'), l), + fieldset: (...l) => _domKids(document.createElement('fieldset'), l), + table: (...l) => _domKids(document.createElement('table'), l), + thead: (...l) => _domKids(document.createElement('thead'), l), + tbody: (...l) => _domKids(document.createElement('tbody'), l), + tfoot: (...l) => _domKids(document.createElement('tfoot'), l), + tr: (...l) => _domKids(document.createElement('tr'), l), + td: (...l) => _domKids(document.createElement('td'), l), + th: (...l) => _domKids(document.createElement('th'), l), + datalist: (...l) => _domKids(document.createElement('datalist'), l), + h1: (...l) => _domKids(document.createElement('h1'), l), + h2: (...l) => _domKids(document.createElement('h2'), l), + h3: (...l) => _domKids(document.createElement('h3'), l), + br: (...l) => _domKids(document.createElement('br'), l), + hr: (...l) => _domKids(document.createElement('hr'), l), + pre: (...l) => _domKids(document.createElement('pre'), l), + label: (...l) => _domKids(document.createElement('label'), l), + ul: (...l) => _domKids(document.createElement('ul'), l), + li: (...l) => _domKids(document.createElement('li'), l), + iframe: (...l) => _domKids(document.createElement('iframe'), l), + b: (...l) => _domKids(document.createElement('b'), l), + img: (...l) => _domKids(document.createElement('img'), l), + style: (...l) => _domKids(document.createElement('style'), l), + search: (...l) => _domKids(document.createElement('search'), l), + p: (...l) => _domKids(document.createElement('p'), l), + }; + const _attr = (k, v) => { const o = {}; o[k] = v; return { _attrs: o }; }; + const attr = { + title: (s) => _attr('title', s), + value: (s) => _attr('value', s), + type: (s) => _attr('type', s), + tabindex: (s) => _attr('tabindex', s), + src: (s) => _attr('src', s), + placeholder: (s) => _attr('placeholder', s), + href: (s) => _attr('href', s), + checked: (s) => _attr('checked', s), + selected: (s) => _attr('selected', s), + id: (s) => _attr('id', s), + datalist: (s) => _attr('datalist', s), + rows: (s) => _attr('rows', s), + target: (s) => _attr('target', s), + rel: (s) => _attr('rel', s), + required: (s) => _attr('required', s), + multiple: (s) => _attr('multiple', s), + download: (s) => _attr('download', s), + disabled: (s) => _attr('disabled', s), + draggable: (s) => _attr('draggable', s), + rowspan: (s) => _attr('rowspan', s), + colspan: (s) => _attr('colspan', s), + for: (s) => _attr('for', s), + role: (s) => _attr('role', s), + arialabel: (s) => _attr('aria-label', s), + arialive: (s) => _attr('aria-live', s), + name: (s) => _attr('name', s), + min: (s) => _attr('min', s), + max: (s) => _attr('max', s), + }; + const style = (x) => { return { _styles: x }; }; + const prop = (x) => { return { _props: x }; }; + return [dom, style, attr, prop]; +})(); // NOTE: GENERATED by github.com/mjl-/sherpats, DO NOT MODIFY var api; (function (api) { @@ -645,221 +866,6 @@ var api; }; })(api || (api = {})); // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. -const [dom, style, attr, prop] = (function () { - // Start of unicode block (rough approximation of script), from https://www.unicode.org/Public/UNIDATA/Blocks.txt - const scriptblocks = [0x0000, 0x0080, 0x0100, 0x0180, 0x0250, 0x02B0, 0x0300, 0x0370, 0x0400, 0x0500, 0x0530, 0x0590, 0x0600, 0x0700, 0x0750, 0x0780, 0x07C0, 0x0800, 0x0840, 0x0860, 0x0870, 0x08A0, 0x0900, 0x0980, 0x0A00, 0x0A80, 0x0B00, 0x0B80, 0x0C00, 0x0C80, 0x0D00, 0x0D80, 0x0E00, 0x0E80, 0x0F00, 0x1000, 0x10A0, 0x1100, 0x1200, 0x1380, 0x13A0, 0x1400, 0x1680, 0x16A0, 0x1700, 0x1720, 0x1740, 0x1760, 0x1780, 0x1800, 0x18B0, 0x1900, 0x1950, 0x1980, 0x19E0, 0x1A00, 0x1A20, 0x1AB0, 0x1B00, 0x1B80, 0x1BC0, 0x1C00, 0x1C50, 0x1C80, 0x1C90, 0x1CC0, 0x1CD0, 0x1D00, 0x1D80, 0x1DC0, 0x1E00, 0x1F00, 0x2000, 0x2070, 0x20A0, 0x20D0, 0x2100, 0x2150, 0x2190, 0x2200, 0x2300, 0x2400, 0x2440, 0x2460, 0x2500, 0x2580, 0x25A0, 0x2600, 0x2700, 0x27C0, 0x27F0, 0x2800, 0x2900, 0x2980, 0x2A00, 0x2B00, 0x2C00, 0x2C60, 0x2C80, 0x2D00, 0x2D30, 0x2D80, 0x2DE0, 0x2E00, 0x2E80, 0x2F00, 0x2FF0, 0x3000, 0x3040, 0x30A0, 0x3100, 0x3130, 0x3190, 0x31A0, 0x31C0, 0x31F0, 0x3200, 0x3300, 0x3400, 0x4DC0, 0x4E00, 0xA000, 0xA490, 0xA4D0, 0xA500, 0xA640, 0xA6A0, 0xA700, 0xA720, 0xA800, 0xA830, 0xA840, 0xA880, 0xA8E0, 0xA900, 0xA930, 0xA960, 0xA980, 0xA9E0, 0xAA00, 0xAA60, 0xAA80, 0xAAE0, 0xAB00, 0xAB30, 0xAB70, 0xABC0, 0xAC00, 0xD7B0, 0xD800, 0xDB80, 0xDC00, 0xE000, 0xF900, 0xFB00, 0xFB50, 0xFE00, 0xFE10, 0xFE20, 0xFE30, 0xFE50, 0xFE70, 0xFF00, 0xFFF0, 0x10000, 0x10080, 0x10100, 0x10140, 0x10190, 0x101D0, 0x10280, 0x102A0, 0x102E0, 0x10300, 0x10330, 0x10350, 0x10380, 0x103A0, 0x10400, 0x10450, 0x10480, 0x104B0, 0x10500, 0x10530, 0x10570, 0x10600, 0x10780, 0x10800, 0x10840, 0x10860, 0x10880, 0x108E0, 0x10900, 0x10920, 0x10980, 0x109A0, 0x10A00, 0x10A60, 0x10A80, 0x10AC0, 0x10B00, 0x10B40, 0x10B60, 0x10B80, 0x10C00, 0x10C80, 0x10D00, 0x10E60, 0x10E80, 0x10EC0, 0x10F00, 0x10F30, 0x10F70, 0x10FB0, 0x10FE0, 0x11000, 0x11080, 0x110D0, 0x11100, 0x11150, 0x11180, 0x111E0, 0x11200, 0x11280, 0x112B0, 0x11300, 0x11400, 0x11480, 0x11580, 0x11600, 0x11660, 0x11680, 0x11700, 0x11800, 0x118A0, 0x11900, 0x119A0, 0x11A00, 0x11A50, 0x11AB0, 0x11AC0, 0x11B00, 0x11C00, 0x11C70, 0x11D00, 0x11D60, 0x11EE0, 0x11F00, 0x11FB0, 0x11FC0, 0x12000, 0x12400, 0x12480, 0x12F90, 0x13000, 0x13430, 0x14400, 0x16800, 0x16A40, 0x16A70, 0x16AD0, 0x16B00, 0x16E40, 0x16F00, 0x16FE0, 0x17000, 0x18800, 0x18B00, 0x18D00, 0x1AFF0, 0x1B000, 0x1B100, 0x1B130, 0x1B170, 0x1BC00, 0x1BCA0, 0x1CF00, 0x1D000, 0x1D100, 0x1D200, 0x1D2C0, 0x1D2E0, 0x1D300, 0x1D360, 0x1D400, 0x1D800, 0x1DF00, 0x1E000, 0x1E030, 0x1E100, 0x1E290, 0x1E2C0, 0x1E4D0, 0x1E7E0, 0x1E800, 0x1E900, 0x1EC70, 0x1ED00, 0x1EE00, 0x1F000, 0x1F030, 0x1F0A0, 0x1F100, 0x1F200, 0x1F300, 0x1F600, 0x1F650, 0x1F680, 0x1F700, 0x1F780, 0x1F800, 0x1F900, 0x1FA00, 0x1FA70, 0x1FB00, 0x20000, 0x2A700, 0x2B740, 0x2B820, 0x2CEB0, 0x2F800, 0x30000, 0x31350, 0xE0000, 0xE0100, 0xF0000, 0x100000]; - // Find block code belongs in. - const findBlock = (code) => { - let s = 0; - let e = scriptblocks.length; - while (s < e - 1) { - let i = Math.floor((s + e) / 2); - if (code < scriptblocks[i]) { - e = i; - } - else { - s = i; - } - } - return s; - }; - // formatText adds s to element e, in a way that makes switching unicode scripts - // clear, with alternating DOM TextNode and span elements with a "switchscript" - // class. Useful for highlighting look alikes, e.g. a (ascii 0x61) and а (cyrillic - // 0x430). - // - // This is only called one string at a time, so the UI can still display strings - // without highlighting switching scripts, by calling formatText on the parts. - const formatText = (e, s) => { - // Handle some common cases quickly. - if (!s) { - return; - } - let ascii = true; - for (const c of s) { - const cp = c.codePointAt(0); // For typescript, to check for undefined. - if (cp !== undefined && cp >= 0x0080) { - ascii = false; - break; - } - } - if (ascii) { - e.appendChild(document.createTextNode(s)); - return; - } - // todo: handle grapheme clusters? wait for Intl.Segmenter? - let n = 0; // Number of text/span parts added. - let str = ''; // Collected so far. - let block = -1; // Previous block/script. - let mod = 1; - const put = (nextblock) => { - if (n === 0 && nextblock === 0) { - // Start was non-ascii, second block is ascii, we'll start marked as switched. - mod = 0; - } - if (n % 2 === mod) { - const x = document.createElement('span'); - x.classList.add('scriptswitch'); - x.appendChild(document.createTextNode(str)); - e.appendChild(x); - } - else { - e.appendChild(document.createTextNode(str)); - } - n++; - str = ''; - }; - for (const c of s) { - // Basic whitespace does not switch blocks. Will probably need to extend with more - // punctuation in the future. Possibly for digits too. But perhaps not in all - // scripts. - if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { - str += c; - continue; - } - const code = c.codePointAt(0); - if (block < 0 || !(code >= scriptblocks[block] && (code < scriptblocks[block + 1] || block === scriptblocks.length - 1))) { - const nextblock = code < 0x0080 ? 0 : findBlock(code); - if (block >= 0) { - put(nextblock); - } - block = nextblock; - } - str += c; - } - put(-1); - }; - const _domKids = (e, l) => { - l.forEach((c) => { - const xc = c; - if (typeof c === 'string') { - formatText(e, c); - } - else if (c instanceof String) { - // String is an escape-hatch for text that should not be formatted with - // unicode-block-change-highlighting, e.g. for textarea values. - e.appendChild(document.createTextNode('' + c)); - } - else if (c instanceof Element) { - e.appendChild(c); - } - else if (c instanceof Function) { - if (!c.name) { - throw new Error('function without name'); - } - e.addEventListener(c.name, c); - } - else if (Array.isArray(xc)) { - _domKids(e, c); - } - else if (xc._class) { - for (const s of xc._class) { - e.classList.toggle(s, true); - } - } - else if (xc._attrs) { - for (const k in xc._attrs) { - e.setAttribute(k, xc._attrs[k]); - } - } - else if (xc._styles) { - for (const k in xc._styles) { - const estyle = e.style; - estyle[k] = xc._styles[k]; - } - } - else if (xc._props) { - for (const k in xc._props) { - const eprops = e; - eprops[k] = xc._props[k]; - } - } - else if (xc.root) { - e.appendChild(xc.root); - } - else { - console.log('bad kid', c); - throw new Error('bad kid'); - } - }); - return e; - }; - const dom = { - _kids: function (e, ...kl) { - while (e.firstChild) { - e.removeChild(e.firstChild); - } - _domKids(e, kl); - }, - _attrs: (x) => { return { _attrs: x }; }, - _class: (...x) => { return { _class: x }; }, - // The createElement calls are spelled out so typescript can derive function - // signatures with a specific HTML*Element return type. - div: (...l) => _domKids(document.createElement('div'), l), - span: (...l) => _domKids(document.createElement('span'), l), - a: (...l) => _domKids(document.createElement('a'), l), - input: (...l) => _domKids(document.createElement('input'), l), - textarea: (...l) => _domKids(document.createElement('textarea'), l), - select: (...l) => _domKids(document.createElement('select'), l), - option: (...l) => _domKids(document.createElement('option'), l), - clickbutton: (...l) => _domKids(document.createElement('button'), [attr.type('button'), ...l]), - submitbutton: (...l) => _domKids(document.createElement('button'), [attr.type('submit'), ...l]), - form: (...l) => _domKids(document.createElement('form'), l), - fieldset: (...l) => _domKids(document.createElement('fieldset'), l), - table: (...l) => _domKids(document.createElement('table'), l), - thead: (...l) => _domKids(document.createElement('thead'), l), - tbody: (...l) => _domKids(document.createElement('tbody'), l), - tr: (...l) => _domKids(document.createElement('tr'), l), - td: (...l) => _domKids(document.createElement('td'), l), - th: (...l) => _domKids(document.createElement('th'), l), - datalist: (...l) => _domKids(document.createElement('datalist'), l), - h1: (...l) => _domKids(document.createElement('h1'), l), - h2: (...l) => _domKids(document.createElement('h2'), l), - br: (...l) => _domKids(document.createElement('br'), l), - hr: (...l) => _domKids(document.createElement('hr'), l), - pre: (...l) => _domKids(document.createElement('pre'), l), - label: (...l) => _domKids(document.createElement('label'), l), - ul: (...l) => _domKids(document.createElement('ul'), l), - li: (...l) => _domKids(document.createElement('li'), l), - iframe: (...l) => _domKids(document.createElement('iframe'), l), - b: (...l) => _domKids(document.createElement('b'), l), - img: (...l) => _domKids(document.createElement('img'), l), - style: (...l) => _domKids(document.createElement('style'), l), - search: (...l) => _domKids(document.createElement('search'), l), - }; - const _attr = (k, v) => { const o = {}; o[k] = v; return { _attrs: o }; }; - const attr = { - title: (s) => _attr('title', s), - value: (s) => _attr('value', s), - type: (s) => _attr('type', s), - tabindex: (s) => _attr('tabindex', s), - src: (s) => _attr('src', s), - placeholder: (s) => _attr('placeholder', s), - href: (s) => _attr('href', s), - checked: (s) => _attr('checked', s), - selected: (s) => _attr('selected', s), - id: (s) => _attr('id', s), - datalist: (s) => _attr('datalist', s), - rows: (s) => _attr('rows', s), - target: (s) => _attr('target', s), - rel: (s) => _attr('rel', s), - required: (s) => _attr('required', s), - multiple: (s) => _attr('multiple', s), - download: (s) => _attr('download', s), - disabled: (s) => _attr('disabled', s), - draggable: (s) => _attr('draggable', s), - rowspan: (s) => _attr('rowspan', s), - colspan: (s) => _attr('colspan', s), - for: (s) => _attr('for', s), - role: (s) => _attr('role', s), - arialabel: (s) => _attr('aria-label', s), - arialive: (s) => _attr('aria-live', s), - name: (s) => _attr('name', s) - }; - const style = (x) => { return { _styles: x }; }; - const prop = (x) => { return { _props: x }; }; - return [dom, style, attr, prop]; -})(); // For authentication/security results. const underlineGreen = '#50c40f'; const underlineRed = '#e15d1c'; diff --git a/webmail/text.js b/webmail/text.js index 3ff447e..072b554 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -1,4 +1,225 @@ "use strict"; +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. +const [dom, style, attr, prop] = (function () { + // Start of unicode block (rough approximation of script), from https://www.unicode.org/Public/UNIDATA/Blocks.txt + const scriptblocks = [0x0000, 0x0080, 0x0100, 0x0180, 0x0250, 0x02B0, 0x0300, 0x0370, 0x0400, 0x0500, 0x0530, 0x0590, 0x0600, 0x0700, 0x0750, 0x0780, 0x07C0, 0x0800, 0x0840, 0x0860, 0x0870, 0x08A0, 0x0900, 0x0980, 0x0A00, 0x0A80, 0x0B00, 0x0B80, 0x0C00, 0x0C80, 0x0D00, 0x0D80, 0x0E00, 0x0E80, 0x0F00, 0x1000, 0x10A0, 0x1100, 0x1200, 0x1380, 0x13A0, 0x1400, 0x1680, 0x16A0, 0x1700, 0x1720, 0x1740, 0x1760, 0x1780, 0x1800, 0x18B0, 0x1900, 0x1950, 0x1980, 0x19E0, 0x1A00, 0x1A20, 0x1AB0, 0x1B00, 0x1B80, 0x1BC0, 0x1C00, 0x1C50, 0x1C80, 0x1C90, 0x1CC0, 0x1CD0, 0x1D00, 0x1D80, 0x1DC0, 0x1E00, 0x1F00, 0x2000, 0x2070, 0x20A0, 0x20D0, 0x2100, 0x2150, 0x2190, 0x2200, 0x2300, 0x2400, 0x2440, 0x2460, 0x2500, 0x2580, 0x25A0, 0x2600, 0x2700, 0x27C0, 0x27F0, 0x2800, 0x2900, 0x2980, 0x2A00, 0x2B00, 0x2C00, 0x2C60, 0x2C80, 0x2D00, 0x2D30, 0x2D80, 0x2DE0, 0x2E00, 0x2E80, 0x2F00, 0x2FF0, 0x3000, 0x3040, 0x30A0, 0x3100, 0x3130, 0x3190, 0x31A0, 0x31C0, 0x31F0, 0x3200, 0x3300, 0x3400, 0x4DC0, 0x4E00, 0xA000, 0xA490, 0xA4D0, 0xA500, 0xA640, 0xA6A0, 0xA700, 0xA720, 0xA800, 0xA830, 0xA840, 0xA880, 0xA8E0, 0xA900, 0xA930, 0xA960, 0xA980, 0xA9E0, 0xAA00, 0xAA60, 0xAA80, 0xAAE0, 0xAB00, 0xAB30, 0xAB70, 0xABC0, 0xAC00, 0xD7B0, 0xD800, 0xDB80, 0xDC00, 0xE000, 0xF900, 0xFB00, 0xFB50, 0xFE00, 0xFE10, 0xFE20, 0xFE30, 0xFE50, 0xFE70, 0xFF00, 0xFFF0, 0x10000, 0x10080, 0x10100, 0x10140, 0x10190, 0x101D0, 0x10280, 0x102A0, 0x102E0, 0x10300, 0x10330, 0x10350, 0x10380, 0x103A0, 0x10400, 0x10450, 0x10480, 0x104B0, 0x10500, 0x10530, 0x10570, 0x10600, 0x10780, 0x10800, 0x10840, 0x10860, 0x10880, 0x108E0, 0x10900, 0x10920, 0x10980, 0x109A0, 0x10A00, 0x10A60, 0x10A80, 0x10AC0, 0x10B00, 0x10B40, 0x10B60, 0x10B80, 0x10C00, 0x10C80, 0x10D00, 0x10E60, 0x10E80, 0x10EC0, 0x10F00, 0x10F30, 0x10F70, 0x10FB0, 0x10FE0, 0x11000, 0x11080, 0x110D0, 0x11100, 0x11150, 0x11180, 0x111E0, 0x11200, 0x11280, 0x112B0, 0x11300, 0x11400, 0x11480, 0x11580, 0x11600, 0x11660, 0x11680, 0x11700, 0x11800, 0x118A0, 0x11900, 0x119A0, 0x11A00, 0x11A50, 0x11AB0, 0x11AC0, 0x11B00, 0x11C00, 0x11C70, 0x11D00, 0x11D60, 0x11EE0, 0x11F00, 0x11FB0, 0x11FC0, 0x12000, 0x12400, 0x12480, 0x12F90, 0x13000, 0x13430, 0x14400, 0x16800, 0x16A40, 0x16A70, 0x16AD0, 0x16B00, 0x16E40, 0x16F00, 0x16FE0, 0x17000, 0x18800, 0x18B00, 0x18D00, 0x1AFF0, 0x1B000, 0x1B100, 0x1B130, 0x1B170, 0x1BC00, 0x1BCA0, 0x1CF00, 0x1D000, 0x1D100, 0x1D200, 0x1D2C0, 0x1D2E0, 0x1D300, 0x1D360, 0x1D400, 0x1D800, 0x1DF00, 0x1E000, 0x1E030, 0x1E100, 0x1E290, 0x1E2C0, 0x1E4D0, 0x1E7E0, 0x1E800, 0x1E900, 0x1EC70, 0x1ED00, 0x1EE00, 0x1F000, 0x1F030, 0x1F0A0, 0x1F100, 0x1F200, 0x1F300, 0x1F600, 0x1F650, 0x1F680, 0x1F700, 0x1F780, 0x1F800, 0x1F900, 0x1FA00, 0x1FA70, 0x1FB00, 0x20000, 0x2A700, 0x2B740, 0x2B820, 0x2CEB0, 0x2F800, 0x30000, 0x31350, 0xE0000, 0xE0100, 0xF0000, 0x100000]; + // Find block code belongs in. + const findBlock = (code) => { + let s = 0; + let e = scriptblocks.length; + while (s < e - 1) { + let i = Math.floor((s + e) / 2); + if (code < scriptblocks[i]) { + e = i; + } + else { + s = i; + } + } + return s; + }; + // formatText adds s to element e, in a way that makes switching unicode scripts + // clear, with alternating DOM TextNode and span elements with a "switchscript" + // class. Useful for highlighting look alikes, e.g. a (ascii 0x61) and а (cyrillic + // 0x430). + // + // This is only called one string at a time, so the UI can still display strings + // without highlighting switching scripts, by calling formatText on the parts. + const formatText = (e, s) => { + // Handle some common cases quickly. + if (!s) { + return; + } + let ascii = true; + for (const c of s) { + const cp = c.codePointAt(0); // For typescript, to check for undefined. + if (cp !== undefined && cp >= 0x0080) { + ascii = false; + break; + } + } + if (ascii) { + e.appendChild(document.createTextNode(s)); + return; + } + // todo: handle grapheme clusters? wait for Intl.Segmenter? + let n = 0; // Number of text/span parts added. + let str = ''; // Collected so far. + let block = -1; // Previous block/script. + let mod = 1; + const put = (nextblock) => { + if (n === 0 && nextblock === 0) { + // Start was non-ascii, second block is ascii, we'll start marked as switched. + mod = 0; + } + if (n % 2 === mod) { + const x = document.createElement('span'); + x.classList.add('scriptswitch'); + x.appendChild(document.createTextNode(str)); + e.appendChild(x); + } + else { + e.appendChild(document.createTextNode(str)); + } + n++; + str = ''; + }; + for (const c of s) { + // Basic whitespace does not switch blocks. Will probably need to extend with more + // punctuation in the future. Possibly for digits too. But perhaps not in all + // scripts. + if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { + str += c; + continue; + } + const code = c.codePointAt(0); + if (block < 0 || !(code >= scriptblocks[block] && (code < scriptblocks[block + 1] || block === scriptblocks.length - 1))) { + const nextblock = code < 0x0080 ? 0 : findBlock(code); + if (block >= 0) { + put(nextblock); + } + block = nextblock; + } + str += c; + } + put(-1); + }; + const _domKids = (e, l) => { + l.forEach((c) => { + const xc = c; + if (typeof c === 'string') { + formatText(e, c); + } + else if (c instanceof String) { + // String is an escape-hatch for text that should not be formatted with + // unicode-block-change-highlighting, e.g. for textarea values. + e.appendChild(document.createTextNode('' + c)); + } + else if (c instanceof Element) { + e.appendChild(c); + } + else if (c instanceof Function) { + if (!c.name) { + throw new Error('function without name'); + } + e.addEventListener(c.name, c); + } + else if (Array.isArray(xc)) { + _domKids(e, c); + } + else if (xc._class) { + for (const s of xc._class) { + e.classList.toggle(s, true); + } + } + else if (xc._attrs) { + for (const k in xc._attrs) { + e.setAttribute(k, xc._attrs[k]); + } + } + else if (xc._styles) { + for (const k in xc._styles) { + const estyle = e.style; + estyle[k] = xc._styles[k]; + } + } + else if (xc._props) { + for (const k in xc._props) { + const eprops = e; + eprops[k] = xc._props[k]; + } + } + else if (xc.root) { + e.appendChild(xc.root); + } + else { + console.log('bad kid', c); + throw new Error('bad kid'); + } + }); + return e; + }; + const dom = { + _kids: function (e, ...kl) { + while (e.firstChild) { + e.removeChild(e.firstChild); + } + _domKids(e, kl); + }, + _attrs: (x) => { return { _attrs: x }; }, + _class: (...x) => { return { _class: x }; }, + // The createElement calls are spelled out so typescript can derive function + // signatures with a specific HTML*Element return type. + div: (...l) => _domKids(document.createElement('div'), l), + span: (...l) => _domKids(document.createElement('span'), l), + a: (...l) => _domKids(document.createElement('a'), l), + input: (...l) => _domKids(document.createElement('input'), l), + textarea: (...l) => _domKids(document.createElement('textarea'), l), + select: (...l) => _domKids(document.createElement('select'), l), + option: (...l) => _domKids(document.createElement('option'), l), + clickbutton: (...l) => _domKids(document.createElement('button'), [attr.type('button'), ...l]), + submitbutton: (...l) => _domKids(document.createElement('button'), [attr.type('submit'), ...l]), + form: (...l) => _domKids(document.createElement('form'), l), + fieldset: (...l) => _domKids(document.createElement('fieldset'), l), + table: (...l) => _domKids(document.createElement('table'), l), + thead: (...l) => _domKids(document.createElement('thead'), l), + tbody: (...l) => _domKids(document.createElement('tbody'), l), + tfoot: (...l) => _domKids(document.createElement('tfoot'), l), + tr: (...l) => _domKids(document.createElement('tr'), l), + td: (...l) => _domKids(document.createElement('td'), l), + th: (...l) => _domKids(document.createElement('th'), l), + datalist: (...l) => _domKids(document.createElement('datalist'), l), + h1: (...l) => _domKids(document.createElement('h1'), l), + h2: (...l) => _domKids(document.createElement('h2'), l), + h3: (...l) => _domKids(document.createElement('h3'), l), + br: (...l) => _domKids(document.createElement('br'), l), + hr: (...l) => _domKids(document.createElement('hr'), l), + pre: (...l) => _domKids(document.createElement('pre'), l), + label: (...l) => _domKids(document.createElement('label'), l), + ul: (...l) => _domKids(document.createElement('ul'), l), + li: (...l) => _domKids(document.createElement('li'), l), + iframe: (...l) => _domKids(document.createElement('iframe'), l), + b: (...l) => _domKids(document.createElement('b'), l), + img: (...l) => _domKids(document.createElement('img'), l), + style: (...l) => _domKids(document.createElement('style'), l), + search: (...l) => _domKids(document.createElement('search'), l), + p: (...l) => _domKids(document.createElement('p'), l), + }; + const _attr = (k, v) => { const o = {}; o[k] = v; return { _attrs: o }; }; + const attr = { + title: (s) => _attr('title', s), + value: (s) => _attr('value', s), + type: (s) => _attr('type', s), + tabindex: (s) => _attr('tabindex', s), + src: (s) => _attr('src', s), + placeholder: (s) => _attr('placeholder', s), + href: (s) => _attr('href', s), + checked: (s) => _attr('checked', s), + selected: (s) => _attr('selected', s), + id: (s) => _attr('id', s), + datalist: (s) => _attr('datalist', s), + rows: (s) => _attr('rows', s), + target: (s) => _attr('target', s), + rel: (s) => _attr('rel', s), + required: (s) => _attr('required', s), + multiple: (s) => _attr('multiple', s), + download: (s) => _attr('download', s), + disabled: (s) => _attr('disabled', s), + draggable: (s) => _attr('draggable', s), + rowspan: (s) => _attr('rowspan', s), + colspan: (s) => _attr('colspan', s), + for: (s) => _attr('for', s), + role: (s) => _attr('role', s), + arialabel: (s) => _attr('aria-label', s), + arialive: (s) => _attr('aria-live', s), + name: (s) => _attr('name', s), + min: (s) => _attr('min', s), + max: (s) => _attr('max', s), + }; + const style = (x) => { return { _styles: x }; }; + const prop = (x) => { return { _props: x }; }; + return [dom, style, attr, prop]; +})(); // NOTE: GENERATED by github.com/mjl-/sherpats, DO NOT MODIFY var api; (function (api) { @@ -645,221 +866,6 @@ var api; }; })(api || (api = {})); // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. -const [dom, style, attr, prop] = (function () { - // Start of unicode block (rough approximation of script), from https://www.unicode.org/Public/UNIDATA/Blocks.txt - const scriptblocks = [0x0000, 0x0080, 0x0100, 0x0180, 0x0250, 0x02B0, 0x0300, 0x0370, 0x0400, 0x0500, 0x0530, 0x0590, 0x0600, 0x0700, 0x0750, 0x0780, 0x07C0, 0x0800, 0x0840, 0x0860, 0x0870, 0x08A0, 0x0900, 0x0980, 0x0A00, 0x0A80, 0x0B00, 0x0B80, 0x0C00, 0x0C80, 0x0D00, 0x0D80, 0x0E00, 0x0E80, 0x0F00, 0x1000, 0x10A0, 0x1100, 0x1200, 0x1380, 0x13A0, 0x1400, 0x1680, 0x16A0, 0x1700, 0x1720, 0x1740, 0x1760, 0x1780, 0x1800, 0x18B0, 0x1900, 0x1950, 0x1980, 0x19E0, 0x1A00, 0x1A20, 0x1AB0, 0x1B00, 0x1B80, 0x1BC0, 0x1C00, 0x1C50, 0x1C80, 0x1C90, 0x1CC0, 0x1CD0, 0x1D00, 0x1D80, 0x1DC0, 0x1E00, 0x1F00, 0x2000, 0x2070, 0x20A0, 0x20D0, 0x2100, 0x2150, 0x2190, 0x2200, 0x2300, 0x2400, 0x2440, 0x2460, 0x2500, 0x2580, 0x25A0, 0x2600, 0x2700, 0x27C0, 0x27F0, 0x2800, 0x2900, 0x2980, 0x2A00, 0x2B00, 0x2C00, 0x2C60, 0x2C80, 0x2D00, 0x2D30, 0x2D80, 0x2DE0, 0x2E00, 0x2E80, 0x2F00, 0x2FF0, 0x3000, 0x3040, 0x30A0, 0x3100, 0x3130, 0x3190, 0x31A0, 0x31C0, 0x31F0, 0x3200, 0x3300, 0x3400, 0x4DC0, 0x4E00, 0xA000, 0xA490, 0xA4D0, 0xA500, 0xA640, 0xA6A0, 0xA700, 0xA720, 0xA800, 0xA830, 0xA840, 0xA880, 0xA8E0, 0xA900, 0xA930, 0xA960, 0xA980, 0xA9E0, 0xAA00, 0xAA60, 0xAA80, 0xAAE0, 0xAB00, 0xAB30, 0xAB70, 0xABC0, 0xAC00, 0xD7B0, 0xD800, 0xDB80, 0xDC00, 0xE000, 0xF900, 0xFB00, 0xFB50, 0xFE00, 0xFE10, 0xFE20, 0xFE30, 0xFE50, 0xFE70, 0xFF00, 0xFFF0, 0x10000, 0x10080, 0x10100, 0x10140, 0x10190, 0x101D0, 0x10280, 0x102A0, 0x102E0, 0x10300, 0x10330, 0x10350, 0x10380, 0x103A0, 0x10400, 0x10450, 0x10480, 0x104B0, 0x10500, 0x10530, 0x10570, 0x10600, 0x10780, 0x10800, 0x10840, 0x10860, 0x10880, 0x108E0, 0x10900, 0x10920, 0x10980, 0x109A0, 0x10A00, 0x10A60, 0x10A80, 0x10AC0, 0x10B00, 0x10B40, 0x10B60, 0x10B80, 0x10C00, 0x10C80, 0x10D00, 0x10E60, 0x10E80, 0x10EC0, 0x10F00, 0x10F30, 0x10F70, 0x10FB0, 0x10FE0, 0x11000, 0x11080, 0x110D0, 0x11100, 0x11150, 0x11180, 0x111E0, 0x11200, 0x11280, 0x112B0, 0x11300, 0x11400, 0x11480, 0x11580, 0x11600, 0x11660, 0x11680, 0x11700, 0x11800, 0x118A0, 0x11900, 0x119A0, 0x11A00, 0x11A50, 0x11AB0, 0x11AC0, 0x11B00, 0x11C00, 0x11C70, 0x11D00, 0x11D60, 0x11EE0, 0x11F00, 0x11FB0, 0x11FC0, 0x12000, 0x12400, 0x12480, 0x12F90, 0x13000, 0x13430, 0x14400, 0x16800, 0x16A40, 0x16A70, 0x16AD0, 0x16B00, 0x16E40, 0x16F00, 0x16FE0, 0x17000, 0x18800, 0x18B00, 0x18D00, 0x1AFF0, 0x1B000, 0x1B100, 0x1B130, 0x1B170, 0x1BC00, 0x1BCA0, 0x1CF00, 0x1D000, 0x1D100, 0x1D200, 0x1D2C0, 0x1D2E0, 0x1D300, 0x1D360, 0x1D400, 0x1D800, 0x1DF00, 0x1E000, 0x1E030, 0x1E100, 0x1E290, 0x1E2C0, 0x1E4D0, 0x1E7E0, 0x1E800, 0x1E900, 0x1EC70, 0x1ED00, 0x1EE00, 0x1F000, 0x1F030, 0x1F0A0, 0x1F100, 0x1F200, 0x1F300, 0x1F600, 0x1F650, 0x1F680, 0x1F700, 0x1F780, 0x1F800, 0x1F900, 0x1FA00, 0x1FA70, 0x1FB00, 0x20000, 0x2A700, 0x2B740, 0x2B820, 0x2CEB0, 0x2F800, 0x30000, 0x31350, 0xE0000, 0xE0100, 0xF0000, 0x100000]; - // Find block code belongs in. - const findBlock = (code) => { - let s = 0; - let e = scriptblocks.length; - while (s < e - 1) { - let i = Math.floor((s + e) / 2); - if (code < scriptblocks[i]) { - e = i; - } - else { - s = i; - } - } - return s; - }; - // formatText adds s to element e, in a way that makes switching unicode scripts - // clear, with alternating DOM TextNode and span elements with a "switchscript" - // class. Useful for highlighting look alikes, e.g. a (ascii 0x61) and а (cyrillic - // 0x430). - // - // This is only called one string at a time, so the UI can still display strings - // without highlighting switching scripts, by calling formatText on the parts. - const formatText = (e, s) => { - // Handle some common cases quickly. - if (!s) { - return; - } - let ascii = true; - for (const c of s) { - const cp = c.codePointAt(0); // For typescript, to check for undefined. - if (cp !== undefined && cp >= 0x0080) { - ascii = false; - break; - } - } - if (ascii) { - e.appendChild(document.createTextNode(s)); - return; - } - // todo: handle grapheme clusters? wait for Intl.Segmenter? - let n = 0; // Number of text/span parts added. - let str = ''; // Collected so far. - let block = -1; // Previous block/script. - let mod = 1; - const put = (nextblock) => { - if (n === 0 && nextblock === 0) { - // Start was non-ascii, second block is ascii, we'll start marked as switched. - mod = 0; - } - if (n % 2 === mod) { - const x = document.createElement('span'); - x.classList.add('scriptswitch'); - x.appendChild(document.createTextNode(str)); - e.appendChild(x); - } - else { - e.appendChild(document.createTextNode(str)); - } - n++; - str = ''; - }; - for (const c of s) { - // Basic whitespace does not switch blocks. Will probably need to extend with more - // punctuation in the future. Possibly for digits too. But perhaps not in all - // scripts. - if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { - str += c; - continue; - } - const code = c.codePointAt(0); - if (block < 0 || !(code >= scriptblocks[block] && (code < scriptblocks[block + 1] || block === scriptblocks.length - 1))) { - const nextblock = code < 0x0080 ? 0 : findBlock(code); - if (block >= 0) { - put(nextblock); - } - block = nextblock; - } - str += c; - } - put(-1); - }; - const _domKids = (e, l) => { - l.forEach((c) => { - const xc = c; - if (typeof c === 'string') { - formatText(e, c); - } - else if (c instanceof String) { - // String is an escape-hatch for text that should not be formatted with - // unicode-block-change-highlighting, e.g. for textarea values. - e.appendChild(document.createTextNode('' + c)); - } - else if (c instanceof Element) { - e.appendChild(c); - } - else if (c instanceof Function) { - if (!c.name) { - throw new Error('function without name'); - } - e.addEventListener(c.name, c); - } - else if (Array.isArray(xc)) { - _domKids(e, c); - } - else if (xc._class) { - for (const s of xc._class) { - e.classList.toggle(s, true); - } - } - else if (xc._attrs) { - for (const k in xc._attrs) { - e.setAttribute(k, xc._attrs[k]); - } - } - else if (xc._styles) { - for (const k in xc._styles) { - const estyle = e.style; - estyle[k] = xc._styles[k]; - } - } - else if (xc._props) { - for (const k in xc._props) { - const eprops = e; - eprops[k] = xc._props[k]; - } - } - else if (xc.root) { - e.appendChild(xc.root); - } - else { - console.log('bad kid', c); - throw new Error('bad kid'); - } - }); - return e; - }; - const dom = { - _kids: function (e, ...kl) { - while (e.firstChild) { - e.removeChild(e.firstChild); - } - _domKids(e, kl); - }, - _attrs: (x) => { return { _attrs: x }; }, - _class: (...x) => { return { _class: x }; }, - // The createElement calls are spelled out so typescript can derive function - // signatures with a specific HTML*Element return type. - div: (...l) => _domKids(document.createElement('div'), l), - span: (...l) => _domKids(document.createElement('span'), l), - a: (...l) => _domKids(document.createElement('a'), l), - input: (...l) => _domKids(document.createElement('input'), l), - textarea: (...l) => _domKids(document.createElement('textarea'), l), - select: (...l) => _domKids(document.createElement('select'), l), - option: (...l) => _domKids(document.createElement('option'), l), - clickbutton: (...l) => _domKids(document.createElement('button'), [attr.type('button'), ...l]), - submitbutton: (...l) => _domKids(document.createElement('button'), [attr.type('submit'), ...l]), - form: (...l) => _domKids(document.createElement('form'), l), - fieldset: (...l) => _domKids(document.createElement('fieldset'), l), - table: (...l) => _domKids(document.createElement('table'), l), - thead: (...l) => _domKids(document.createElement('thead'), l), - tbody: (...l) => _domKids(document.createElement('tbody'), l), - tr: (...l) => _domKids(document.createElement('tr'), l), - td: (...l) => _domKids(document.createElement('td'), l), - th: (...l) => _domKids(document.createElement('th'), l), - datalist: (...l) => _domKids(document.createElement('datalist'), l), - h1: (...l) => _domKids(document.createElement('h1'), l), - h2: (...l) => _domKids(document.createElement('h2'), l), - br: (...l) => _domKids(document.createElement('br'), l), - hr: (...l) => _domKids(document.createElement('hr'), l), - pre: (...l) => _domKids(document.createElement('pre'), l), - label: (...l) => _domKids(document.createElement('label'), l), - ul: (...l) => _domKids(document.createElement('ul'), l), - li: (...l) => _domKids(document.createElement('li'), l), - iframe: (...l) => _domKids(document.createElement('iframe'), l), - b: (...l) => _domKids(document.createElement('b'), l), - img: (...l) => _domKids(document.createElement('img'), l), - style: (...l) => _domKids(document.createElement('style'), l), - search: (...l) => _domKids(document.createElement('search'), l), - }; - const _attr = (k, v) => { const o = {}; o[k] = v; return { _attrs: o }; }; - const attr = { - title: (s) => _attr('title', s), - value: (s) => _attr('value', s), - type: (s) => _attr('type', s), - tabindex: (s) => _attr('tabindex', s), - src: (s) => _attr('src', s), - placeholder: (s) => _attr('placeholder', s), - href: (s) => _attr('href', s), - checked: (s) => _attr('checked', s), - selected: (s) => _attr('selected', s), - id: (s) => _attr('id', s), - datalist: (s) => _attr('datalist', s), - rows: (s) => _attr('rows', s), - target: (s) => _attr('target', s), - rel: (s) => _attr('rel', s), - required: (s) => _attr('required', s), - multiple: (s) => _attr('multiple', s), - download: (s) => _attr('download', s), - disabled: (s) => _attr('disabled', s), - draggable: (s) => _attr('draggable', s), - rowspan: (s) => _attr('rowspan', s), - colspan: (s) => _attr('colspan', s), - for: (s) => _attr('for', s), - role: (s) => _attr('role', s), - arialabel: (s) => _attr('aria-label', s), - arialive: (s) => _attr('aria-live', s), - name: (s) => _attr('name', s) - }; - const style = (x) => { return { _styles: x }; }; - const prop = (x) => { return { _props: x }; }; - return [dom, style, attr, prop]; -})(); // For authentication/security results. const underlineGreen = '#50c40f'; const underlineRed = '#e15d1c'; diff --git a/webmail/view.go b/webmail/view.go index fc00bf1..ce7f912 100644 --- a/webmail/view.go +++ b/webmail/view.go @@ -584,7 +584,7 @@ func serveEvents(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *ht // We'll be sending quite a bit of message data (text) in JSON (plenty duplicate // keys), so should be quite compressible. var out writeFlusher - gz := acceptsGzip(r) + gz := mox.AcceptsGzip(r) if gz { h.Set("Content-Encoding", "gzip") out, _ = gzip.NewWriterLevel(w, gzip.BestSpeed) diff --git a/webmail/webmail.go b/webmail/webmail.go index dbcb3be..d520f6b 100644 --- a/webmail/webmail.go +++ b/webmail/webmail.go @@ -6,7 +6,6 @@ package webmail import ( "archive/zip" "bytes" - "compress/gzip" "context" "encoding/base64" "encoding/json" @@ -21,8 +20,6 @@ import ( "runtime/debug" "strconv" "strings" - "sync" - "time" _ "embed" @@ -40,7 +37,6 @@ import ( "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/moxio" - "github.com/mjl-/mox/moxvar" "github.com/mjl-/mox/store" "github.com/mjl-/mox/webaccount" ) @@ -150,159 +146,11 @@ func xdbread(ctx context.Context, acc *store.Account, fn func(tx *bstore.Tx)) { xcheckf(ctx, err, "transaction") } -// We merge the js into the html at first load, cache a gzipped version that is -// generated on first need, and respond with a Last-Modified header. For quickly -// serving a single, compressed, cacheable file. -type merged struct { - sync.Mutex - combined []byte - combinedGzip []byte - mtime time.Time // For Last-Modified and conditional request. - fallbackHTML, fallbackJS []byte // The embedded html/js files. - htmlPath, jsPath string // Paths used during development. -} - -var webmail = &merged{ - fallbackHTML: webmailHTML, - fallbackJS: webmailJS, - htmlPath: filepath.FromSlash("webmail/webmail.html"), - jsPath: filepath.FromSlash("webmail/webmail.js"), -} - -// fallbackMtime returns a time to use for the Last-Modified header in case we -// cannot find a file, e.g. when used in production. -func fallbackMtime(log mlog.Log) time.Time { - p, err := os.Executable() - log.Check(err, "finding executable for mtime") - if err == nil { - st, err := os.Stat(p) - log.Check(err, "stat on executable for mtime") - if err == nil { - return st.ModTime() - } - } - log.Info("cannot find executable for webmail mtime, using current time") - return time.Now() -} - -func (m *merged) serve(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *http.Request) { - // We typically return the embedded file, but during development it's handy - // to load from disk. - fhtml, _ := os.Open(m.htmlPath) - if fhtml != nil { - defer fhtml.Close() - } - fjs, _ := os.Open(m.jsPath) - if fjs != nil { - defer fjs.Close() - } - - html := m.fallbackHTML - js := m.fallbackJS - - var diskmtime time.Time - var refreshdisk bool - if fhtml != nil && fjs != nil { - sth, err := fhtml.Stat() - xcheckf(ctx, err, "stat html") - stj, err := fjs.Stat() - xcheckf(ctx, err, "stat js") - - maxmtime := sth.ModTime() - if stj.ModTime().After(maxmtime) { - maxmtime = stj.ModTime() - } - - m.Lock() - refreshdisk = maxmtime.After(m.mtime) || m.combined == nil - m.Unlock() - - if refreshdisk { - html, err = io.ReadAll(fhtml) - xcheckf(ctx, err, "reading html") - js, err = io.ReadAll(fjs) - xcheckf(ctx, err, "reading js") - diskmtime = maxmtime - } - } - - gz := acceptsGzip(r) - var out []byte - var mtime time.Time - var origSize int64 - - func() { - m.Lock() - defer m.Unlock() - - if refreshdisk || m.combined == nil { - script := []byte(``) - index := bytes.Index(html, script) - if index < 0 { - xcheckf(ctx, errors.New("script not found"), "generating combined html") - } - var b bytes.Buffer - b.Write(html[:index]) - fmt.Fprintf(&b, "") - b.Write(html[index+len(script):]) - out = b.Bytes() - m.combined = out - if refreshdisk { - m.mtime = diskmtime - } else { - m.mtime = fallbackMtime(log) - } - m.combinedGzip = nil - } else { - out = m.combined - } - if gz { - if m.combinedGzip == nil { - var b bytes.Buffer - gzw, err := gzip.NewWriterLevel(&b, gzip.BestCompression) - if err == nil { - _, err = gzw.Write(out) - } - if err == nil { - err = gzw.Close() - } - xcheckf(ctx, err, "gzipping combined html") - m.combinedGzip = b.Bytes() - } - origSize = int64(len(out)) - out = m.combinedGzip - } - mtime = m.mtime - }() - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - http.ServeContent(gzipInjector{w, gz, origSize}, r, "", mtime, bytes.NewReader(out)) -} - -// gzipInjector is a http.ResponseWriter that optionally injects a -// Content-Encoding: gzip header, only in case of status 200 OK. Used with -// http.ServeContent to serve gzipped content if the client supports it. We cannot -// just unconditionally add the content-encoding header, because we don't know -// enough if we will be sending data: http.ServeContent may be sending a "not -// modified" response, and possibly others. -type gzipInjector struct { - http.ResponseWriter // Keep most methods. - gz bool - origSize int64 -} - -// WriteHeader adds a Content-Encoding: gzip header before actually writing the -// headers and status. -func (w gzipInjector) WriteHeader(statusCode int) { - if w.gz && statusCode == http.StatusOK { - w.ResponseWriter.Header().Set("Content-Encoding", "gzip") - if lw, ok := w.ResponseWriter.(interface{ SetUncompressedSize(int64) }); ok { - lw.SetUncompressedSize(w.origSize) - } - } - w.ResponseWriter.WriteHeader(statusCode) +var webmailFile = &mox.WebappFile{ + HTML: webmailHTML, + JS: webmailJS, + HTMLPath: filepath.FromSlash("webmail/webmail.html"), + JSPath: filepath.FromSlash("webmail/webmail.js"), } // Serve content, either from a file, or return the fallback data. Caller @@ -319,7 +167,7 @@ func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request, return } } - http.ServeContent(w, r, "", fallbackMtime(log), bytes.NewReader(fallback)) + http.ServeContent(w, r, "", mox.FallbackMtime(log), bytes.NewReader(fallback)) } // Handler returns a handler for the webmail endpoints, customized for the max @@ -388,7 +236,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) { case "GET", "HEAD": } - webmail.serve(ctx, log, w, r) + webmailFile.Serve(ctx, log, w, r) return case "/msg.js", "/text.js": @@ -904,22 +752,6 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) { } } -func acceptsGzip(r *http.Request) bool { - s := r.Header.Get("Accept-Encoding") - t := strings.Split(s, ",") - for _, e := range t { - e = strings.TrimSpace(e) - tt := strings.Split(e, ";") - if len(tt) > 1 && t[1] == "q=0" { - continue - } - if tt[0] == "gzip" { - return true - } - } - return false -} - // inlineSanitizeHTML writes the part as HTML, with "cid:" URIs for html "src" // attributes inlined and with potentially dangerous tags removed (javascript). The // sanitizing is just a first layer of defense, CSP headers block execution of diff --git a/webmail/webmail.js b/webmail/webmail.js index 4fc7b44..7fe03b2 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -1,4 +1,225 @@ "use strict"; +// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. +const [dom, style, attr, prop] = (function () { + // Start of unicode block (rough approximation of script), from https://www.unicode.org/Public/UNIDATA/Blocks.txt + const scriptblocks = [0x0000, 0x0080, 0x0100, 0x0180, 0x0250, 0x02B0, 0x0300, 0x0370, 0x0400, 0x0500, 0x0530, 0x0590, 0x0600, 0x0700, 0x0750, 0x0780, 0x07C0, 0x0800, 0x0840, 0x0860, 0x0870, 0x08A0, 0x0900, 0x0980, 0x0A00, 0x0A80, 0x0B00, 0x0B80, 0x0C00, 0x0C80, 0x0D00, 0x0D80, 0x0E00, 0x0E80, 0x0F00, 0x1000, 0x10A0, 0x1100, 0x1200, 0x1380, 0x13A0, 0x1400, 0x1680, 0x16A0, 0x1700, 0x1720, 0x1740, 0x1760, 0x1780, 0x1800, 0x18B0, 0x1900, 0x1950, 0x1980, 0x19E0, 0x1A00, 0x1A20, 0x1AB0, 0x1B00, 0x1B80, 0x1BC0, 0x1C00, 0x1C50, 0x1C80, 0x1C90, 0x1CC0, 0x1CD0, 0x1D00, 0x1D80, 0x1DC0, 0x1E00, 0x1F00, 0x2000, 0x2070, 0x20A0, 0x20D0, 0x2100, 0x2150, 0x2190, 0x2200, 0x2300, 0x2400, 0x2440, 0x2460, 0x2500, 0x2580, 0x25A0, 0x2600, 0x2700, 0x27C0, 0x27F0, 0x2800, 0x2900, 0x2980, 0x2A00, 0x2B00, 0x2C00, 0x2C60, 0x2C80, 0x2D00, 0x2D30, 0x2D80, 0x2DE0, 0x2E00, 0x2E80, 0x2F00, 0x2FF0, 0x3000, 0x3040, 0x30A0, 0x3100, 0x3130, 0x3190, 0x31A0, 0x31C0, 0x31F0, 0x3200, 0x3300, 0x3400, 0x4DC0, 0x4E00, 0xA000, 0xA490, 0xA4D0, 0xA500, 0xA640, 0xA6A0, 0xA700, 0xA720, 0xA800, 0xA830, 0xA840, 0xA880, 0xA8E0, 0xA900, 0xA930, 0xA960, 0xA980, 0xA9E0, 0xAA00, 0xAA60, 0xAA80, 0xAAE0, 0xAB00, 0xAB30, 0xAB70, 0xABC0, 0xAC00, 0xD7B0, 0xD800, 0xDB80, 0xDC00, 0xE000, 0xF900, 0xFB00, 0xFB50, 0xFE00, 0xFE10, 0xFE20, 0xFE30, 0xFE50, 0xFE70, 0xFF00, 0xFFF0, 0x10000, 0x10080, 0x10100, 0x10140, 0x10190, 0x101D0, 0x10280, 0x102A0, 0x102E0, 0x10300, 0x10330, 0x10350, 0x10380, 0x103A0, 0x10400, 0x10450, 0x10480, 0x104B0, 0x10500, 0x10530, 0x10570, 0x10600, 0x10780, 0x10800, 0x10840, 0x10860, 0x10880, 0x108E0, 0x10900, 0x10920, 0x10980, 0x109A0, 0x10A00, 0x10A60, 0x10A80, 0x10AC0, 0x10B00, 0x10B40, 0x10B60, 0x10B80, 0x10C00, 0x10C80, 0x10D00, 0x10E60, 0x10E80, 0x10EC0, 0x10F00, 0x10F30, 0x10F70, 0x10FB0, 0x10FE0, 0x11000, 0x11080, 0x110D0, 0x11100, 0x11150, 0x11180, 0x111E0, 0x11200, 0x11280, 0x112B0, 0x11300, 0x11400, 0x11480, 0x11580, 0x11600, 0x11660, 0x11680, 0x11700, 0x11800, 0x118A0, 0x11900, 0x119A0, 0x11A00, 0x11A50, 0x11AB0, 0x11AC0, 0x11B00, 0x11C00, 0x11C70, 0x11D00, 0x11D60, 0x11EE0, 0x11F00, 0x11FB0, 0x11FC0, 0x12000, 0x12400, 0x12480, 0x12F90, 0x13000, 0x13430, 0x14400, 0x16800, 0x16A40, 0x16A70, 0x16AD0, 0x16B00, 0x16E40, 0x16F00, 0x16FE0, 0x17000, 0x18800, 0x18B00, 0x18D00, 0x1AFF0, 0x1B000, 0x1B100, 0x1B130, 0x1B170, 0x1BC00, 0x1BCA0, 0x1CF00, 0x1D000, 0x1D100, 0x1D200, 0x1D2C0, 0x1D2E0, 0x1D300, 0x1D360, 0x1D400, 0x1D800, 0x1DF00, 0x1E000, 0x1E030, 0x1E100, 0x1E290, 0x1E2C0, 0x1E4D0, 0x1E7E0, 0x1E800, 0x1E900, 0x1EC70, 0x1ED00, 0x1EE00, 0x1F000, 0x1F030, 0x1F0A0, 0x1F100, 0x1F200, 0x1F300, 0x1F600, 0x1F650, 0x1F680, 0x1F700, 0x1F780, 0x1F800, 0x1F900, 0x1FA00, 0x1FA70, 0x1FB00, 0x20000, 0x2A700, 0x2B740, 0x2B820, 0x2CEB0, 0x2F800, 0x30000, 0x31350, 0xE0000, 0xE0100, 0xF0000, 0x100000]; + // Find block code belongs in. + const findBlock = (code) => { + let s = 0; + let e = scriptblocks.length; + while (s < e - 1) { + let i = Math.floor((s + e) / 2); + if (code < scriptblocks[i]) { + e = i; + } + else { + s = i; + } + } + return s; + }; + // formatText adds s to element e, in a way that makes switching unicode scripts + // clear, with alternating DOM TextNode and span elements with a "switchscript" + // class. Useful for highlighting look alikes, e.g. a (ascii 0x61) and а (cyrillic + // 0x430). + // + // This is only called one string at a time, so the UI can still display strings + // without highlighting switching scripts, by calling formatText on the parts. + const formatText = (e, s) => { + // Handle some common cases quickly. + if (!s) { + return; + } + let ascii = true; + for (const c of s) { + const cp = c.codePointAt(0); // For typescript, to check for undefined. + if (cp !== undefined && cp >= 0x0080) { + ascii = false; + break; + } + } + if (ascii) { + e.appendChild(document.createTextNode(s)); + return; + } + // todo: handle grapheme clusters? wait for Intl.Segmenter? + let n = 0; // Number of text/span parts added. + let str = ''; // Collected so far. + let block = -1; // Previous block/script. + let mod = 1; + const put = (nextblock) => { + if (n === 0 && nextblock === 0) { + // Start was non-ascii, second block is ascii, we'll start marked as switched. + mod = 0; + } + if (n % 2 === mod) { + const x = document.createElement('span'); + x.classList.add('scriptswitch'); + x.appendChild(document.createTextNode(str)); + e.appendChild(x); + } + else { + e.appendChild(document.createTextNode(str)); + } + n++; + str = ''; + }; + for (const c of s) { + // Basic whitespace does not switch blocks. Will probably need to extend with more + // punctuation in the future. Possibly for digits too. But perhaps not in all + // scripts. + if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { + str += c; + continue; + } + const code = c.codePointAt(0); + if (block < 0 || !(code >= scriptblocks[block] && (code < scriptblocks[block + 1] || block === scriptblocks.length - 1))) { + const nextblock = code < 0x0080 ? 0 : findBlock(code); + if (block >= 0) { + put(nextblock); + } + block = nextblock; + } + str += c; + } + put(-1); + }; + const _domKids = (e, l) => { + l.forEach((c) => { + const xc = c; + if (typeof c === 'string') { + formatText(e, c); + } + else if (c instanceof String) { + // String is an escape-hatch for text that should not be formatted with + // unicode-block-change-highlighting, e.g. for textarea values. + e.appendChild(document.createTextNode('' + c)); + } + else if (c instanceof Element) { + e.appendChild(c); + } + else if (c instanceof Function) { + if (!c.name) { + throw new Error('function without name'); + } + e.addEventListener(c.name, c); + } + else if (Array.isArray(xc)) { + _domKids(e, c); + } + else if (xc._class) { + for (const s of xc._class) { + e.classList.toggle(s, true); + } + } + else if (xc._attrs) { + for (const k in xc._attrs) { + e.setAttribute(k, xc._attrs[k]); + } + } + else if (xc._styles) { + for (const k in xc._styles) { + const estyle = e.style; + estyle[k] = xc._styles[k]; + } + } + else if (xc._props) { + for (const k in xc._props) { + const eprops = e; + eprops[k] = xc._props[k]; + } + } + else if (xc.root) { + e.appendChild(xc.root); + } + else { + console.log('bad kid', c); + throw new Error('bad kid'); + } + }); + return e; + }; + const dom = { + _kids: function (e, ...kl) { + while (e.firstChild) { + e.removeChild(e.firstChild); + } + _domKids(e, kl); + }, + _attrs: (x) => { return { _attrs: x }; }, + _class: (...x) => { return { _class: x }; }, + // The createElement calls are spelled out so typescript can derive function + // signatures with a specific HTML*Element return type. + div: (...l) => _domKids(document.createElement('div'), l), + span: (...l) => _domKids(document.createElement('span'), l), + a: (...l) => _domKids(document.createElement('a'), l), + input: (...l) => _domKids(document.createElement('input'), l), + textarea: (...l) => _domKids(document.createElement('textarea'), l), + select: (...l) => _domKids(document.createElement('select'), l), + option: (...l) => _domKids(document.createElement('option'), l), + clickbutton: (...l) => _domKids(document.createElement('button'), [attr.type('button'), ...l]), + submitbutton: (...l) => _domKids(document.createElement('button'), [attr.type('submit'), ...l]), + form: (...l) => _domKids(document.createElement('form'), l), + fieldset: (...l) => _domKids(document.createElement('fieldset'), l), + table: (...l) => _domKids(document.createElement('table'), l), + thead: (...l) => _domKids(document.createElement('thead'), l), + tbody: (...l) => _domKids(document.createElement('tbody'), l), + tfoot: (...l) => _domKids(document.createElement('tfoot'), l), + tr: (...l) => _domKids(document.createElement('tr'), l), + td: (...l) => _domKids(document.createElement('td'), l), + th: (...l) => _domKids(document.createElement('th'), l), + datalist: (...l) => _domKids(document.createElement('datalist'), l), + h1: (...l) => _domKids(document.createElement('h1'), l), + h2: (...l) => _domKids(document.createElement('h2'), l), + h3: (...l) => _domKids(document.createElement('h3'), l), + br: (...l) => _domKids(document.createElement('br'), l), + hr: (...l) => _domKids(document.createElement('hr'), l), + pre: (...l) => _domKids(document.createElement('pre'), l), + label: (...l) => _domKids(document.createElement('label'), l), + ul: (...l) => _domKids(document.createElement('ul'), l), + li: (...l) => _domKids(document.createElement('li'), l), + iframe: (...l) => _domKids(document.createElement('iframe'), l), + b: (...l) => _domKids(document.createElement('b'), l), + img: (...l) => _domKids(document.createElement('img'), l), + style: (...l) => _domKids(document.createElement('style'), l), + search: (...l) => _domKids(document.createElement('search'), l), + p: (...l) => _domKids(document.createElement('p'), l), + }; + const _attr = (k, v) => { const o = {}; o[k] = v; return { _attrs: o }; }; + const attr = { + title: (s) => _attr('title', s), + value: (s) => _attr('value', s), + type: (s) => _attr('type', s), + tabindex: (s) => _attr('tabindex', s), + src: (s) => _attr('src', s), + placeholder: (s) => _attr('placeholder', s), + href: (s) => _attr('href', s), + checked: (s) => _attr('checked', s), + selected: (s) => _attr('selected', s), + id: (s) => _attr('id', s), + datalist: (s) => _attr('datalist', s), + rows: (s) => _attr('rows', s), + target: (s) => _attr('target', s), + rel: (s) => _attr('rel', s), + required: (s) => _attr('required', s), + multiple: (s) => _attr('multiple', s), + download: (s) => _attr('download', s), + disabled: (s) => _attr('disabled', s), + draggable: (s) => _attr('draggable', s), + rowspan: (s) => _attr('rowspan', s), + colspan: (s) => _attr('colspan', s), + for: (s) => _attr('for', s), + role: (s) => _attr('role', s), + arialabel: (s) => _attr('aria-label', s), + arialive: (s) => _attr('aria-live', s), + name: (s) => _attr('name', s), + min: (s) => _attr('min', s), + max: (s) => _attr('max', s), + }; + const style = (x) => { return { _styles: x }; }; + const prop = (x) => { return { _props: x }; }; + return [dom, style, attr, prop]; +})(); // NOTE: GENERATED by github.com/mjl-/sherpats, DO NOT MODIFY var api; (function (api) { @@ -645,221 +866,6 @@ var api; }; })(api || (api = {})); // Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten. -const [dom, style, attr, prop] = (function () { - // Start of unicode block (rough approximation of script), from https://www.unicode.org/Public/UNIDATA/Blocks.txt - const scriptblocks = [0x0000, 0x0080, 0x0100, 0x0180, 0x0250, 0x02B0, 0x0300, 0x0370, 0x0400, 0x0500, 0x0530, 0x0590, 0x0600, 0x0700, 0x0750, 0x0780, 0x07C0, 0x0800, 0x0840, 0x0860, 0x0870, 0x08A0, 0x0900, 0x0980, 0x0A00, 0x0A80, 0x0B00, 0x0B80, 0x0C00, 0x0C80, 0x0D00, 0x0D80, 0x0E00, 0x0E80, 0x0F00, 0x1000, 0x10A0, 0x1100, 0x1200, 0x1380, 0x13A0, 0x1400, 0x1680, 0x16A0, 0x1700, 0x1720, 0x1740, 0x1760, 0x1780, 0x1800, 0x18B0, 0x1900, 0x1950, 0x1980, 0x19E0, 0x1A00, 0x1A20, 0x1AB0, 0x1B00, 0x1B80, 0x1BC0, 0x1C00, 0x1C50, 0x1C80, 0x1C90, 0x1CC0, 0x1CD0, 0x1D00, 0x1D80, 0x1DC0, 0x1E00, 0x1F00, 0x2000, 0x2070, 0x20A0, 0x20D0, 0x2100, 0x2150, 0x2190, 0x2200, 0x2300, 0x2400, 0x2440, 0x2460, 0x2500, 0x2580, 0x25A0, 0x2600, 0x2700, 0x27C0, 0x27F0, 0x2800, 0x2900, 0x2980, 0x2A00, 0x2B00, 0x2C00, 0x2C60, 0x2C80, 0x2D00, 0x2D30, 0x2D80, 0x2DE0, 0x2E00, 0x2E80, 0x2F00, 0x2FF0, 0x3000, 0x3040, 0x30A0, 0x3100, 0x3130, 0x3190, 0x31A0, 0x31C0, 0x31F0, 0x3200, 0x3300, 0x3400, 0x4DC0, 0x4E00, 0xA000, 0xA490, 0xA4D0, 0xA500, 0xA640, 0xA6A0, 0xA700, 0xA720, 0xA800, 0xA830, 0xA840, 0xA880, 0xA8E0, 0xA900, 0xA930, 0xA960, 0xA980, 0xA9E0, 0xAA00, 0xAA60, 0xAA80, 0xAAE0, 0xAB00, 0xAB30, 0xAB70, 0xABC0, 0xAC00, 0xD7B0, 0xD800, 0xDB80, 0xDC00, 0xE000, 0xF900, 0xFB00, 0xFB50, 0xFE00, 0xFE10, 0xFE20, 0xFE30, 0xFE50, 0xFE70, 0xFF00, 0xFFF0, 0x10000, 0x10080, 0x10100, 0x10140, 0x10190, 0x101D0, 0x10280, 0x102A0, 0x102E0, 0x10300, 0x10330, 0x10350, 0x10380, 0x103A0, 0x10400, 0x10450, 0x10480, 0x104B0, 0x10500, 0x10530, 0x10570, 0x10600, 0x10780, 0x10800, 0x10840, 0x10860, 0x10880, 0x108E0, 0x10900, 0x10920, 0x10980, 0x109A0, 0x10A00, 0x10A60, 0x10A80, 0x10AC0, 0x10B00, 0x10B40, 0x10B60, 0x10B80, 0x10C00, 0x10C80, 0x10D00, 0x10E60, 0x10E80, 0x10EC0, 0x10F00, 0x10F30, 0x10F70, 0x10FB0, 0x10FE0, 0x11000, 0x11080, 0x110D0, 0x11100, 0x11150, 0x11180, 0x111E0, 0x11200, 0x11280, 0x112B0, 0x11300, 0x11400, 0x11480, 0x11580, 0x11600, 0x11660, 0x11680, 0x11700, 0x11800, 0x118A0, 0x11900, 0x119A0, 0x11A00, 0x11A50, 0x11AB0, 0x11AC0, 0x11B00, 0x11C00, 0x11C70, 0x11D00, 0x11D60, 0x11EE0, 0x11F00, 0x11FB0, 0x11FC0, 0x12000, 0x12400, 0x12480, 0x12F90, 0x13000, 0x13430, 0x14400, 0x16800, 0x16A40, 0x16A70, 0x16AD0, 0x16B00, 0x16E40, 0x16F00, 0x16FE0, 0x17000, 0x18800, 0x18B00, 0x18D00, 0x1AFF0, 0x1B000, 0x1B100, 0x1B130, 0x1B170, 0x1BC00, 0x1BCA0, 0x1CF00, 0x1D000, 0x1D100, 0x1D200, 0x1D2C0, 0x1D2E0, 0x1D300, 0x1D360, 0x1D400, 0x1D800, 0x1DF00, 0x1E000, 0x1E030, 0x1E100, 0x1E290, 0x1E2C0, 0x1E4D0, 0x1E7E0, 0x1E800, 0x1E900, 0x1EC70, 0x1ED00, 0x1EE00, 0x1F000, 0x1F030, 0x1F0A0, 0x1F100, 0x1F200, 0x1F300, 0x1F600, 0x1F650, 0x1F680, 0x1F700, 0x1F780, 0x1F800, 0x1F900, 0x1FA00, 0x1FA70, 0x1FB00, 0x20000, 0x2A700, 0x2B740, 0x2B820, 0x2CEB0, 0x2F800, 0x30000, 0x31350, 0xE0000, 0xE0100, 0xF0000, 0x100000]; - // Find block code belongs in. - const findBlock = (code) => { - let s = 0; - let e = scriptblocks.length; - while (s < e - 1) { - let i = Math.floor((s + e) / 2); - if (code < scriptblocks[i]) { - e = i; - } - else { - s = i; - } - } - return s; - }; - // formatText adds s to element e, in a way that makes switching unicode scripts - // clear, with alternating DOM TextNode and span elements with a "switchscript" - // class. Useful for highlighting look alikes, e.g. a (ascii 0x61) and а (cyrillic - // 0x430). - // - // This is only called one string at a time, so the UI can still display strings - // without highlighting switching scripts, by calling formatText on the parts. - const formatText = (e, s) => { - // Handle some common cases quickly. - if (!s) { - return; - } - let ascii = true; - for (const c of s) { - const cp = c.codePointAt(0); // For typescript, to check for undefined. - if (cp !== undefined && cp >= 0x0080) { - ascii = false; - break; - } - } - if (ascii) { - e.appendChild(document.createTextNode(s)); - return; - } - // todo: handle grapheme clusters? wait for Intl.Segmenter? - let n = 0; // Number of text/span parts added. - let str = ''; // Collected so far. - let block = -1; // Previous block/script. - let mod = 1; - const put = (nextblock) => { - if (n === 0 && nextblock === 0) { - // Start was non-ascii, second block is ascii, we'll start marked as switched. - mod = 0; - } - if (n % 2 === mod) { - const x = document.createElement('span'); - x.classList.add('scriptswitch'); - x.appendChild(document.createTextNode(str)); - e.appendChild(x); - } - else { - e.appendChild(document.createTextNode(str)); - } - n++; - str = ''; - }; - for (const c of s) { - // Basic whitespace does not switch blocks. Will probably need to extend with more - // punctuation in the future. Possibly for digits too. But perhaps not in all - // scripts. - if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { - str += c; - continue; - } - const code = c.codePointAt(0); - if (block < 0 || !(code >= scriptblocks[block] && (code < scriptblocks[block + 1] || block === scriptblocks.length - 1))) { - const nextblock = code < 0x0080 ? 0 : findBlock(code); - if (block >= 0) { - put(nextblock); - } - block = nextblock; - } - str += c; - } - put(-1); - }; - const _domKids = (e, l) => { - l.forEach((c) => { - const xc = c; - if (typeof c === 'string') { - formatText(e, c); - } - else if (c instanceof String) { - // String is an escape-hatch for text that should not be formatted with - // unicode-block-change-highlighting, e.g. for textarea values. - e.appendChild(document.createTextNode('' + c)); - } - else if (c instanceof Element) { - e.appendChild(c); - } - else if (c instanceof Function) { - if (!c.name) { - throw new Error('function without name'); - } - e.addEventListener(c.name, c); - } - else if (Array.isArray(xc)) { - _domKids(e, c); - } - else if (xc._class) { - for (const s of xc._class) { - e.classList.toggle(s, true); - } - } - else if (xc._attrs) { - for (const k in xc._attrs) { - e.setAttribute(k, xc._attrs[k]); - } - } - else if (xc._styles) { - for (const k in xc._styles) { - const estyle = e.style; - estyle[k] = xc._styles[k]; - } - } - else if (xc._props) { - for (const k in xc._props) { - const eprops = e; - eprops[k] = xc._props[k]; - } - } - else if (xc.root) { - e.appendChild(xc.root); - } - else { - console.log('bad kid', c); - throw new Error('bad kid'); - } - }); - return e; - }; - const dom = { - _kids: function (e, ...kl) { - while (e.firstChild) { - e.removeChild(e.firstChild); - } - _domKids(e, kl); - }, - _attrs: (x) => { return { _attrs: x }; }, - _class: (...x) => { return { _class: x }; }, - // The createElement calls are spelled out so typescript can derive function - // signatures with a specific HTML*Element return type. - div: (...l) => _domKids(document.createElement('div'), l), - span: (...l) => _domKids(document.createElement('span'), l), - a: (...l) => _domKids(document.createElement('a'), l), - input: (...l) => _domKids(document.createElement('input'), l), - textarea: (...l) => _domKids(document.createElement('textarea'), l), - select: (...l) => _domKids(document.createElement('select'), l), - option: (...l) => _domKids(document.createElement('option'), l), - clickbutton: (...l) => _domKids(document.createElement('button'), [attr.type('button'), ...l]), - submitbutton: (...l) => _domKids(document.createElement('button'), [attr.type('submit'), ...l]), - form: (...l) => _domKids(document.createElement('form'), l), - fieldset: (...l) => _domKids(document.createElement('fieldset'), l), - table: (...l) => _domKids(document.createElement('table'), l), - thead: (...l) => _domKids(document.createElement('thead'), l), - tbody: (...l) => _domKids(document.createElement('tbody'), l), - tr: (...l) => _domKids(document.createElement('tr'), l), - td: (...l) => _domKids(document.createElement('td'), l), - th: (...l) => _domKids(document.createElement('th'), l), - datalist: (...l) => _domKids(document.createElement('datalist'), l), - h1: (...l) => _domKids(document.createElement('h1'), l), - h2: (...l) => _domKids(document.createElement('h2'), l), - br: (...l) => _domKids(document.createElement('br'), l), - hr: (...l) => _domKids(document.createElement('hr'), l), - pre: (...l) => _domKids(document.createElement('pre'), l), - label: (...l) => _domKids(document.createElement('label'), l), - ul: (...l) => _domKids(document.createElement('ul'), l), - li: (...l) => _domKids(document.createElement('li'), l), - iframe: (...l) => _domKids(document.createElement('iframe'), l), - b: (...l) => _domKids(document.createElement('b'), l), - img: (...l) => _domKids(document.createElement('img'), l), - style: (...l) => _domKids(document.createElement('style'), l), - search: (...l) => _domKids(document.createElement('search'), l), - }; - const _attr = (k, v) => { const o = {}; o[k] = v; return { _attrs: o }; }; - const attr = { - title: (s) => _attr('title', s), - value: (s) => _attr('value', s), - type: (s) => _attr('type', s), - tabindex: (s) => _attr('tabindex', s), - src: (s) => _attr('src', s), - placeholder: (s) => _attr('placeholder', s), - href: (s) => _attr('href', s), - checked: (s) => _attr('checked', s), - selected: (s) => _attr('selected', s), - id: (s) => _attr('id', s), - datalist: (s) => _attr('datalist', s), - rows: (s) => _attr('rows', s), - target: (s) => _attr('target', s), - rel: (s) => _attr('rel', s), - required: (s) => _attr('required', s), - multiple: (s) => _attr('multiple', s), - download: (s) => _attr('download', s), - disabled: (s) => _attr('disabled', s), - draggable: (s) => _attr('draggable', s), - rowspan: (s) => _attr('rowspan', s), - colspan: (s) => _attr('colspan', s), - for: (s) => _attr('for', s), - role: (s) => _attr('role', s), - arialabel: (s) => _attr('aria-label', s), - arialive: (s) => _attr('aria-live', s), - name: (s) => _attr('name', s) - }; - const style = (x) => { return { _styles: x }; }; - const prop = (x) => { return { _props: x }; }; - return [dom, style, attr, prop]; -})(); // For authentication/security results. const underlineGreen = '#50c40f'; const underlineRed = '#e15d1c';